Compare commits

...

35 Commits

Author SHA1 Message Date
lemon 0e723b1c94 Refine community chat message bubbles 2026-05-11 10:49:05 -07:00
lemon b1b9695500 Refine community chat and member workflows 2026-05-09 15:45:05 -07:00
lemon 482f28bf16 Enhance community chat composer 2026-05-08 15:51:23 -07:00
lemon c82990d6e3 Refine community chat layout 2026-05-08 00:06:57 -07:00
lemon 5abd88deb6 Add community chat tab 2026-05-07 23:42:25 -07:00
lemon 2fac32e7e9 Fix world feed refresh dependencies 2026-05-07 23:13:35 -07:00
lemon 0de2c40477 Rename fundraising goals to goals 2026-05-07 23:10:57 -07:00
lemon 19ce62a0f4 Add community member feed tab 2026-05-07 22:54:40 -07:00
lemon b9e9e6cc02 Portal tooltip overlays above sidebars 2026-05-07 22:54:40 -07:00
lemon f79f33e16a Refresh goal progress after zaps 2026-05-07 22:54:40 -07:00
lemon ac72e99359 Check member badge identifier collisions 2026-05-07 22:54:40 -07:00
lemon ee1908e3b8 Refresh community caches after member updates 2026-05-07 22:54:40 -07:00
lemon f57b4bcf54 Page community activity streams independently 2026-05-07 22:54:40 -07:00
lemon 1105553d62 Prevent banned community moderators from acting 2026-05-07 22:54:40 -07:00
lemon e683b23912 Page community awards and reports exhaustively
Introduce queryAll, a portable helper that exhausts a Nostr filter by
paging with the until cursor, capped at 5,000 events / 10 pages so
worst-case cost stays bounded. Works against any relay regardless of
its internal page size.

Migrate useCommunityMembers and useCommunityActivityFeed so membership
and moderation state are complete for any community that fits within
the cap, instead of silently truncating at 500 events.
2026-05-07 22:54:40 -07:00
lemon bd1b1bd056 Tighten flat community primitives
Extract isAuthorizedAward helper as the single source of truth for
membership award validation, used by both resolveMembership and
useMyCommunities. Simplify resolveCommunityModeration by dropping
the dead banned-reporter guard from pass 1 (impossible under strict
rank ordering). Flip useMembersOnlyFilter default to opt-in to match
the spec's MAY wording, and reword the NIP to match.
2026-05-07 22:54:40 -07:00
lemon 0d4477469e Clean up flat community language 2026-05-07 22:54:40 -07:00
lemon c81c1f2020 Flatten community membership resolution 2026-05-07 22:54:40 -07:00
lemon ab363be77f Document flat community membership 2026-05-07 22:54:40 -07:00
lemon 0731545c0d Add calendar event editing 2026-05-07 22:54:40 -07:00
lemon 9b2c8abac6 Share image upload field across dialogs 2026-05-07 22:54:40 -07:00
lemon 9f6838af0c Add image uploads to event creation 2026-05-07 22:54:40 -07:00
lemon 25836c8c61 Add engagement actions to calendar events 2026-05-07 22:54:40 -07:00
lemon 418c56db13 Add RSVP controls to calendar event details
- Rename tentative label to 'Interested' (Facebook-style, Star icon)
- Auto-enroll event authors as 'accepted' when publishing
- Let authors change their own RSVP from the detail page
- Restyle RSVP section to match About/Attendees headers
- Remove optional note field; click a button to submit immediately
- Move Attendees above RSVP
2026-05-07 22:54:40 -07:00
lemon 25a72a79a5 Use event dialog on events page 2026-05-07 22:54:40 -07:00
lemon a962017f70 Add community event creation dialog 2026-05-07 22:54:40 -07:00
lemon 66907790c1 Add community events tab 2026-05-07 22:54:40 -07:00
lemon 7aa9a3dbaf Improve community bookmark reliability 2026-05-07 22:54:40 -07:00
lemon 5905cdf726 Add bookmark toggle to community detail page top bar
Places a NIP-51 kind 10004 bookmark button between the edit and share
buttons so users can save a community while viewing it, not just from
the feed card's more-menu.
2026-05-07 22:54:40 -07:00
lemon 951356d66c Show bookmarked communities in My Communities via NIP-51 kind 10004
Bookmarking a kind 34550 community now writes to the NIP-51 Communities
list (kind 10004) keyed by the addressable coordinate, so the reference
stays valid across community updates. My Communities merges bookmarked
communities as a third discovery source alongside founded and member-of,
with Founder/Member/Bookmarked badges on each card.

Bookmark toasts live on the mutation itself so they survive the more-menu
dialog unmounting between .mutate() and publish resolution.
2026-05-07 22:54:40 -07:00
lemon 81089c7e9f fix: remove duplicate community share action 2026-05-07 22:54:40 -07:00
lemon 88bce08b13 feat: add community editing 2026-05-07 22:54:40 -07:00
lemon c0f843e6ec fix: improve community member management 2026-05-07 22:54:40 -07:00
lemon cc4b075c8a refactor: split community creation into two steps
- CreateCommunityDialog now only publishes kind 34550 (name, image, description)
- New AddMemberDialog on the community detail page handles membership:
  - Founder can add moderators and members
  - Moderators can add members only
  - Badge definition (kind 30009) created lazily on first member add
  - Community definition republished once with all changes batched
  - Kind 8 badge awards published for each member
- Add Members button on Members tab, visible to rank 0 users
- Search dropdown moved outside ScrollArea to prevent clipping
2026-05-07 22:54:40 -07:00
lemon 07d8917813 feat: add community creation flow and improve discovery UX
- Add CreateCommunityDialog with name, image upload, description, and moderator type-ahead search
- Publish kind 30009 badge definition + kind 34550 community definition with d-tag collision check
- Context-aware FAB on My Communities tab opens the create dialog
- Default Search page tab to Communities instead of Posts
- Add Search to default sidebar order for new accounts
- Improve empty states on both Activities and My Communities tabs to guide users toward discovery
2026-05-07 22:54:40 -07:00
40 changed files with 3751 additions and 908 deletions
+99 -96
View File
@@ -21,7 +21,24 @@
| 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 Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
### Community Chat
Agora uses NIP-53 live chat messages (`kind:1311`) for realtime chat inside a NIP-72 community. Messages are scoped directly to the community definition's address using an `a` tag:
```json
{
"kind": 1311,
"content": "Hello community!",
"tags": [
["a", "34550:<community-author-pubkey>:<community-d-tag>", "", "root"]
]
}
```
Clients SHOULD query community chat with `{ "kinds": [1311], "#a": ["34550:<pubkey>:<d-tag>"] }`. Agora treats sending as members-only at the UI layer and applies the same community moderation overlay used for community posts.
### Community Kinds
@@ -435,9 +452,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 +471,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 +505,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 +530,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 +540,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 +550,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 +646,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 +676,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 +703,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 +715,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 +727,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 +752,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 +776,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
+613
View File
@@ -0,0 +1,613 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { UserPlus, Loader2, X, Search, Crown, Users } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ImageUploadField } from '@/components/ImageUploadField';
import { getAvatarShape } from '@/lib/avatarShape';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { genUserName } from '@/lib/genUserName';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import {
COMMUNITY_DEFINITION_KIND,
BADGE_DEFINITION_KIND,
BADGE_AWARD_KIND,
EMPTY_MODERATION,
type CommunityMember,
type CommunityMembership,
type CommunityModeration,
type ParsedCommunity,
} from '@/lib/communityUtils';
import { cn } from '@/lib/utils';
// ── Types ─────────────────────────────────────────────────────────────────────
type MemberRole = 'moderator' | 'member';
interface PendingMember {
profile: SearchProfile;
role: MemberRole;
}
interface CommunityMembersCacheValue {
membership: CommunityMembership;
moderation: CommunityModeration;
rankMap: Map<string, CommunityMember>;
}
interface AddMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The raw community definition event. */
communityEvent: NostrEvent;
/** Parsed community data. */
community: ParsedCommunity;
/** Whether the current user is the founder (can add moderators). */
isFounder: boolean;
/** Existing active members and moderators, excluded from duplicate adds. */
existingMemberPubkeys: string[];
}
// ── Component ─────────────────────────────────────────────────────────────────
export function AddMemberDialog({
open,
onOpenChange,
communityEvent,
community,
isFounder,
existingMemberPubkeys,
}: AddMemberDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
// Form state
const [pendingMembers, setPendingMembers] = useState<PendingMember[]>([]);
const [badgeImageUrl, setBadgeImageUrl] = useState('');
const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Does this community already have a member badge definition?
const existingBadgeATag = community.memberBadgeATag;
const hasBadge = !!existingBadgeATag;
// Are there any pending members with the "member" role?
const hasPendingMembers = pendingMembers.some((m) => m.role === 'member');
// Will we need to create a badge? (members added + no badge exists yet)
const needsBadgeCreation = hasPendingMembers && !hasBadge;
const resetForm = useCallback(() => {
setPendingMembers([]);
setBadgeImageUrl('');
setIsBadgeImageUploading(false);
setIsPublishing(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
// ── People management ─────────────────────────────────────────────────────
const addPerson = useCallback((profile: SearchProfile) => {
if (!user) return;
if (profile.pubkey === community.founderPubkey) {
toast({ title: 'Already the founder' });
return;
}
if (existingMemberPubkeys.includes(profile.pubkey)) {
toast({ title: 'Already in the community' });
return;
}
if (pendingMembers.some((m) => m.profile.pubkey === profile.pubkey)) {
toast({ title: 'Already added' });
return;
}
// Default role: member if they're not already a moderator, moderator if founder is adding
const defaultRole: MemberRole = isFounder ? 'moderator' : 'member';
setPendingMembers((prev) => [...prev, { profile, role: defaultRole }]);
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, isFounder, toast]);
const removePerson = useCallback((pubkey: string) => {
setPendingMembers((prev) => prev.filter((m) => m.profile.pubkey !== pubkey));
}, []);
const toggleRole = useCallback((pubkey: string) => {
if (!isFounder) return; // Only founder can toggle to moderator
setPendingMembers((prev) => prev.map((m) =>
m.profile.pubkey === pubkey
? { ...m, role: m.role === 'moderator' ? 'member' : 'moderator' }
: m,
));
}, [isFounder]);
const applyOptimisticMembership = useCallback((members: PendingMember[], awardEvents: Map<string, NostrEvent>) => {
queryClient.setQueryData<CommunityMembersCacheValue>(['community-members', community.aTag], (prev) => {
const moderation = prev?.moderation ?? EMPTY_MODERATION;
const rankMap = new Map(prev?.rankMap ?? []);
const membershipByPubkey = new Map(
(prev?.membership.members ?? []).map((member) => [member.pubkey, member] as const),
);
const seedRankZero = (pubkey: string) => {
if (moderation.bannedPubkeys.has(pubkey)) return;
const member: CommunityMember = { pubkey, rank: 0 };
if (!membershipByPubkey.has(pubkey)) membershipByPubkey.set(pubkey, member);
if (!rankMap.has(pubkey)) rankMap.set(pubkey, member);
};
seedRankZero(community.founderPubkey);
community.moderatorPubkeys.forEach(seedRankZero);
for (const pending of members) {
if (moderation.bannedPubkeys.has(pending.profile.pubkey)) continue;
const nextMember: CommunityMember = pending.role === 'moderator'
? { pubkey: pending.profile.pubkey, rank: 0 }
: {
pubkey: pending.profile.pubkey,
rank: 1,
awardEvent: awardEvents.get(pending.profile.pubkey),
awardedBy: user?.pubkey,
};
const current = membershipByPubkey.get(nextMember.pubkey);
if (!current || nextMember.rank < current.rank) {
membershipByPubkey.set(nextMember.pubkey, nextMember);
}
const currentRank = rankMap.get(nextMember.pubkey);
if (!currentRank || nextMember.rank < currentRank.rank) {
rankMap.set(nextMember.pubkey, nextMember);
}
}
const membership: CommunityMembership = {
members: Array.from(membershipByPubkey.values()).sort((a, b) => a.rank - b.rank),
};
return { membership, moderation, rankMap };
});
}, [community.aTag, community.founderPubkey, community.moderatorPubkeys, queryClient, user?.pubkey]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleSubmit = useCallback(async () => {
if (!user || pendingMembers.length === 0) return;
if (isBadgeImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (badgeImageUrl.trim() && !sanitizeUrl(badgeImageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
if (needsBadgeCreation && !isFounder) {
toast({ title: 'Member badge is missing', description: 'Only the founder can initialize community membership.', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
const newModerators = pendingMembers.filter((m) => m.role === 'moderator');
const newMembers = pendingMembers.filter((m) => m.role === 'member');
let badgeATag = existingBadgeATag;
// Step 1: Create badge definition if needed
if (newMembers.length > 0 && !hasBadge) {
const badgeDTag = `${community.dTag}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'This community needs a member badge, but that badge identifier already exists on your account.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeTags: string[][] = [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${community.name}`],
];
const sanitizedBadgeImage = sanitizeUrl(badgeImageUrl.trim());
if (sanitizedBadgeImage) {
badgeTags.push(['image', sanitizedBadgeImage, '1024x1024']);
}
badgeTags.push(['alt', `Badge definition: Member of ${community.name}`]);
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: badgeTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`;
}
// Step 2: Republish community definition if needed
// Needed when: adding moderators (new p tags) OR badge was just created (new a tag)
const needsCommunityUpdate = newModerators.length > 0 || (newMembers.length > 0 && !hasBadge);
if (needsCommunityUpdate) {
// Fetch fresh community event to avoid stale overwrites
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const baseTags = prev?.tags ?? communityEvent.tags;
const updatedTags = [...baseTags];
// Add new moderator p tags
for (const mod of newModerators) {
// Don't add if already exists
const exists = updatedTags.some(
([n, v, , role]) => n === 'p' && v === mod.profile.pubkey && role === 'moderator',
);
if (!exists) {
updatedTags.push(['p', mod.profile.pubkey, '', 'moderator']);
}
}
// Add badge a tag if badge was just created
if (badgeATag && !hasBadge) {
updatedTags.push(['a', badgeATag, '', 'member']);
}
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? '',
tags: updatedTags,
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.setQueryData(['event', updatedEvent.id], updatedEvent);
}
// Step 3: Publish badge awards for each member
const memberAwardEvents = new Map<string, NostrEvent>();
if (newMembers.length > 0 && badgeATag) {
for (const member of newMembers) {
const awardEvent = await publishEvent({
kind: BADGE_AWARD_KIND,
content: '',
tags: [
['a', badgeATag],
['p', member.profile.pubkey],
['alt', `Badge award: Member in ${community.name}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
memberAwardEvents.set(member.profile.pubkey, awardEvent);
}
}
applyOptimisticMembership(pendingMembers, memberAwardEvents);
queryClient.invalidateQueries({ queryKey: ['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag] });
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag] });
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
if (!hasBadge && newMembers.length > 0) {
queryClient.invalidateQueries({ queryKey: ['badge-feed'] });
}
const addedCount = pendingMembers.length;
toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` });
handleOpenChange(false);
} catch (err) {
toast({
title: 'Failed to add members',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, pendingMembers, existingBadgeATag, hasBadge, needsBadgeCreation, isFounder, community, communityEvent,
badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, applyOptimisticMembership, isBadgeImageUploading,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
<PortalContainerProvider value={portalContainer}>
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<UserPlus className="size-5 text-primary" />
Add Members
</DialogTitle>
<DialogDescription>
{isFounder
? 'Add moderators or members to your community.'
: 'Invite members to the community.'}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5 space-y-4">
{/* People search */}
<div className="space-y-1.5">
<Label>Search people</Label>
<PersonSearch
onAdd={addPerson}
excludePubkeys={[
community.founderPubkey,
...existingMemberPubkeys,
...pendingMembers.map((m) => m.profile.pubkey),
]}
/>
</div>
{/* Pending members list */}
{pendingMembers.length > 0 && (
<div className="space-y-1.5">
<Label>
People to add
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
</Label>
<div className="space-y-1">
{pendingMembers.map((pm) => (
<PendingMemberChip
key={pm.profile.pubkey}
pending={pm}
onRemove={removePerson}
onToggleRole={isFounder ? toggleRole : undefined}
/>
))}
</div>
</div>
)}
{/* Badge image — only shown when badge needs to be created */}
{needsBadgeCreation && (
<ImageUploadField
id="member-badge-image"
label={<>Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
value={badgeImageUrl}
onChange={setBadgeImageUrl}
onUploadingChange={setIsBadgeImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Badge preview"
objectFit="contain"
dropAreaClassName="min-h-24"
/>
)}
{/* Submit button */}
<Button
onClick={handleSubmit}
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> Adding...</>
) : (
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
)}
</Button>
</div>
</ScrollArea>
</PortalContainerProvider>
</DialogContent>
</Dialog>
);
}
// ── Sub-Components ────────────────────────────────────────────────────────────
/** Inline type-ahead person search. */
function PersonSearch({
onAdd,
excludePubkeys,
}: {
onAdd: (profile: SearchProfile) => void;
excludePubkeys: string[];
}) {
const [query, setQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: profiles, isFetching } = useSearchProfiles(query);
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
const filteredProfiles = useMemo(
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
[profiles, excludeSet],
);
useEffect(() => {
if (query.trim().length > 0 && filteredProfiles.length > 0) {
setDropdownOpen(true);
} else if (query.trim().length === 0) {
setDropdownOpen(false);
}
}, [filteredProfiles, query]);
const handleSelect = useCallback((profile: SearchProfile) => {
onAdd(profile);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, [onAdd]);
return (
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isFetching && query.trim() && (
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
)}
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => {
if (query.trim().length > 0 && filteredProfiles.length > 0) {
setDropdownOpen(true);
}
}}
placeholder="Search people or paste npub..."
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>
);
}
+151 -191
View File
@@ -1,34 +1,28 @@
import { useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { CalendarDays, MapPin, Clock, Users } from 'lucide-react';
import { CalendarDays, Clock, MapPin, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { NoteContent } from '@/components/NoteContent';
import { Badge } from '@/components/ui/badge';
import { RSVPAvatars } from '@/components/RSVPAvatars';
import { Badge } from '@/components/ui/badge';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface CalendarEventContentProps {
event: NostrEvent;
/** When true, limits the description to 2 lines for compact feed display. */
/** When true, renders a compact feed card. */
compact?: boolean;
className?: string;
}
/** Extract the first value for a given tag name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Collect all values for a repeated tag name. */
function getAllTags(tags: string[][], name: string): string[][] {
return tags.filter(([n]) => n === name);
}
/**
* Parse a location tag value. Some clients encode location as JSON
* (e.g. `{"description":"Riga, Latvia","coordinates":{"lat":56.9,"lon":24.1}}`).
* Extract a human-readable string when possible, otherwise return the raw value.
*/
function parseLocation(raw: string): string {
const trimmed = raw.trim();
if (!trimmed.startsWith('{')) return raw;
@@ -43,14 +37,12 @@ function parseLocation(raw: string): string {
return raw;
}
/** Date-only formatter: "Jan 15, 2026" */
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
/** Date+time formatter: "Jan 15, 2026 at 3:00 PM" */
const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
@@ -59,52 +51,35 @@ const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
minute: '2-digit',
});
/** Time-only formatter: "3:00 PM" */
const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
});
/** Check if two dates fall on the same calendar day. */
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate()
);
}
/**
* Format the date/time display for a NIP-52 calendar event.
*
* Kind 31922 (date-based): "Jan 15, 2026" or "Jan 15 - Jan 17, 2026"
* Kind 31923 (time-based): "Jan 15, 2026 at 3:00 PM" or time ranges
*/
function formatEventDate(event: NostrEvent): string {
const start = getTag(event.tags, 'start');
if (!start) return '';
if (event.kind === 31922) {
// Date-based: start/end are YYYY-MM-DD strings
// Parse as UTC to avoid timezone shifting the date
const startDate = new Date(start + 'T00:00:00Z');
const startDate = new Date(`${start}T00:00:00Z`);
if (isNaN(startDate.getTime())) return start;
const end = getTag(event.tags, 'end');
if (end) {
const endDate = new Date(end + 'T00:00:00Z');
const endDate = new Date(`${end}T00:00:00Z`);
if (!isNaN(endDate.getTime()) && endDate > startDate) {
// Multi-day range: "Jan 15 - Jan 17, 2026"
// NIP-52: end date is exclusive, so display the last inclusive day
const lastDay = new Date(endDate.getTime() - 86400000);
if (lastDay > startDate) {
const startParts = dateFormatter.formatToParts(startDate);
const startStr = startParts
.filter((p) => p.type !== 'year' && p.type !== 'literal' || p.value === ' ')
.map((p) => (p.type === 'literal' && p.value.includes(',') ? '' : p.value))
.join('')
.trim();
return `${startStr} ${dateFormatter.format(lastDay)}`;
const startStr = dateFormatter.format(startDate).replace(/, \d{4}$/, '');
return `${startStr} - ${dateFormatter.format(lastDay)}`;
}
}
}
@@ -113,7 +88,6 @@ function formatEventDate(event: NostrEvent): string {
}
if (event.kind === 31923) {
// Time-based: start/end are Unix timestamps
const startTs = parseInt(start, 10);
if (isNaN(startTs)) return start;
const startDate = new Date(startTs * 1000);
@@ -123,13 +97,10 @@ function formatEventDate(event: NostrEvent): string {
const endTs = parseInt(end, 10);
if (!isNaN(endTs) && endTs > startTs) {
const endDate = new Date(endTs * 1000);
if (isSameDay(startDate, endDate)) {
// Same day: "Jan 15, 2026 at 3:00 PM 5:00 PM"
return `${dateTimeFormatter.format(startDate)} ${timeFormatter.format(endDate)}`;
return `${dateTimeFormatter.format(startDate)} - ${timeFormatter.format(endDate)}`;
}
// Different days: "Jan 15, 2026 at 3:00 PM Jan 16, 2026 at 5:00 PM"
return `${dateTimeFormatter.format(startDate)} ${dateTimeFormatter.format(endDate)}`;
return `${dateTimeFormatter.format(startDate)} - ${dateTimeFormatter.format(endDate)}`;
}
}
@@ -139,173 +110,162 @@ function formatEventDate(event: NostrEvent): string {
return start;
}
function getEventEndTimestamp(event: NostrEvent): number {
const start = getTag(event.tags, 'start');
if (!start) return 0;
if (event.kind === 31922) {
const end = getTag(event.tags, 'end');
const endDate = new Date(`${end || start}T00:00:00Z`);
if (isNaN(endDate.getTime())) return 0;
return Math.floor(endDate.getTime() / 1000) + (end ? 0 : 86400);
}
const end = getTag(event.tags, 'end') || start;
const endTs = parseInt(end, 10);
return isNaN(endTs) ? 0 : endTs;
}
/** Renders NIP-52 calendar event content (kind 31922 and 31923). */
export function CalendarEventContent({ event, compact, className }: CalendarEventContentProps) {
const title = useMemo(() => getTag(event.tags, 'title'), [event.tags]);
const image = useMemo(() => getTag(event.tags, 'image'), [event.tags]);
const image = useMemo(() => sanitizeUrl(getTag(event.tags, 'image')), [event.tags]);
const locationRaw = useMemo(() => getTag(event.tags, 'location'), [event.tags]);
const location = useMemo(() => locationRaw ? parseLocation(locationRaw) : undefined, [locationRaw]);
const dateDisplay = useMemo(() => formatEventDate(event), [event]);
const hashtags = useMemo(() => getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean), [event.tags]);
const participants = useMemo(() => getAllTags(event.tags, 'p'), [event.tags]);
const hasContent = event.content.trim().length > 0;
const summary = useMemo(() => getTag(event.tags, 'summary'), [event.tags]);
const ended = useMemo(() => getEventEndTimestamp(event) < Math.floor(Date.now() / 1000), [event]);
const hasContent = event.content.trim().length > 0;
const participantPubkeys = useMemo(
() => participants.map(([, pubkey]) => pubkey).filter(Boolean),
[participants],
);
if (compact) {
return (
<div className={cn('mt-3 space-y-3', className)}>
{image && (
<div className="relative -mx-4 aspect-[21/9] overflow-hidden">
<img src={image} alt={title ?? 'Calendar event'} className="w-full h-full object-cover" loading="lazy" />
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
)}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<CalendarDays className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-[15px] leading-tight line-clamp-2">{title ?? 'Untitled event'}</h3>
</div>
{ended ? (
<Badge variant="secondary" className="shrink-0">Ended</Badge>
) : dateDisplay ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 max-w-[45%]">
<Clock className="size-3" />
<span className="truncate">{dateDisplay}</span>
</span>
) : null}
</div>
{dateDisplay && ended && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="size-3" />
<span>{dateDisplay}</span>
</div>
)}
{(summary || hasContent) && (
<div className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
{summary && !hasContent ? (
<p>{summary}</p>
) : (
<NoteContent event={event} className="text-sm" hideEmbedImages />
)}
</div>
)}
{(location || participantPubkeys.length > 0) && (
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2">
{location ? (
<>
<MapPin className="h-4 w-4 shrink-0 text-red-500" />
<span className="text-sm truncate flex-1">{location}</span>
</>
) : (
<>
<Users className="h-4 w-4 shrink-0 text-primary" />
<span className="text-sm text-muted-foreground flex-1">Participants</span>
</>
)}
{participantPubkeys.length > 0 && (
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="sm" />
)}
</div>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0.5">
{tag}
</Badge>
))}
</div>
)}
</div>
);
}
return (
<div className={cn('mt-2 rounded-xl border border-border overflow-hidden', className)}>
{compact ? (
/* ── Compact feed card matching reference design ── */
<>
{/* Cover image with capped height, or gradient placeholder */}
{image ? (
<div className="relative h-[180px] overflow-hidden">
<img
src={image}
alt={title ?? 'Calendar event'}
className="h-full w-full object-cover"
loading="lazy"
/>
{/* Participant avatars overlaid on the image */}
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
) : (
<div className="relative flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[100px]">
<CalendarDays className="h-10 w-10 text-primary/30" />
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
)}
{/* Event details below image */}
<div className="p-3 space-y-2">
{/* Title */}
{title && (
<h3 className="text-base font-bold leading-snug line-clamp-2">{title}</h3>
)}
{/* Date/time */}
{dateDisplay && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{/* Description snippet — hard-capped to ~2 lines */}
{(summary || hasContent) && (
<div className="text-sm text-muted-foreground max-h-[2.8em] overflow-hidden relative">
{summary && !hasContent ? (
<p className="line-clamp-2">{summary}</p>
) : (
<NoteContent event={event} className="text-sm" hideEmbedImages />
)}
</div>
)}
{/* Location pill */}
{location && (
<div className="flex items-center gap-2.5 rounded-lg bg-secondary/50 px-3 py-2">
<MapPin className="h-4 w-4 shrink-0 text-red-500" />
<span className="text-sm truncate">{location}</span>
</div>
)}
{/* Hashtags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0.5">
{tag}
</Badge>
))}
</div>
)}
</div>
</>
{image ? (
<div className="aspect-video rounded-lg overflow-hidden">
<img src={image} alt={title ?? 'Calendar event'} className="h-full w-full object-cover" loading="lazy" />
</div>
) : (
/* ── Full detail layout (detail page, expanded view) ── */
<>
{/* Cover image or gradient header */}
{image ? (
<div className="aspect-video rounded-lg overflow-hidden">
<img
src={image}
alt={title ?? 'Calendar event'}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
) : (
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
<CalendarDays className="h-10 w-10 text-primary/30" />
</div>
)}
{/* Event details */}
<div className="space-y-2 p-3">
{title && (
<h3 className="text-[15px] font-semibold leading-snug">{title}</h3>
)}
{dateDisplay && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{location && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{location}</span>
</div>
)}
{participants.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{participants.length} {participants.length === 1 ? 'participant' : 'participants'}
</span>
</div>
)}
{summary && !hasContent && (
<p className="text-sm text-muted-foreground">
{summary}
</p>
)}
{hasContent && (
<div>
<NoteContent event={event} className="text-sm" hideEmbedImages={!!image} />
</div>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{hashtags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0">
{tag}
</Badge>
))}
</div>
)}
</div>
</>
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
<CalendarDays className="h-10 w-10 text-primary/30" />
</div>
)}
<div className="space-y-2 p-3">
{title && <h3 className="text-[15px] font-semibold leading-snug">{title}</h3>}
{dateDisplay && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{location && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{location}</span>
</div>
)}
{participants.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>{participants.length} {participants.length === 1 ? 'participant' : 'participants'}</span>
</div>
)}
{summary && !hasContent && <p className="text-sm text-muted-foreground">{summary}</p>}
{hasContent && <NoteContent event={event} className="text-sm" hideEmbedImages={!!image} />}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{hashtags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0">
{tag}
</Badge>
))}
</div>
)}
</div>
</div>
);
}
+150 -136
View File
@@ -1,6 +1,5 @@
import { useState, useMemo, useCallback } from 'react';
import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
ArrowLeft,
CalendarDays,
@@ -9,10 +8,9 @@ import {
Users,
Check,
X as XIcon,
HelpCircle,
Share2,
Star,
Pencil,
ExternalLink,
Zap,
Link as LinkIcon,
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
@@ -22,10 +20,15 @@ import { getAvatarShape } from '@/lib/avatarShape';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { NoteContent } from '@/components/NoteContent';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { PostActionBar } from '@/components/PostActionBar';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { RSVPAvatars } from '@/components/RSVPAvatars';
import { ZapDialog } from '@/components/ZapDialog';
import { Skeleton } from '@/components/ui/skeleton';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useComments } from '@/hooks/useComments';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
@@ -184,48 +187,47 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const rsvps = useEventRSVPs(eventCoord);
const myRsvp = useMyRSVP(eventCoord);
const publishRSVP = usePublishRSVP();
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const canEdit = user?.pubkey === event.pubkey;
const [selectedStatus, setSelectedStatus] = useState<'accepted' | 'declined' | 'tentative' | null>(null);
const [rsvpNote, setRsvpNote] = useState('');
const replyTree = useMemo((): ReplyNode[] => {
const buildNode = (comment: NostrEvent): ReplyNode => {
const children = commentsData?.getDirectReplies(comment.id) ?? [];
if (children.length <= 1) {
return { event: comment, children: children.map((child) => buildNode(child)) };
}
const activeStatus = selectedStatus ?? myRsvp.status;
const hasChanged = selectedStatus !== null && selectedStatus !== myRsvp.status;
const [first, ...rest] = children;
return {
event: comment,
children: [buildNode(first)],
hiddenChildren: rest.map((child) => buildNode(child)),
};
};
const handleRSVP = useCallback(async () => {
if (!activeStatus) return;
return [...(commentsData?.topLevelComments ?? [])]
.sort((a, b) => a.created_at - b.created_at)
.map((comment) => buildNode(comment));
}, [commentsData]);
const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => {
if (status === myRsvp.status) return;
try {
await publishRSVP.mutateAsync({
eventCoord,
eventAuthorPubkey: event.pubkey,
status: activeStatus,
note: rsvpNote || undefined,
status,
});
setSelectedStatus(null);
setRsvpNote('');
toast({ title: 'RSVP updated' });
} catch {
toast({ title: 'Failed to update RSVP', variant: 'destructive' });
}
}, [activeStatus, eventCoord, event.pubkey, rsvpNote, publishRSVP, toast]);
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
const handleShare = useCallback(async () => {
const d = getTag(event.tags, 'd') ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
});
const url = `${window.location.origin}/${naddr}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
} catch {
toast({ title: 'Failed to copy link', variant: 'destructive' });
}
}, [event, toast]);
const isAuthor = user?.pubkey === event.pubkey;
const showRSVP = !!user && !isAuthor;
const showRSVP = !!user;
return (
<div className="max-w-2xl mx-auto pb-16">
@@ -239,6 +241,15 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1">Event Details</h1>
{canEdit && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={() => setEditOpen(true)}
aria-label="Edit event"
>
<Pencil className="size-5" />
</button>
)}
</div>
{/* ── Cover image ── */}
@@ -256,25 +267,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<div className="px-5 mt-5 space-y-5">
{/* Title */}
<h2 className="text-2xl font-bold leading-tight tracking-tight">{title}</h2>
{/* Organizer row + actions */}
{/* Organizer row */}
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<PersonRow pubkey={event.pubkey} />
</div>
<div className="flex items-center gap-1 shrink-0">
<ZapDialog target={event}>
<button className="p-2 rounded-full hover:bg-secondary/60 transition-colors" aria-label="Zap">
<Zap className="size-5" />
</button>
</ZapDialog>
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={handleShare}
aria-label="Share"
>
<Share2 className="size-5" />
</button>
</div>
</div>
{/* Date & Location — sidebar-style pills */}
@@ -355,96 +352,113 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
</>
)}
{/* RSVP section */}
{showRSVP && (
<div className="rounded-[1.25rem] bg-background/85 p-4 space-y-3">
<h2 className="text-sm font-semibold px-1">Your RSVP</h2>
{myRsvp.status && !selectedStatus && (
<div className="px-1">
<Badge
variant="outline"
className={cn(
myRsvp.status === 'accepted' && 'border-green-500 text-green-600',
myRsvp.status === 'tentative' && 'border-amber-500 text-amber-600',
myRsvp.status === 'declined' && 'border-destructive text-destructive',
)}
>
{myRsvp.status === 'accepted' ? 'Going' : myRsvp.status === 'tentative' ? 'Maybe' : "Can't Go"}
</Badge>
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
variant={activeStatus === 'accepted' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => setSelectedStatus('accepted')}
>
<Check className="size-3.5 mr-1.5" /> Going
</Button>
<Button
size="sm"
variant={activeStatus === 'tentative' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => setSelectedStatus('tentative')}
>
<HelpCircle className="size-3.5 mr-1.5" /> Maybe
</Button>
<Button
size="sm"
variant={activeStatus === 'declined' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => setSelectedStatus('declined')}
>
<XIcon className="size-3.5 mr-1.5" /> Can't Go
</Button>
</div>
{activeStatus && (
<Textarea
placeholder="Add a note (optional)"
value={rsvpNote}
onChange={(e) => setRsvpNote(e.target.value)}
className="mt-1 resize-none rounded-xl"
rows={2}
/>
)}
{(hasChanged || (activeStatus && !myRsvp.status)) && (
<Button
size="sm"
onClick={handleRSVP}
disabled={publishRSVP.isPending}
className="w-full mt-1 rounded-full"
>
{publishRSVP.isPending ? 'Updating...' : myRsvp.status ? 'Update RSVP' : 'Submit RSVP'}
</Button>
)}
</div>
)}
{/* Attendees */}
{rsvps.total > 0 && (
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Attendees
</h2>
<div className="space-y-2.5">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Maybe', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="flex items-center gap-3">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Attendees
</h2>
<div className="space-y-2.5">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="flex items-center gap-3">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
</div>
))}
</div>
</section>
</>
)}
{/* RSVP section */}
{showRSVP && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Check className="size-4" /> RSVP
</h2>
<div className="flex gap-2">
<Button
size="sm"
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => handleRSVP('accepted')}
>
<Check className="size-3.5 mr-1.5" /> Going
</Button>
<Button
size="sm"
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => handleRSVP('tentative')}
>
<Star className="size-3.5 mr-1.5" /> Interested
</Button>
<Button
size="sm"
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => handleRSVP('declined')}
>
<XIcon className="size-3.5 mr-1.5" /> Can't Go
</Button>
</div>
</section>
</>
)}
<PostActionBar
event={event}
replyLabel="Comments"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="-mx-5 px-5"
/>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
{canEdit && (
<CreateCommunityEventDialog
open={editOpen}
onOpenChange={setEditOpen}
event={event}
/>
)}
<section>
{commentsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
))}
</div>
</section>
)}
) : replyTree.length > 0 ? (
<div className="-mx-5">
<ThreadedReplyList roots={replyTree} />
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
No comments yet. Be the first to comment!
</div>
)}
</section>
</div>
</div>
);
+1 -1
View File
@@ -134,7 +134,7 @@ const KIND_LABELS: Record<number, string> = {
34139: 'a playlist',
34236: 'a divine',
34550: 'a community',
9041: 'a fundraising goal',
9041: 'a goal',
35128: 'an nsite',
36639: 'an action',
36787: 'a track',
+26 -8
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Crown, Shield, Users } from 'lucide-react';
import { Bookmark, Crown, Shield, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -18,6 +18,10 @@ interface CommunityCardProps {
event: NostrEvent;
/** Whether the current user founded this community. */
isFounded?: boolean;
/** Whether the current user is a validated member. */
isMember?: boolean;
/** Whether the current user has bookmarked this community (NIP-51 kind 10004). */
isBookmarked?: boolean;
className?: string;
}
@@ -25,7 +29,13 @@ interface CommunityCardProps {
* Compact card for displaying a community in a list.
* Shows image, name, description snippet, founder info, and moderator count.
*/
export function CommunityCard({ event, isFounded, className }: CommunityCardProps) {
export function CommunityCard({
event,
isFounded,
isMember,
isBookmarked,
className,
}: CommunityCardProps) {
const community = useMemo(() => parseCommunityEvent(event), [event]);
const founderAuthor = useAuthor(event.pubkey);
@@ -42,8 +52,6 @@ export function CommunityCard({ event, isFounded, className }: CommunityCardProp
identifier: community.dTag,
});
const rankCount = community.ranks.length - 1; // Exclude rank 0
return (
<Link
to={`/${naddr}`}
@@ -75,12 +83,22 @@ export function CommunityCard({ event, isFounded, className }: CommunityCardProp
<h3 className="text-sm font-semibold truncate flex-1 group-hover:text-primary transition-colors">
{community.name}
</h3>
{isFounded && (
{isFounded ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
<Crown className="size-2.5" />
Founder
</Badge>
)}
) : isMember ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
<Shield className="size-2.5" />
Member
</Badge>
) : isBookmarked ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-primary/10 text-primary border-primary/20">
<Bookmark className="size-2.5 fill-current" />
Bookmarked
</Badge>
) : null}
</div>
{/* Description */}
@@ -115,10 +133,10 @@ export function CommunityCard({ event, isFounded, className }: CommunityCardProp
{community.moderatorPubkeys.length}
</span>
)}
{rankCount > 0 && (
{community.memberBadgeATag && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Users className="size-3" />
{rankCount} rank{rankCount !== 1 ? 's' : ''}
Member badge
</span>
)}
</div>
+217
View File
@@ -0,0 +1,217 @@
import { useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { MessageSquare } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ComposeBox } from '@/components/ComposeBox';
import { ContentWarningGuard } from '@/components/ContentWarningGuard';
import { NoteContent } from '@/components/NoteContent';
import { useAuthor } from '@/hooks/useAuthor';
import { useCommunityChatMessages, COMMUNITY_CHAT_KIND } from '@/hooks/useCommunityChatMessages';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getAvatarShape } from '@/lib/avatarShape';
import { getDisplayName } from '@/lib/getDisplayName';
import type { CommunityMember, CommunityModeration } from '@/lib/communityUtils';
import { cn } from '@/lib/utils';
interface CommunityChatPanelProps {
communityATag: string;
moderation: CommunityModeration;
rankMap: ReadonlyMap<string, CommunityMember>;
isMembershipLoading: boolean;
}
function shortTimeAgo(timestamp: number): string {
const diff = Math.max(0, Math.floor(Date.now() / 1000) - timestamp);
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
export function CommunityChatPanel({
communityATag,
moderation,
rankMap,
isMembershipLoading,
}: CommunityChatPanelProps) {
const queryClient = useQueryClient();
const { user } = useCurrentUser();
const { data: messages, isLoading, isError, error, queryKey } = useCommunityChatMessages(communityATag, moderation);
const isBanned = !!user && moderation.bannedPubkeys.has(user.pubkey);
const isMember = !!user && rankMap.has(user.pubkey) && !isBanned;
const disabledReason = !user
? 'Log in to chat with this community.'
: isMembershipLoading
? 'Loading membership...'
: isBanned
? 'You are banned from this community.'
: !isMember
? 'Only community members can chat.'
: undefined;
const canSend = !disabledReason;
const chatPublish = useMemo(() => ({
kind: COMMUNITY_CHAT_KIND,
tags: [['a', communityATag, '', 'root']],
suppressSuccessToast: true,
}), [communityATag]);
const handlePublished = useCallback((event: NostrEvent) => {
queryClient.setQueryData<NostrEvent[]>(queryKey, (old = []) => {
if (old.some((existing) => existing.id === event.id)) return old;
return [...old, event].sort((a, b) => b.created_at - a.created_at);
});
}, [queryClient, queryKey]);
return (
<div>
<div>
{disabledReason && (
<p className="px-4 pt-3 text-center text-xs text-muted-foreground">{disabledReason}</p>
)}
{canSend && (
<ComposeBox
compact
placeholder="What's up?"
customPublish={chatPublish}
hidePoll
submitLabel="Send"
onPublished={handlePublished}
/>
)}
</div>
<div>
{isLoading ? (
<CommunityChatSkeleton />
) : isError ? (
<div className="py-12 px-4 text-center text-sm text-destructive">
{error instanceof Error ? error.message : 'Failed to load community chat.'}
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
<div className="mb-3 rounded-full bg-primary/10 p-3">
<MessageSquare className="size-6 text-primary" />
</div>
<p className="text-sm font-medium">No messages yet</p>
<p className="mt-1 text-xs text-muted-foreground">Start the first live conversation here.</p>
</div>
) : (
<div>
{messages.map((event, index) => {
const previous = messages[index - 1];
const showAvatar = !previous
|| previous.pubkey !== event.pubkey
|| previous.created_at - event.created_at > 300;
return <CommunityChatMessage key={event.id} event={event} showAvatar={showAvatar} />;
})}
</div>
)}
</div>
</div>
);
}
function CommunityChatSkeleton() {
return (
<div className="space-y-4 px-2 py-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex items-start gap-3">
<Skeleton className="size-8 rounded-full" />
<div className="flex-1 space-y-2 pt-1">
<Skeleton className="h-3 w-24" />
<Skeleton className={cn('h-4', index % 2 === 0 ? 'w-4/5' : 'w-2/3')} />
</div>
</div>
))}
</div>
);
}
function CommunityChatMessage({ event, showAvatar }: { event: NostrEvent; showAvatar: boolean }) {
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const avatarShape = getAvatarShape(metadata);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const isOwnMessage = user?.pubkey === event.pubkey;
return (
<div
className={cn(
'group flex gap-3 px-4 py-3 transition-colors hover:bg-secondary/40',
!showAvatar && 'py-2',
isOwnMessage && 'justify-end',
)}
>
{!isOwnMessage && <ChatMessageAvatar showAvatar={showAvatar} profileUrl={profileUrl} avatarShape={avatarShape} metadata={metadata} displayName={displayName} createdAt={event.created_at} />}
<div className={cn('min-w-0 flex-1', isOwnMessage && 'flex flex-col items-end')}>
{showAvatar && (
<div className={cn('mb-0.5 flex items-baseline gap-2', isOwnMessage && 'justify-end')}>
<Link
to={profileUrl}
className={cn('truncate text-xs font-semibold text-primary hover:underline', isOwnMessage && 'order-2')}
onClick={(event) => event.stopPropagation()}
>
{displayName}
</Link>
<span className={cn('text-[10px] text-muted-foreground/60', isOwnMessage && 'order-1')}>{shortTimeAgo(event.created_at)}</span>
</div>
)}
<ContentWarningGuard event={event}>
<div
className={cn(
'inline-block w-fit max-w-[64%] break-words rounded-2xl px-3 py-2 text-sm leading-relaxed sm:max-w-xs',
isOwnMessage ? 'rounded-tr-md bg-primary text-primary-foreground text-right' : 'rounded-tl-md bg-secondary/60',
)}
>
<NoteContent event={event} disableNoteEmbeds />
</div>
</ContentWarningGuard>
</div>
{isOwnMessage && <ChatMessageAvatar showAvatar={showAvatar} profileUrl={profileUrl} avatarShape={avatarShape} metadata={metadata} displayName={displayName} createdAt={event.created_at} />}
</div>
);
}
function ChatMessageAvatar({
showAvatar,
profileUrl,
avatarShape,
metadata,
displayName,
createdAt,
}: {
showAvatar: boolean;
profileUrl: string;
avatarShape: ReturnType<typeof getAvatarShape>;
metadata: NostrMetadata | undefined;
displayName: string;
createdAt: number;
}) {
return (
<div className="w-8 shrink-0">
{showAvatar ? (
<Link to={profileUrl} onClick={(event) => event.stopPropagation()}>
<Avatar shape={avatarShape} className="size-8">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/15 text-[10px] text-primary">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
) : (
<span className="hidden pt-0.5 text-[10px] text-muted-foreground/60 group-hover:block">
{shortTimeAgo(createdAt)}
</span>
)}
</div>
);
}
+2 -29
View File
@@ -1,15 +1,12 @@
import { useMemo, useCallback } from 'react';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Users, Share2, Globe } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Users, Globe } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Button } from '@/components/ui/button';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
@@ -43,7 +40,6 @@ function parseCommunityEvent(event: NostrEvent) {
// --- Main Component ---
export function CommunityContent({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const { name, description, image } = useMemo(
() => parseCommunityEvent(event),
[event],
@@ -68,22 +64,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
}, [description, descriptionUrl]);
const handleShare = useCallback(async () => {
const d = getTag(event.tags, 'd') ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
});
const url = `${window.location.origin}/${naddr}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
} catch {
toast({ title: 'Failed to copy link', variant: 'destructive' });
}
}, [event, toast]);
return (
<div className="mt-3 space-y-5">
{/* Community hero image */}
@@ -110,13 +90,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
</div>
)}
{/* Share button */}
<div className="flex items-center">
<Button variant="outline" size="icon" className="ml-auto size-8 shrink-0" onClick={handleShare}>
<Share2 className="size-3.5" />
</Button>
</div>
{/* Description */}
{descriptionText && (
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
+421 -112
View File
@@ -1,43 +1,64 @@
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,
MessageSquare,
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';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
import { CommunityChatPanel } from '@/components/CommunityChatPanel';
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 +139,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 +275,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 +301,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,48 +324,27 @@ 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);
const { membersOnly } = useMembersOnlyFilter();
// ── Fundraising goals (NIP-75) ──────────────────────────────────────────────
// ── 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 +375,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;
@@ -266,7 +430,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
};
return applyModeration([...topLevel])
.sort((a, b) => a.created_at - b.created_at)
.sort((a, b) => b.created_at - a.created_at)
.map((r) => buildNode(r));
}, [commentsData, moderation, membersOnly, rankMap]);
@@ -287,21 +451,33 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}
}, [event, toast]);
// ── FAB — visible on comments & fundraising tabs ──────────────────────────
// ── FAB — visible on comments, goals, and members tabs ─────────────────────
const handleFabClick = useCallback(() => {
if (activeTab === 'comments') {
setComposeOpen(true);
} else if (activeTab === 'fundraising') {
} else if (activeTab === 'goals') {
setGoalDialogOpen(true);
} else if (activeTab === 'events') {
setEventDialogOpen(true);
} else if (activeTab === 'members') {
setAddMemberOpen(true);
}
}, [activeTab]);
const fabIcon = activeTab === 'fundraising'
const fabIcon = activeTab === 'goals'
? <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 === 'goals'
|| activeTab === 'events'
|| (activeTab === 'members' && canAddMembers),
onFabClick: handleFabClick,
fabIcon,
});
@@ -312,88 +488,132 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
);
// ── Render ──────────────────────────────────────────────────────────────────
const heroIconClassName = 'size-5 text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]';
return (
<div className="max-w-2xl mx-auto pb-16">
{/* ── Top bar ── */}
<div className="flex items-center gap-4 px-4 pt-4 pb-3">
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1 truncate">Community</h1>
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={handleShare}
aria-label="Share"
>
<Share2 className="size-5" />
</button>
</div>
{/* ── Hero image ── */}
{image ? (
<div className="relative aspect-[21/9] w-full overflow-hidden">
<div className="relative h-32 w-full overflow-hidden sm:h-40">
{image ? (
<img src={image} alt={name} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<div className="absolute bottom-0 left-0 right-0 px-5 pb-4">
<h2 className="text-2xl font-bold text-white leading-tight drop-shadow-lg">{name}</h2>
</div>
</div>
) : (
<div className="relative aspect-[21/9] w-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
<Users className="size-16 text-primary/20" />
<div className="absolute bottom-0 left-0 right-0 px-5 pb-4">
<h2 className="text-2xl font-bold leading-tight">{name}</h2>
</div>
</div>
)}
{/* ── Community info ── */}
<div className="px-5 mt-4 space-y-4">
{/* Description */}
{descriptionText && (
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary/50 via-primary/25 to-primary/5" />
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/10 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{!image && (
<Users className="size-12 text-primary/20 sm:size-16" />
)}
{/* Founder + community-wide filter toggle. The toggle sits
right-justified on the same row as the "Founded by" label so
it clearly scopes the whole community (every content feed
below the tabs), not any one tab. */}
<div>
<div className="flex items-start justify-between gap-2 mb-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Founded by</p>
{/* ── Top bar ── */}
<div className="absolute inset-x-0 top-0 z-10 flex items-center gap-2 px-4 pt-4">
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="p-2 -ml-2 rounded-full hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors"
aria-label="Go back"
>
<ArrowLeft className={heroIconClassName} />
</button>
<div className="flex-1" />
{isFounder && community && (
<button
className="p-2 rounded-full hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors"
onClick={() => setEditCommunityOpen(true)}
aria-label="Edit community"
>
<Pencil className={heroIconClassName} />
</button>
)}
{user && communityATag && (
<button
className="p-2 rounded-full hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50 disabled:pointer-events-none transition-colors"
onClick={handleToggleBookmark}
disabled={toggleCommunityBookmark.isPending}
aria-label={bookmarked ? 'Remove community bookmark' : 'Bookmark community'}
aria-pressed={bookmarked}
aria-busy={toggleCommunityBookmark.isPending}
>
<Bookmark className={cn(heroIconClassName, bookmarked && 'fill-white')} />
</button>
)}
<button
className="p-2 rounded-full hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors"
onClick={handleShare}
aria-label="Share"
>
<Share2 className={heroIconClassName} />
</button>
</div>
<div className="absolute bottom-0 left-0 right-0 px-5 pb-3">
<h2 className="text-xl font-bold text-white leading-tight drop-shadow-lg sm:text-2xl">{name}</h2>
</div>
</div>
{/* ── Community info ── */}
<div className="px-5 mt-3 space-y-3">
{/* Description */}
{descriptionText && (
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
)}
{/* Founder + community-wide filter toggle. Keep this compact so the
content tabs stay visible on small screens. */}
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Founded by</p>
<PersonRow pubkey={event.pubkey} size="sm" />
</div>
<div className="flex shrink-0 items-center gap-2">
<MembersOnlyToggle className="-my-2 -mr-2" />
</div>
<PersonRow pubkey={event.pubkey} />
</div>
{/* ── 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="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"
value="chat"
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
<MessageSquare className="size-4 mr-1.5" />
Chat
</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"
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-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" />
Posts
</TabsTrigger>
<TabsTrigger
value="goals"
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
Goals
</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>
@@ -401,23 +621,23 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<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 +664,30 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
)}
</TabsContent>
{/* ── Chat tab ── */}
<TabsContent value="chat" className="mt-0">
{communityATag ? (
<CommunityChatPanel
communityATag={communityATag}
moderation={moderation}
rankMap={rankMap}
isMembershipLoading={membersLoading}
/>
) : (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
Community chat is unavailable for this community.
</div>
)}
</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} />
@@ -467,8 +711,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
)}
</TabsContent>
{/* ── Fundraising tab ── */}
<TabsContent value="fundraising" className="mt-0">
{/* ── Goals tab ── */}
<TabsContent value="goals" className="mt-0">
{goalsLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 2 }).map((_, i) => (
@@ -478,8 +722,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
) : activeGoals.length === 0 && pastGoals.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
{membersOnly && (goals ?? []).length > 0
? 'No fundraising goals from community members yet. Toggle the shield icon to see all goals.'
: <>No fundraising goals yet.{user ? ' Create one to get started!' : ''}</>}
? 'No goals from community members yet. Toggle the shield icon to see all goals.'
: <>No goals yet.{user ? ' Create one to get started!' : ''}</>}
</div>
) : (
<div className="divide-y divide-border">
@@ -502,6 +746,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>
@@ -527,7 +805,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
onOpenChange={setComposeOpen}
/>
{/* FAB-triggered goal creation dialog for the fundraising tab */}
{/* FAB-triggered goal creation dialog for the goals tab */}
{communityATag && (
<CreateGoalDialog
communityATag={communityATag}
@@ -535,6 +813,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>
);
}
+66 -15
View File
@@ -106,6 +106,8 @@ async function getImageMeta(file: File): Promise<{ dim?: string; blurhash?: stri
interface ComposeBoxProps {
onSuccess?: () => void;
/** Callback with the freshly published event, useful for optimistic parent caches. */
onPublished?: (event: NostrEvent) => void;
placeholder?: string;
compact?: boolean;
/** Event being replied to adds NIP-10 reply tags when set. A URL triggers NIP-22 comment mode. */
@@ -124,6 +126,18 @@ interface ComposeBoxProps {
initialContent?: string;
/** Open directly in poll mode. */
initialMode?: 'post' | 'poll';
/** Publish directly as a custom event kind with fixed tags instead of note/comment mode. */
customPublish?: {
kind: number;
tags: string[][];
successTitle?: string;
successDescription?: string;
suppressSuccessToast?: boolean;
};
/** Hide the poll option from the overflow tray. */
hidePoll?: boolean;
/** Label for the primary submit button. */
submitLabel?: string;
}
/** Circular progress ring for character count. */
@@ -166,6 +180,7 @@ function CharRing({ count, max }: { count: number; max: number }) {
export function ComposeBox({
onSuccess,
onPublished,
placeholder = "What's on your mind?",
compact = false,
replyTo,
@@ -176,6 +191,9 @@ export function ComposeBox({
onHasPreviewableContentChange,
initialContent = '',
initialMode = 'post',
customPublish,
hidePoll = false,
submitLabel = 'Post!',
}: ComposeBoxProps) {
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
const avatarShape = getAvatarShape(metadata);
@@ -270,6 +288,21 @@ export function ComposeBox({
if (!expanded) setExpanded(true);
}, [expanded]);
const buildContentWarningTags = useCallback((): string[][] => {
if (!cwEnabled) return [];
const tags: string[][] = [
['content-warning', cwText || ''],
['L', 'content-warning'],
];
if (cwText) {
tags.push(['l', cwText, 'content-warning']);
}
return tags;
}, [cwEnabled, cwText]);
// Detect embeds in content (nevent, note, naddr, URLs) with their positions
@@ -647,6 +680,16 @@ export function ComposeBox({
];
const imetaTag = ['imeta', ...imetaFields];
const cwTags = buildContentWarningTags();
if (customPublish) {
const event = await createEvent({
kind: customPublish.kind,
content: audioUrl,
tags: [...customPublish.tags, imetaTag, ...cwTags],
});
onPublished?.(event);
} else {
// Determine kind: 1244 for NIP-22 replies, 1222 for root messages
const isNip22Reply = replyTo && (replyTo instanceof URL || replyTo.kind !== 1);
const isKind1Reply = replyTo && !(replyTo instanceof URL) && replyTo.kind === 1;
@@ -655,7 +698,7 @@ export function ComposeBox({
// NIP-22 voice reply (kind 1244) — use postComment infrastructure
// but we need to publish kind 1244 directly since postComment uses kind 1111
// Build NIP-22 tags manually
const voiceTags: string[][] = [imetaTag];
const voiceTags: string[][] = [imetaTag, ...cwTags];
if (replyTo instanceof URL) {
voiceTags.push(['I', replyTo.toString()]);
@@ -680,7 +723,7 @@ export function ComposeBox({
});
} else if (isKind1Reply && !(replyTo instanceof URL)) {
// NIP-10 voice reply to a kind 1 note — still publish as kind 1222 with reply tags
const voiceTags: string[][] = [imetaTag];
const voiceTags: string[][] = [imetaTag, ...cwTags];
const rootTag = replyTo.tags.find(([name, , , marker]) => name === 'e' && marker === 'root');
if (rootTag) {
voiceTags.push(['e', rootTag[1], rootTag[2] || DITTO_RELAY, 'root', ...(rootTag[4] ? [rootTag[4]] : [])]);
@@ -700,9 +743,10 @@ export function ComposeBox({
await createEvent({
kind: 1222,
content: audioUrl,
tags: [imetaTag],
tags: [imetaTag, ...cwTags],
});
}
}
// Reset state
queryClient.invalidateQueries({ queryKey: ['feed'] });
@@ -724,7 +768,7 @@ export function ComposeBox({
} finally {
setIsPublishingVoice(false);
}
}, [user, voiceRecorder, uploadFile, createEvent, replyTo, queryClient, toast, onSuccess]);
}, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess]);
const handleSubmit = async () => {
if (!content.trim() || !user || charCount > MAX_CHARS) return;
@@ -802,13 +846,7 @@ export function ComposeBox({
}
// NIP-36: content warning
if (cwEnabled) {
tags.push(['content-warning', cwText || '']);
tags.push(['L', 'content-warning']);
if (cwText) {
tags.push(['l', cwText, 'content-warning']);
}
}
tags.push(...buildContentWarningTags());
// NIP-30: Add emoji tags for custom emojis referenced in content
if (customEmojis.length > 0) {
@@ -874,7 +912,15 @@ export function ComposeBox({
if (isNip22Reply) {
if (customPublish) {
const event = await createEvent({
kind: customPublish.kind,
content: finalContent,
tags: [...customPublish.tags, ...tags],
created_at: Math.floor(Date.now() / 1000),
});
onPublished?.(event);
} else if (isNip22Reply) {
// NIP-22: use usePostComment for non-kind-1 targets and URL roots
// Determine root and reply params for the comment hook
let root: NostrEvent | URL | `#${string}`;
@@ -975,7 +1021,12 @@ export function ComposeBox({
queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] });
}
notificationSuccess();
toast({ title: 'Posted!', description: replyTo ? 'Your reply has been published.' : quotedEvent ? 'Your quote has been published.' : 'Your note has been published.' });
if (!customPublish?.suppressSuccessToast) {
toast({
title: customPublish?.successTitle ?? 'Posted!',
description: customPublish?.successDescription ?? (replyTo ? 'Your reply has been published.' : quotedEvent ? 'Your quote has been published.' : 'Your note has been published.'),
});
}
onSuccess?.();
} catch {
toast({ title: 'Error', description: 'Failed to publish note.', variant: 'destructive' });
@@ -1532,7 +1583,7 @@ export function ComposeBox({
{/* Polls are top-level events (kind 1068), so they only make sense as a
standalone post or rooted on an external-content URL (e.g. iso3166: country
page). Hide for actual event replies (NostrEvent replyTo). */}
{(!replyTo || replyTo instanceof URL) && (
{!hidePoll && (!replyTo || replyTo instanceof URL) && !customPublish && (
<button
type="button"
onClick={() => { setMode((m) => m === 'poll' ? 'post' : 'poll'); setTrayOpen(false); expand(); }}
@@ -1587,7 +1638,7 @@ export function ComposeBox({
className="rounded-full px-5 font-bold"
size="sm"
>
{isPending || isCommentPending ? 'Posting...' : 'Post!'}
{isPending || isCommentPending ? 'Posting...' : submitLabel}
</Button>
)}
</div>
+331
View File
@@ -0,0 +1,331 @@
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Loader2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert text into a URL-safe slug for the d-tag identifier. */
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface CreateCommunityDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Existing community event when editing. Omit to create a new community. */
communityEvent?: NostrEvent;
/** Parsed existing community data when editing. */
community?: ParsedCommunity;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CreateCommunityDialog({ open, onOpenChange, communityEvent, community }: CreateCommunityDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isEditing = !!communityEvent && !!community;
// Form state
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Derived
const effectiveSlug = isEditing && community ? community.dTag : slugify(name);
const populateFromCommunity = useCallback(() => {
setName(community?.name ?? '');
setDescription(community?.description ?? '');
setImageUrl(community?.image ?? '');
setIsPublishing(false);
setIsImageUploading(false);
}, [community]);
const resetForm = useCallback(() => {
if (isEditing) {
populateFromCommunity();
} else {
setName('');
setDescription('');
setImageUrl('');
setIsPublishing(false);
setIsImageUploading(false);
}
}, [isEditing, populateFromCommunity]);
useEffect(() => {
if (open && isEditing) {
populateFromCommunity();
}
}, [open, isEditing, populateFromCommunity]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
const buildUpdatedCommunityTags = useCallback((baseTags: string[][]): string[][] => {
const tags = baseTags.filter(([name]) => !['d', 'name', 'description', 'image', 'alt'].includes(name));
const nextTags: string[][] = [
['d', effectiveSlug],
['name', name.trim()],
];
if (description.trim()) {
nextTags.push(['description', description.trim()]);
}
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (sanitizedImage) {
nextTags.push(['image', sanitizedImage]);
}
nextTags.push(...tags);
nextTags.push(['alt', `Community: ${name.trim()}`]);
return nextTags;
}, [description, effectiveSlug, imageUrl, name]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleCreate = useCallback(async () => {
if (!user || !name.trim() || !effectiveSlug) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
if (isEditing && communityEvent && community) {
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? communityEvent.content,
tags: buildUpdatedCommunityTags(prev?.tags ?? communityEvent.tags),
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
toast({ title: 'Community updated!' });
handleOpenChange(false);
return;
}
// Check for d-tag collision (same author, same kind, same d-tag)
const existing = await nostr.query([{
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [effectiveSlug],
limit: 1,
}]);
if (existing.length > 0) {
toast({
title: 'Name already in use',
description: 'You already have a community with this name. Please choose a different name.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeDTag = `${effectiveSlug}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'Choose a different community name so the member badge can be created safely.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${name.trim()}`],
['alt', `Badge definition: Member of ${name.trim()}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Founder as moderator (p tag) plus one flat member badge reference.
const communityTags = buildUpdatedCommunityTags([
['a', `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`, '', 'member'],
['p', user.pubkey, '', 'moderator'],
]);
// Publish community definition (kind 34550)
const createdEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: '',
tags: communityTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Navigate to the new community
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey: createdEvent.pubkey,
identifier: effectiveSlug,
});
toast({ title: 'Community created!' });
handleOpenChange(false);
navigate(`/${naddr}`);
} catch (err) {
toast({
title: isEditing ? 'Failed to update community' : 'Failed to create community',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, name, effectiveSlug, isEditing, communityEvent, community, nostr, isImageUploading, imageUrl,
publishEvent, buildUpdatedCommunityTags, queryClient, toast, handleOpenChange, navigate,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5 text-primary" />
{isEditing ? 'Edit Community' : 'Create a Community'}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Update the name, image, and description. Moderators are preserved.'
: "Start a new community on Nostr. You'll be the founder."}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(100vh-9rem)] sm:max-h-none">
<div className="px-5 pb-5 space-y-4">
{/* Community name */}
<div className="space-y-1.5">
<Label htmlFor="community-name">Community Name *</Label>
<Input
id="community-name"
placeholder="e.g. The Arbiter's Guard"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
/>
{name.trim() && (
<p className="text-xs text-muted-foreground font-mono">
ID: {effectiveSlug || '...'}{isEditing ? ' (unchanged)' : ''}
</p>
)}
</div>
<ImageUploadField
id="community-image"
label={<>Community Image <span className="text-muted-foreground font-normal">(recommended)</span></>}
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Community image preview"
dropAreaClassName="min-h-32"
/>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="community-description">
Description
<span className="text-muted-foreground font-normal ml-1">(recommended)</span>
</Label>
<Textarea
id="community-description"
placeholder="What is this community about?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
{/* Submit button */}
<Button
onClick={handleCreate}
disabled={!name.trim() || !effectiveSlug || isPublishing || isImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> {isEditing ? 'Saving...' : 'Creating...'}</>
) : (
<><Users className="size-4" /> {isEditing ? 'Save Changes' : 'Create Community'}</>
)}
</Button>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,523 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CalendarDays, ChevronLeft } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
interface CreateCommunityEventDialogProps {
communityATag?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
event?: NostrEvent;
}
const MANAGED_EDIT_TAGS = new Set([
'd',
'title',
'alt',
'summary',
'location',
'image',
'start',
'end',
'D',
'start_tzid',
'end_tzid',
'A',
'K',
'P',
]);
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function addDays(date: string, days: number): string {
const parsed = new Date(`${date}T00:00:00Z`);
parsed.setUTCDate(parsed.getUTCDate() + days);
return parsed.toISOString().slice(0, 10);
}
function subtractDays(date: string, days: number): string {
return addDays(date, -days);
}
function formatLocalDateTimeFields(timestamp: string): { date: string; time: string } {
const parsed = parseInt(timestamp, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return { date: '', time: '' };
const date = new Date(parsed * 1000);
const pad = (n: number) => n.toString().padStart(2, '0');
return {
date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
time: `${pad(date.getHours())}:${pad(date.getMinutes())}`,
};
}
function toLocalTimestamp(date: string, time: string): number {
return Math.floor(new Date(`${date}T${time}:00`).getTime() / 1000);
}
function parseCommunityAuthor(communityATag: string): string | undefined {
const [, pubkey] = communityATag.split(':');
return pubkey || undefined;
}
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange, event }: CreateCommunityEventDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: publishRSVP } = usePublishRSVP();
const [step, setStep] = useState<1 | 2>(1);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [allDay, setAllDay] = useState(true);
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('');
const [location, setLocation] = useState('');
const [isImageUploading, setIsImageUploading] = useState(false);
const timezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
[],
);
const isEditing = !!event;
const effectiveCommunityATag = communityATag ?? event?.tags.find(([name]) => name === 'A')?.[1];
const isCommunityEvent = !!effectiveCommunityATag;
const resetForm = useCallback(() => {
setStep(1);
setTitle('');
setDescription('');
setImageUrl('');
setAllDay(true);
setStartDate('');
setStartTime('');
setEndDate('');
setEndTime('');
setLocation('');
setIsImageUploading(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
useEffect(() => {
if (!open || !event) return;
const titleTag = event.tags.find(([name]) => name === 'title')?.[1] ?? '';
const summaryTag = event.tags.find(([name]) => name === 'summary')?.[1] ?? '';
const imageTag = event.tags.find(([name]) => name === 'image')?.[1] ?? '';
const locationTag = event.tags.find(([name]) => name === 'location')?.[1] ?? '';
const startTag = event.tags.find(([name]) => name === 'start')?.[1] ?? '';
const endTag = event.tags.find(([name]) => name === 'end')?.[1] ?? '';
const isAllDay = event.kind === 31922;
setStep(1);
setTitle(titleTag);
setDescription(summaryTag || event.content);
setImageUrl(imageTag);
setLocation(locationTag);
setAllDay(isAllDay);
setIsImageUploading(false);
if (isAllDay) {
setStartDate(startTag);
setStartTime('');
setEndDate(endTag ? subtractDays(endTag, 1) : '');
setEndTime('');
return;
}
const startFields = formatLocalDateTimeFields(startTag);
const endFields = formatLocalDateTimeFields(endTag);
setStartDate(startFields.date);
setStartTime(startFields.time);
setEndDate(endFields.date);
setEndTime(endFields.time);
}, [event, open]);
const validateInfoStep = useCallback((): boolean => {
if (!title.trim()) {
toast({ title: 'Enter an event title', variant: 'destructive' });
return false;
}
return true;
}, [title, toast]);
const handleNext = useCallback((e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (isImageUploading) return;
if (!validateInfoStep()) return;
setStep(2);
}, [isImageUploading, validateInfoStep]);
const handleSubmit = useCallback(async () => {
if (!user) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (!validateInfoStep()) return;
if (!startDate) {
toast({ title: 'Choose a start date', variant: 'destructive' });
return;
}
if (!allDay && !startTime) {
toast({ title: 'Choose a start time or turn on all-day', variant: 'destructive' });
return;
}
if (!allDay && endDate && !endTime) {
toast({ title: 'Add an end time or clear the end date', variant: 'destructive' });
return;
}
const trimmedTitle = title.trim();
const dTag = event?.tags.find(([name]) => name === 'd')?.[1] || `${slugify(trimmedTitle) || 'event'}-${Date.now()}`;
let kind = isEditing && event ? event.kind : 31922;
try {
const prev = isEditing && event
? await fetchFreshEvent(nostr, {
kinds: [event.kind],
authors: [event.pubkey],
'#d': [dTag],
})
: undefined;
const preservedTags = isEditing
? (prev?.tags ?? event?.tags ?? []).filter(([name]) => !MANAGED_EDIT_TAGS.has(name))
: [];
const tags: string[][] = [
['d', dTag],
['title', trimmedTitle],
['alt', `${isCommunityEvent ? 'Community event' : 'Calendar event'}: ${trimmedTitle}`],
...preservedTags,
];
if (effectiveCommunityATag) {
const communityAuthor = parseCommunityAuthor(effectiveCommunityATag);
tags.push(['A', effectiveCommunityATag], ['K', '34550']);
if (communityAuthor) {
tags.push(['P', communityAuthor]);
}
}
if (description.trim()) {
tags.push(['summary', description.trim()]);
}
if (location.trim()) {
tags.push(['location', location.trim()]);
}
if (imageUrl.trim()) {
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (!sanitizedImage) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
tags.push(['image', sanitizedImage]);
}
if (allDay) {
tags.push(['start', startDate]);
if (endDate) {
if (endDate < startDate) {
toast({ title: 'End date must be on or after the start date', variant: 'destructive' });
return;
}
tags.push(['end', addDays(endDate, 1)]);
}
} else {
if (!isEditing) kind = 31923;
const startTs = toLocalTimestamp(startDate, startTime);
if (!Number.isFinite(startTs) || startTs <= 0) {
toast({ title: 'Start date or time is invalid', variant: 'destructive' });
return;
}
tags.push(['start', String(startTs)]);
tags.push(['D', String(Math.floor(startTs / 86400))]);
tags.push(['start_tzid', timezone]);
if (endTime) {
const effectiveEndDate = endDate || startDate;
const endTs = toLocalTimestamp(effectiveEndDate, endTime);
if (!Number.isFinite(endTs) || endTs <= startTs) {
toast({ title: 'End time must be after the start time', variant: 'destructive' });
return;
}
tags.push(['end', String(endTs)]);
tags.push(['end_tzid', timezone]);
}
}
const publishedEvent = await publishEvent({
kind,
content: description.trim(),
tags,
prev: prev ?? undefined,
});
if (!isEditing) {
// Auto-RSVP the author as "accepted" so they appear in the attendees list.
// Best-effort: don't block on failure -- the event itself is already published.
const eventCoord = `${kind}:${user.pubkey}:${dTag}`;
publishRSVP({
eventCoord,
eventAuthorPubkey: user.pubkey,
status: 'accepted',
}).catch(() => {
// Silently ignore -- user can manually RSVP from the detail page if needed.
});
}
queryClient.setQueryData(['addr-event', kind, publishedEvent.pubkey, dTag], publishedEvent);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['feed'] }),
queryClient.invalidateQueries({ queryKey: ['addr-event', kind, publishedEvent.pubkey, dTag] }),
...(effectiveCommunityATag ? [
queryClient.invalidateQueries({ queryKey: ['community-events', effectiveCommunityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(effectiveCommunityATag);
},
}),
] : []),
]);
toast({ title: isEditing ? 'Event updated!' : 'Event created!' });
handleOpenChange(false);
} catch (err) {
toast({
title: 'Failed to create event',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
}, [
allDay,
description,
endDate,
endTime,
effectiveCommunityATag,
handleOpenChange,
imageUrl,
isImageUploading,
isEditing,
location,
nostr,
publishEvent,
publishRSVP,
queryClient,
startDate,
startTime,
timezone,
title,
toast,
user,
validateInfoStep,
isCommunityEvent,
event,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<CalendarDays className="size-5 text-primary" />
{isEditing ? 'Edit Event' : 'Create Event'}
</DialogTitle>
<DialogDescription>
Step {step} of 2 · {step === 1 ? 'What is happening?' : 'When and where?'}
</DialogDescription>
</DialogHeader>
<form onSubmit={(e) => e.preventDefault()}>
<ScrollArea className="max-h-[62vh]">
<div className="px-5 pb-5 space-y-4">
{step === 1 ? (
<>
<div className="space-y-1.5">
<Label htmlFor="community-event-title">Title *</Label>
<Input
id="community-event-title"
placeholder="e.g. Neighborhood cleanup"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-description">Description (recommended)</Label>
<Textarea
id="community-event-description"
placeholder="Tell people what to expect..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</div>
<ImageUploadField
id="community-event-image"
label="Image (recommended)"
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Event image preview"
/>
</>
) : (
<>
<div className="flex items-center justify-between gap-4 rounded-xl border border-border px-3 py-3">
<div className="space-y-0.5">
<Label htmlFor="community-event-all-day">All-day event</Label>
<p className="text-xs text-muted-foreground">
{isEditing ? "Event type can't be changed while editing." : 'Turn off to add start and end times.'}
</p>
</div>
<Switch
id="community-event-all-day"
checked={allDay}
onCheckedChange={setAllDay}
disabled={isEditing}
/>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<div className="space-y-1.5">
<Label htmlFor="community-event-start-date">Start date *</Label>
<Input
id="community-event-start-date"
type="date"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-end-date">End date (optional)</Label>
<Input
id="community-event-end-date"
type="date"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
{!allDay && (
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<div className="space-y-1.5">
<Label htmlFor="community-event-start-time">Start time *</Label>
<Input
id="community-event-start-time"
type="time"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-end-time">End time (optional)</Label>
<Input
id="community-event-end-time"
type="time"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="community-event-location">Location (recommended)</Label>
<Input
id="community-event-location"
placeholder="Address, venue, or video call link"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
<div className="flex items-center gap-2 border-t border-border px-5 py-4 bg-background">
{step === 1 ? (
<>
<Button type="button" variant="outline" className="flex-1" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button type="button" className="flex-1" onClick={handleNext} disabled={isImageUploading}>
{isImageUploading ? 'Uploading...' : 'Next'}
</Button>
</>
) : (
<>
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => setStep(1)}>
<ChevronLeft className="size-4" />
Back
</Button>
<Button type="button" className="flex-1" onClick={handleSubmit} disabled={isPending || isImageUploading}>
{isPending ? (isEditing ? 'Saving...' : 'Creating...') : isImageUploading ? 'Uploading...' : isEditing ? 'Save Event' : 'Create Event'}
</Button>
</>
)}
</div>
</form>
</DialogContent>
</Dialog>
);
}
+48 -39
View File
@@ -11,6 +11,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -45,6 +46,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
const [summary, setSummary] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [deadlineDate, setDeadlineDate] = useState('');
const [isImageUploading, setIsImageUploading] = useState(false);
const resetForm = () => {
setTitle('');
@@ -52,11 +54,16 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
setSummary('');
setImageUrl('');
setDeadlineDate('');
setIsImageUploading(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
const sats = parseInt(amountSats, 10);
if (isNaN(sats) || sats <= 0) {
@@ -92,9 +99,11 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
}
if (imageUrl.trim()) {
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (sanitizedImage) {
tags.push(['image', sanitizedImage]);
if (!sanitizedImage) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
tags.push(['image', sanitizedImage]);
}
if (deadlineDate) {
const deadline = Math.floor(new Date(deadlineDate).getTime() / 1000);
@@ -110,7 +119,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
tags,
});
// Refresh the fundraising tab and the community activity feed
// Refresh the goals tab and the community activity feed
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['community-goals', communityATag] }),
queryClient.invalidateQueries({
@@ -123,7 +132,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
}),
]);
toast({ title: 'Fundraising goal created!' });
toast({ title: 'Goal created!' });
resetForm();
setOpen(false);
} catch {
@@ -148,7 +157,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
<DialogContent className="sm:max-w-md">
<DialogTitle className="flex items-center gap-2">
<Target className="size-5" />
Create Fundraising Goal
Create Goal
</DialogTitle>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
@@ -163,53 +172,53 @@ 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">
<Label htmlFor="goal-summary">Description (optional)</Label>
<Textarea
id="goal-summary"
placeholder="Tell people what this fundraiser is for..."
placeholder="Tell people what this goal is for..."
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={3}
/>
</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="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 -1
View File
@@ -1082,7 +1082,7 @@ function hasVideo(tags: string[][]): boolean {
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
9041: 'Fundraising Goal',
9041: 'Goal',
31990: 'App',
32267: 'Zapstore App',
30063: 'Zapstore Release',
+3 -2
View File
@@ -144,6 +144,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
// World feed: all country-tagged events with diversity cap + live streaming.
const worldFeed = useWorldFeed(isWorldActive);
const { flushStreamBuffer } = worldFeed;
// For non-world tabs, use the standard feed query
const queryKey = useMemo(
@@ -153,10 +154,10 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const handleRefresh = usePageRefresh(queryKey);
const handleWorldRefresh = useCallback(async () => {
worldFeed.flushStreamBuffer();
flushStreamBuffer();
await handleRefresh();
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [worldFeed.flushStreamBuffer, handleRefresh]);
}, [flushStreamBuffer, handleRefresh]);
const {
data: rawData,
+178
View File
@@ -0,0 +1,178 @@
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
import { Loader2, Upload, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { resizeImage } from '@/lib/resizeImage';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface ImageUploadFieldProps {
id: string;
label: ReactNode;
value: string;
onChange: (value: string) => void;
onUploadingChange?: (isUploading: boolean) => void;
placeholder?: string;
uploadText?: string;
uploadingText?: string;
uploadToastTitle?: string;
previewAlt?: string;
objectFit?: 'cover' | 'contain';
className?: string;
dropAreaClassName?: string;
disabled?: boolean;
}
export function ImageUploadField({
id,
label,
value,
onChange,
onUploadingChange,
placeholder = 'Paste an image URL, or upload above',
uploadText = 'Paste, drop, or click to upload an image',
uploadingText = 'Uploading image...',
uploadToastTitle = 'Image uploaded',
previewAlt = 'Image preview',
objectFit = 'cover',
className,
dropAreaClassName,
disabled,
}: ImageUploadFieldProps) {
const { config } = useAppContext();
const { toast } = useToast();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const imageInputRef = useRef<HTMLInputElement>(null);
const previewUrl = sanitizeUrl(value);
useEffect(() => {
onUploadingChange?.(isUploading);
}, [isUploading, onUploadingChange]);
const handleImageFile = useCallback(async (file: File) => {
if (!file.type.startsWith('image/')) {
toast({ title: 'Invalid file', description: 'Please choose an image file.', variant: 'destructive' });
return;
}
try {
const uploadableFile = config.imageQuality === 'compressed'
? (await resizeImage(file)).file
: file;
const [[, url]] = await uploadFile(uploadableFile);
onChange(url);
toast({ title: uploadToastTitle });
} catch (err) {
toast({
title: 'Upload failed',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
}, [config.imageQuality, onChange, toast, uploadFile, uploadToastTitle]);
const handleImagePaste = useCallback((e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items || disabled) return;
for (const item of Array.from(items)) {
if (!item.type.startsWith('image/')) continue;
const file = item.getAsFile();
if (!file) return;
e.preventDefault();
void handleImageFile(file);
return;
}
}, [disabled, handleImageFile]);
const handleImageDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (disabled) return;
const file = e.dataTransfer.files[0];
if (file) void handleImageFile(file);
}, [disabled, handleImageFile]);
const clearImage = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
if (imageInputRef.current) imageInputRef.current.value = '';
}, [onChange]);
return (
<div className={cn('space-y-1.5', className)} onPaste={handleImagePaste}>
<Label htmlFor={id}>{label}</Label>
<div>
<div
role="button"
tabIndex={disabled ? -1 : 0}
onClick={() => {
if (!disabled) imageInputRef.current?.click();
}}
onDrop={handleImageDrop}
onDragOver={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) imageInputRef.current?.click();
}}
className={cn(
'relative flex min-h-28 w-full cursor-pointer flex-col items-center justify-center overflow-hidden rounded-t-xl border border-b-0 border-dashed border-border bg-secondary/20 text-center transition-colors hover:bg-secondary/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
disabled && 'cursor-not-allowed opacity-60',
dropAreaClassName,
)}
>
{isUploading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span className="text-xs">{uploadingText}</span>
</div>
) : previewUrl ? (
<>
<img
src={previewUrl}
alt={previewAlt}
className={cn('absolute inset-0 h-full w-full', objectFit === 'contain' ? 'object-contain p-3' : 'object-cover')}
/>
<button
type="button"
aria-label="Remove image"
onClick={clearImage}
disabled={disabled}
className="absolute right-2 top-2 rounded-full bg-background/90 p-1 text-muted-foreground shadow-sm transition-colors hover:text-destructive disabled:opacity-60"
>
<X className="size-4" />
</button>
</>
) : (
<div className="flex flex-col items-center gap-2 px-4 text-muted-foreground">
<Upload className="size-5 opacity-50" />
<span className="text-xs">{uploadText}</span>
</div>
)}
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
disabled={disabled}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleImageFile(file);
}}
/>
</div>
<Input
id={id}
type="url"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-t-none rounded-b-xl"
disabled={disabled}
/>
</div>
</div>
);
}
+6 -4
View File
@@ -10,10 +10,12 @@ interface MembersOnlyToggleProps {
/**
* Shield-icon toggle that controls the "members only" filter for community
* surfaces. When active (default), community feeds only show content authored
* by chain-validated members — matching the NIP's canonical-author
* recommendation. When inactive, the feed shows every event scoped to the
* community regardless of author.
* surfaces. When active, community feeds only show content authored by
* validated members. When inactive (default), the feed shows every event
* scoped to the community regardless of author.
*
* Per the flat-communities spec, members-only is a MAY feature — the
* protocol makes no recommendation, so the toggle is an opt-in UX choice.
*
* The preference is persisted in localStorage via `useMembersOnlyFilter` and
* is global across community surfaces (Activities feed, per-community
-6
View File
@@ -16,7 +16,6 @@ import {
Share2,
SmilePlus,
PartyPopper,
Target,
Users,
Zap,
} from "lucide-react";
@@ -1699,11 +1698,6 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
noun: "community",
nounRoute: "/communities",
},
9041: {
icon: Target,
action: "created a",
noun: "fundraising goal",
},
30009: {
icon: Award,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
+36 -4
View File
@@ -47,6 +47,7 @@ import { CommunityReportDialog } from '@/components/CommunityReportDialog';
import { AddToListDialog } from '@/components/AddToListDialog';
import { useNostr } from '@nostrify/react';
import { useBookmarks } from '@/hooks/useBookmarks';
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
import { usePinnedNotes } from '@/hooks/usePinnedNotes';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
@@ -57,7 +58,7 @@ import { useOrganizers } from '@/hooks/useOrganizers';
import { usePinnedPosts } from '@/hooks/usePinnedPosts';
import { useCountryFeed } from '@/contexts/CountryFeedContext';
import { useCommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { type CommunityMenuContext, canBanTarget, getViewerAuthority } from '@/lib/communityUtils';
import { type CommunityMenuContext, canBanTarget, getViewerAuthority, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
// NOTE: `CommunityMenuContext` is derived automatically from
// `useCommunityModerationContext()`. Parents install a
// `CommunityModerationContext.Provider` to enable community-aware menu items.
@@ -368,7 +369,26 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
const navigate = useNavigate();
const { user } = useCurrentUser();
const { isBookmarked, toggleBookmark } = useBookmarks();
const bookmarked = isBookmarked(event.id);
const {
isBookmarked: isCommunityBookmarked,
toggleBookmark: toggleCommunityBookmark,
} = useCommunityBookmarks();
// Community events (kind 34550) are bookmarked via NIP-51 kind 10004
// using their addressable `a` tag coordinate, so the reference stays
// valid across community updates. Non-community events use the standard
// kind 10003 bookmark list keyed by event id.
const isCommunityEvent = event.kind === COMMUNITY_DEFINITION_KIND;
const communityATag = useMemo(() => {
if (!isCommunityEvent) return undefined;
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (!dTag) return undefined;
return `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`;
}, [isCommunityEvent, event.pubkey, event.tags]);
const bookmarked = isCommunityEvent
? !!communityATag && isCommunityBookmarked(communityATag)
: isBookmarked(event.id);
const { isPinned, togglePin } = usePinnedNotes(user?.pubkey);
const pinned = isPinned(event.id);
const isOwnPost = user?.pubkey === event.pubkey;
@@ -411,7 +431,15 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
const handleBookmark = () => {
impactLight();
toggleBookmark.mutate(event.id);
if (isCommunityEvent) {
if (!communityATag) return;
// Success/error toasts are fired from the mutation itself in
// useCommunityBookmarks so they survive this dialog unmounting
// before the publish resolves.
toggleCommunityBookmark.mutate({ aTag: communityATag });
} else {
toggleBookmark.mutate(event.id);
}
close();
};
@@ -548,7 +576,11 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
/>
<MenuItem
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
label={bookmarked ? 'Remove Bookmark' : 'Bookmark'}
label={
isCommunityEvent
? (bookmarked ? 'Remove community bookmark' : 'Bookmark community')
: (bookmarked ? 'Remove Bookmark' : 'Bookmark')
}
onClick={handleBookmark}
/>
{user && (
+11 -9
View File
@@ -13,15 +13,17 @@ const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-[200] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-[200] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
+1 -1
View File
@@ -12,7 +12,7 @@ export interface CommunityModerationContextValue {
communityATag: string;
/** Resolved moderation data (bans, reports, content warnings). */
moderation: CommunityModeration;
/** Chain-validated rank lookup (pubkey rank). Includes banned members for authority checks only. */
/** Flat membership lookup (pubkey -> authority rank). Includes banned members for authority checks only. */
rankMap: Map<string, CommunityMember>;
}
+102 -63
View File
@@ -16,23 +16,39 @@ import {
} from '@/lib/communityUtils';
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
import { getPaginationCursor } from '@/lib/feedUtils';
import { queryAll } from '@/lib/queryAll';
/** Internal result type — events plus per-community moderation/membership data. */
interface ActivityFeedResult {
events: NostrEvent[];
/** Moderation data keyed by community A tag. */
moderationByATag: Map<string, CommunityModeration>;
/** Chain-validated rank maps keyed by community A tag (pre-moderation, for authority checks). */
/** Flat authority maps keyed by community A tag (pre-moderation, for authority checks). */
rankMapByATag: Map<string, Map<string, CommunityMember>>;
/** Cursor for the next comments/goals page. */
oldestActivityTimestamp: number;
/** Whether at least one paginated activity filter returned a full page. */
hasMoreActivity: boolean;
/** Cursor for the next comments page, when comments still have more events. */
commentsNextUntil?: number;
/** Cursor for the next goals page, when goals still have more events. */
goalsNextUntil?: number;
/** Whether comments still have more events. */
hasMoreComments: boolean;
/** Whether goals still have more events. */
hasMoreGoals: boolean;
}
interface ActivityFeedPageParam {
includeComments: boolean;
includeGoals: boolean;
commentsUntil?: number;
goalsUntil?: number;
}
const EMPTY_MODERATION_BY_A_TAG: ReadonlyMap<string, CommunityModeration> = new Map();
const EMPTY_RANK_MAP_BY_A_TAG: ReadonlyMap<string, Map<string, CommunityMember>> = new Map();
const ACTIVITY_PAGE_SIZE = 100;
const INITIAL_PAGE_PARAM: ActivityFeedPageParam = {
includeComments: true,
includeGoals: true,
};
/**
* Fetches a chronological activity feed for communities the current user
@@ -68,25 +84,29 @@ export function useCommunityActivityFeed() {
events: [],
moderationByATag: new Map(),
rankMapByATag: new Map(),
oldestActivityTimestamp: Math.floor(Date.now() / 1000),
hasMoreActivity: false,
hasMoreComments: false,
hasMoreGoals: false,
};
}
const timeout = AbortSignal.timeout(8_000);
const combinedSignal = AbortSignal.any([signal, timeout]);
const until = pageParam as number | undefined;
const page = (pageParam as ActivityFeedPageParam | undefined) ?? INITIAL_PAGE_PARAM;
// Collect all badge a-tag coordinates across all communities for membership resolution
const allBadgeATags: string[] = [];
for (const entry of myCommunities) {
for (const rank of entry.community.ranks) {
if (rank.badgeATag) allBadgeATags.push(rank.badgeATag);
}
}
const awardFilters = myCommunities
.filter((entry) => !!entry.community.memberBadgeATag)
.map((entry) => ({
kinds: [BADGE_AWARD_KIND],
authors: [entry.community.founderPubkey, ...entry.community.moderatorPubkeys],
'#a': [entry.community.memberBadgeATag!],
limit: 500,
}));
// Fetch community definitions, comments, reports, badge awards, and goals in parallel
const [definitionEvents, comments, reports, awards, goals] = await Promise.all([
// Fetch community definitions, comments, membership awards, and goals in parallel.
// Awards are exhausted per-community with `queryAll` so every community's
// membership is complete, regardless of how many communities the user
// belongs to. See src/lib/queryAll.ts.
const [definitionEvents, comments, awards, goals] = await Promise.all([
// The community definitions themselves
nostr.query(
[{
@@ -98,41 +118,35 @@ export function useCommunityActivityFeed() {
{ signal: combinedSignal },
),
// Kind 1111 comments scoped to these communities via uppercase A tag
nostr.query(
[{
kinds: [1111],
'#A': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(until ? { until } : {}),
}],
{ signal: combinedSignal },
),
// Kind 1984 reports scoped to these communities
nostr.query(
[{
kinds: [REPORT_KIND],
'#A': aTags,
limit: 500,
}],
{ signal: combinedSignal },
),
// Badge awards for membership resolution
allBadgeATags.length > 0
page.includeComments
? nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#a': allBadgeATags, limit: 500 }],
[{
kinds: [1111],
'#A': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(page.commentsUntil ? { until: page.commentsUntil } : {}),
}],
{ signal: combinedSignal },
)
: Promise.resolve([]),
: Promise.resolve([] as NostrEvent[]),
// Flat membership awards, one exhaustive query per community.
awardFilters.length > 0
? Promise.all(
awardFilters.map((f) => queryAll(nostr, f, { signal: combinedSignal })),
).then((pages) => pages.flat())
: Promise.resolve([] as NostrEvent[]),
// NIP-75 zap goals linked to these communities (lowercase a tag)
nostr.query(
[{
kinds: [ZAP_GOAL_KIND],
'#a': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(until ? { until } : {}),
}],
{ signal: combinedSignal },
),
page.includeGoals
? nostr.query(
[{
kinds: [ZAP_GOAL_KIND],
'#a': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(page.goalsUntil ? { until: page.goalsUntil } : {}),
}],
{ signal: combinedSignal },
)
: Promise.resolve([] as NostrEvent[]),
]);
// ── Resolve membership and moderation per community ──
@@ -142,25 +156,38 @@ export function useCommunityActivityFeed() {
// only be filtered from community A's posts, not from community B.
//
// We do **not** seed the `['community-members', aTag]` cache from
// this hook. The activity feed uses shared relay limits across all
// subscribed communities (awards and reports both capped at 500
// total), so per-community results can be incomplete. Overwriting
// the members cache with incomplete data would silently corrupt
// membership, authority, and moderation state on detail pages.
// `useCommunityMembers` is the authoritative per-community fetch.
const moderationByATag = new Map<string, CommunityModeration>();
// this hook. Even with exhaustive `queryAll` paging, the per-community
// fetch in `useCommunityMembers` may apply different filters or
// trigger a fresh read; keeping it authoritative avoids stale writes.
const rankMapByATag = new Map<string, Map<string, CommunityMember>>();
const reportAuthorSet = new Set<string>();
for (const entry of myCommunities) {
const community = entry.community;
// Resolve membership for this community.
// Resolve flat membership for this community.
const fullMembership = resolveMembership(community, awards);
const rankMap = new Map<string, CommunityMember>();
for (const m of fullMembership.members) {
rankMap.set(m.pubkey, m);
reportAuthorSet.add(m.pubkey);
}
rankMapByATag.set(community.aTag, rankMap);
}
const reports = reportAuthorSet.size > 0
? await queryAll(
nostr,
{ kinds: [REPORT_KIND], authors: [...reportAuthorSet], '#A': aTags, limit: 500 },
{ signal: combinedSignal },
)
: [];
const moderationByATag = new Map<string, CommunityModeration>();
for (const entry of myCommunities) {
const community = entry.community;
const rankMap = rankMapByATag.get(community.aTag) ?? new Map<string, CommunityMember>();
// Resolve moderation. The resolver filters `reports` by matching
// `A` tag internally, so we can pass the full cross-community
@@ -183,12 +210,17 @@ export function useCommunityActivityFeed() {
};
// ── Merge, deduplicate, and filter ──
const knownCommunityATags = new Set(aTags);
const seen = new Set<string>();
const merged: NostrEvent[] = [];
for (const event of [...definitionEvents, ...comments, ...goals]) {
if (seen.has(event.id)) continue;
seen.add(event.id);
if (event.kind === COMMUNITY_DEFINITION_KIND) {
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (!dTag || !knownCommunityATags.has(`${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`)) continue;
}
if (!isAllowed(event)) continue;
merged.push(event);
}
@@ -196,8 +228,8 @@ export function useCommunityActivityFeed() {
// Sort by created_at descending
merged.sort((a, b) => b.created_at - a.created_at);
const paginatedActivity = [...comments, ...goals];
const oldestActivityTimestamp = getPaginationCursor(paginatedActivity);
const hasMoreComments = page.includeComments && comments.length === ACTIVITY_PAGE_SIZE;
const hasMoreGoals = page.includeGoals && goals.length === ACTIVITY_PAGE_SIZE;
// Seed the ['event', id] cache so embedded previews (quotes, reply
// context, etc.) resolve instantly instead of refetching.
@@ -211,15 +243,22 @@ export function useCommunityActivityFeed() {
events: merged,
moderationByATag,
rankMapByATag,
oldestActivityTimestamp,
hasMoreActivity: comments.length === ACTIVITY_PAGE_SIZE || goals.length === ACTIVITY_PAGE_SIZE,
commentsNextUntil: hasMoreComments ? getPaginationCursor(comments) - 1 : undefined,
goalsNextUntil: hasMoreGoals ? getPaginationCursor(goals) - 1 : undefined,
hasMoreComments,
hasMoreGoals,
};
},
getNextPageParam: (lastPage) => {
if (!lastPage.hasMoreActivity) return undefined;
return lastPage.oldestActivityTimestamp - 1;
if (!lastPage.hasMoreComments && !lastPage.hasMoreGoals) return undefined;
return {
includeComments: lastPage.hasMoreComments,
includeGoals: lastPage.hasMoreGoals,
commentsUntil: lastPage.commentsNextUntil,
goalsUntil: lastPage.goalsNextUntil,
} satisfies ActivityFeedPageParam;
},
initialPageParam: undefined as number | undefined,
initialPageParam: INITIAL_PAGE_PARAM,
enabled: !communitiesLoading && aTags.length > 0,
staleTime: 2 * 60_000,
gcTime: 30 * 60_000,
+139
View File
@@ -0,0 +1,139 @@
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
import { parseATagCoordinate } from '@/lib/nostrEvents';
import { toast } from '@/hooks/useToast';
/** NIP-51 Communities list — kind 10004. */
export const COMMUNITIES_LIST_KIND = 10004;
const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i;
/** Parse and validate a NIP-51 community list coordinate. */
export function parseCommunityBookmarkATag(aTag: string): { pubkey: string; dTag: string } | undefined {
const coord = parseATagCoordinate(aTag);
if (!coord || coord.kind !== COMMUNITY_DEFINITION_KIND) return undefined;
if (!HEX_PUBKEY_RE.test(coord.pubkey) || !coord.identifier) return undefined;
return { pubkey: coord.pubkey, dTag: coord.identifier };
}
/**
* Hook to manage the user's NIP-51 Communities list (kind 10004).
*
* This list stores `a` tag coordinates for kind 34550 community definitions
* that the user has bookmarked / "saved". Unlike `useBookmarks` (kind 10003)
* which targets event IDs, this list targets addressable coordinates so the
* reference remains stable across community updates.
*/
export function useCommunityBookmarks() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
// Query the user's communities list (kind 10004 — replaceable event)
const listQuery = useQuery({
queryKey: ['community-bookmarks', user?.pubkey],
queryFn: async () => {
if (!user) return null;
const events = await nostr.query([{
kinds: [COMMUNITIES_LIST_KIND],
authors: [user.pubkey],
limit: 1,
}]);
return events[0] ?? null;
},
enabled: !!user,
});
// Extract bookmarked community a-tags (only `34550:` coordinates)
const bookmarkedATags: string[] = (listQuery.data?.tags ?? [])
.filter(([name, value]) =>
name === 'a' && typeof value === 'string' && !!parseCommunityBookmarkATag(value),
)
.map(([, value]) => value);
/** Check if a community `a` tag coordinate is bookmarked. */
function isBookmarked(aTag: string): boolean {
return bookmarkedATags.includes(aTag);
}
/**
* Toggle bookmark for a given community coordinate.
* `aTag` is expected to be a `34550:<pubkey>:<d-tag>` string.
* `relayHint` is optional — appended to the tag per NIP-51 when provided.
*/
const toggleBookmark = useMutation({
mutationFn: async ({ aTag, relayHint }: { aTag: string; relayHint?: string }) => {
if (!user) throw new Error('User is not logged in');
// Fetch the freshest kind 10004 from relays before mutating
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITIES_LIST_KIND],
authors: [user.pubkey],
});
const currentTags = prev?.tags ?? [];
const currentlyBookmarked = currentTags.some(
([name, value]) => name === 'a' && value === aTag,
);
let newTags: string[][];
if (currentlyBookmarked) {
// Remove all matching a-tags for this coordinate
newTags = currentTags.filter(
([name, value]) => !(name === 'a' && value === aTag),
);
} else {
// Append the new bookmark per NIP-51 recommendation
const newTag: string[] = relayHint ? ['a', aTag, relayHint] : ['a', aTag];
newTags = [...currentTags, newTag];
}
await publishEvent({
kind: COMMUNITIES_LIST_KIND,
content: prev?.content ?? '',
tags: newTags,
created_at: Math.floor(Date.now() / 1000),
prev: prev ?? undefined,
});
// Return whether this was a remove or add so onSuccess can pick the
// right toast wording. Callbacks live on the mutation (not per-call)
// so they still fire when the triggering UI (e.g. a dialog) unmounts
// before the publish resolves.
return { removed: currentlyBookmarked };
},
onSuccess: ({ removed }) => {
queryClient.invalidateQueries({ queryKey: ['community-bookmarks', user?.pubkey] });
queryClient.invalidateQueries({ queryKey: ['my-communities'] });
toast({
title: removed ? 'Community removed from bookmarks' : 'Community bookmarked',
});
},
onError: () => {
toast({
title: 'Failed to update bookmark',
variant: 'destructive',
});
},
});
return {
/** The kind 10004 list event itself. */
listEvent: listQuery.data,
/** Array of bookmarked community `a` tag coordinates. */
bookmarkedATags,
/** Whether the list query is still loading. */
isLoading: listQuery.isLoading,
/** Check whether a given `a` tag coordinate is bookmarked. */
isBookmarked,
/** Toggle a community bookmark on/off. */
toggleBookmark,
};
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { applyCommunityModerationToEvents, type CommunityModeration } from '@/lib/communityUtils';
export const COMMUNITY_CHAT_KIND = 1311;
function isCommunityChatMessage(event: NostrEvent, communityATag: string): boolean {
return event.kind === COMMUNITY_CHAT_KIND
&& event.tags.some(([name, value]) => name === 'a' && value === communityATag);
}
export function useCommunityChatMessages(
communityATag: string | undefined,
moderation?: CommunityModeration,
) {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const queryKey = useMemo(() => ['community-chat', communityATag ?? ''], [communityATag]);
const query = useQuery<NostrEvent[]>({
queryKey,
queryFn: async ({ signal }) => {
if (!communityATag) return [];
const events = await nostr.query(
[{ kinds: [COMMUNITY_CHAT_KIND], '#a': [communityATag], limit: 100 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8_000)]) },
);
return events
.filter((event) => isCommunityChatMessage(event, communityATag))
.sort((a, b) => b.created_at - a.created_at);
},
enabled: !!communityATag,
staleTime: 10_000,
});
useEffect(() => {
if (!communityATag) return;
const controller = new AbortController();
const since = Math.floor(Date.now() / 1000);
(async () => {
try {
for await (const msg of nostr.req(
[{ kinds: [COMMUNITY_CHAT_KIND], '#a': [communityATag], since }],
{ signal: controller.signal },
)) {
if (msg[0] !== 'EVENT') continue;
const event = msg[2] as NostrEvent;
if (!isCommunityChatMessage(event, communityATag)) continue;
queryClient.setQueryData<NostrEvent[]>(queryKey, (old = []) => {
if (old.some((existing) => existing.id === event.id)) return old;
return [...old, event].sort((a, b) => b.created_at - a.created_at);
});
}
} catch (error) {
if (!controller.signal.aborted) {
console.error('Community chat subscription failed:', error);
}
}
})();
return () => controller.abort();
}, [nostr, communityATag, queryClient, queryKey]);
const moderatedMessages = useMemo(() => {
const messages = query.data ?? [];
return moderation ? applyCommunityModerationToEvents(messages, moderation) : messages;
}, [query.data, moderation]);
return {
...query,
data: moderatedMessages,
queryKey,
};
}
+47
View File
@@ -0,0 +1,47 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
const CALENDAR_EVENT_KINDS = [31922, 31923];
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function isValidCalendarEvent(event: NostrEvent): boolean {
if (!CALENDAR_EVENT_KINDS.includes(event.kind)) return false;
const d = getTag(event.tags, 'd');
const title = getTag(event.tags, 'title');
const start = getTag(event.tags, 'start');
if (!d || !title || !start) return false;
if (event.kind === 31922) {
return /^\d{4}-\d{2}-\d{2}$/.test(start);
}
const startTs = parseInt(start, 10);
return Number.isFinite(startTs) && startTs > 0;
}
/** Fetches NIP-52 calendar events scoped to a community via the uppercase `A` tag. */
export function useCommunityEvents(communityATag: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['community-events', communityATag],
queryFn: async ({ signal }): Promise<NostrEvent[]> => {
if (!communityATag) return [];
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const events = await nostr.query(
[{ kinds: CALENDAR_EVENT_KINDS, '#A': [communityATag], limit: 50 }],
{ signal: combinedSignal },
);
return events.filter(isValidCalendarEvent);
},
enabled: !!communityATag,
staleTime: 60_000,
});
}
+26 -23
View File
@@ -15,21 +15,22 @@ import {
resolveCommunityModeration,
resolveMembership,
} from '@/lib/communityUtils';
import { queryAll } from '@/lib/queryAll';
interface CommunityMembersResult {
/** Resolved membership with banned members removed. Use `members` to list active community members. */
membership: CommunityMembership;
/** Resolved moderation data (bans, reports, content warnings). */
moderation: CommunityModeration;
/** Chain-validated rank lookup (pubkey → rank) BEFORE moderation overlay. Includes banned members. Used for authority checks only — do NOT use to list active members. */
/** Flat authority lookup before moderation overlay. Includes banned members. Used for authority checks only — do NOT use to list active members. */
rankMap: Map<string, CommunityMember>;
}
/**
* Fetch and resolve the full membership tree and moderation state for a community.
* Fetch and resolve flat membership and moderation state for a community.
*
* Queries badge awards (kind 8) and reports (kind 1984),
* then runs the chain validation algorithm with moderation overlay.
* Queries founder/moderator-authored membership awards (kind 8), then
* queries member-authored reports and bans (kind 1984).
*/
export function useCommunityMembers(community: ParsedCommunity | null | undefined) {
const { nostr } = useNostr();
@@ -47,41 +48,43 @@ export function useCommunityMembers(community: ParsedCommunity | null | undefine
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
// Collect all badge a-tag coordinates from the community definition
const badgeATags = community.ranks
.filter((r) => r.badgeATag)
.map((r) => r.badgeATag!);
const awardAuthors = [community.founderPubkey, ...community.moderatorPubkeys];
// Fetch awards and reports in parallel
const [awards, reports] = await Promise.all([
badgeATags.length > 0
? nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#a': badgeATags, limit: 500 }],
{ signal: combinedSignal },
)
: Promise.resolve([]),
nostr.query(
[{ kinds: [REPORT_KIND], '#A': [community.aTag], limit: 500 }],
// Exhaustive paging: awards and reports are unbounded sets that grow
// with the community. `queryAll` pages with `until` until the relay
// drains, capped at 5_000 events / 10 pages so worst-case cost is
// bounded. See src/lib/queryAll.ts.
const awards = community.memberBadgeATag
? await queryAll(
nostr,
{ kinds: [BADGE_AWARD_KIND], authors: awardAuthors, '#a': [community.memberBadgeATag], limit: 500 },
{ signal: combinedSignal },
),
]);
)
: [];
// Step 1-2: Resolve full membership (needed for authority checks)
const fullMembership = resolveMembership(community, awards);
// Build rank lookup for authority checks (includes all chain-validated members, even those later banned)
// Build authority lookup for checks (includes members even if later banned).
const rankMap = new Map<string, CommunityMember>();
for (const m of fullMembership.members) {
rankMap.set(m.pubkey, m);
}
// Step 3: Resolve moderation using the rank map. The resolver
const reportAuthors = fullMembership.members.map((member) => member.pubkey);
const reports = await queryAll(
nostr,
{ kinds: [REPORT_KIND], authors: reportAuthors, '#A': [community.aTag], limit: 500 },
{ signal: combinedSignal },
);
// Step 3: Resolve moderation using the flat membership map. The resolver
// filters by `A` tag internally; we pass all reports as-is since
// the relay query already scoped them to this community.
const moderation = resolveCommunityModeration(community.aTag, reports, rankMap);
// Step 4: Apply moderation overlay — filter banned members from the
// already-computed membership rather than re-running chain validation.
// already-computed membership.
const membership: CommunityMembership = {
members: fullMembership.members.filter(
(m) => !moderation.bannedPubkeys.has(m.pubkey),
+12 -4
View File
@@ -34,6 +34,8 @@ interface FeedPage {
interface UseFeedOptions {
/** Override the kinds list instead of using feed settings. Used by kind-specific pages. */
kinds?: number[];
/** Override the follows author list. Used by scoped member feeds. */
authors?: string[];
/** Additional tag filters to apply (e.g. `{ '#m': ['application/x-webxdc'] }`). */
tagFilters?: Record<string, string[]>;
}
@@ -51,15 +53,19 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
const allKinds = options?.kinds ?? getEnabledFeedKinds(feedSettings);
const tagFilters = options?.tagFilters;
const authorsOverride = options?.authors;
// Stable key so queries re-run when settings change.
const kindsKey = [...allKinds].sort().join(',');
const authorsKey = authorsOverride ? [...authorsOverride].sort().join(',') : '';
const tagFiltersKey = tagFilters ? JSON.stringify(tagFilters) : '';
// For the follows tab, wait until the follow list is loaded before running any query.
// Without this guard, the query falls through to the global branch while followList is still loading.
// Allow query to run if not on follows tab, OR if follow list has loaded (even if empty).
const followsReady = tab !== 'follows' || (!!user && followList !== undefined);
const followsReady = authorsOverride
? authorsOverride.length > 0
: tab !== 'follows' || (!!user && followList !== undefined);
// Load community pubkeys from localStorage
const communityPubkeys = (() => {
@@ -84,7 +90,7 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
// on page load because feedSettings is read from localStorage
// synchronously — the encrypted settings sync at ~5s only calls
// updateConfig if values actually differ (NostrSync changed guard).
queryKey: ['feed', tab, user?.pubkey ?? '', kindsKey, tagFiltersKey, communityPubkeys.length, feedSettings.followsFeedShowReplies],
queryKey: ['feed', tab, user?.pubkey ?? '', kindsKey, authorsKey, tagFiltersKey, communityPubkeys.length, feedSettings.followsFeedShowReplies],
queryFn: async ({ pageParam }) => {
const signal = AbortSignal.timeout(8000);
const now = Math.floor(Date.now() / 1000);
@@ -238,10 +244,12 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
cacheEvents(dedupedItems);
return { items: dedupedItems, oldestQueryTimestamp, rawCount: validFilteredEvents.length };
} else if (tab === 'follows' && user && followList !== undefined) {
} else if ((authorsOverride && authorsOverride.length > 0) || (tab === 'follows' && user && followList !== undefined)) {
// Follows feed — posts, reposts, and extra kinds from people you follow
// If followList is empty, just query own posts
const authors = followList.length > 0 ? [...followList, user.pubkey] : [user.pubkey];
const authors = authorsOverride ?? (user && followList
? (followList.length > 0 ? [...followList, user.pubkey] : [user.pubkey])
: []);
const fetchLimit = !feedSettings.followsFeedShowReplies ? PAGE_SIZE * OVER_FETCH_MULTIPLIER : PAGE_SIZE;
const filter: Record<string, unknown> = { kinds: allKinds, authors, limit: fetchLimit, ...tagFilters };
if (pageParam) {
+1
View File
@@ -13,6 +13,7 @@ import { useCallback, useMemo } from "react";
*/
const DEFAULT_SIDEBAR_ORDER: string[] = [
'wallet',
'search',
'verified',
'actions',
'polls',
+9 -9
View File
@@ -8,12 +8,12 @@ const STORAGE_KEY = 'community:members-only';
/**
* Controls whether community views filter content down to posts authored by
* chain-validated members, or show everything scoped to the community.
* validated members, or show everything scoped to the community.
*
* Defaults to `true` (members-only), which aligns with the NIP's "canonical
* community feeds SHOULD discard non-member content by default" guidance
* (see NIP.md §Community-Scoped Content). Users can opt out per their
* preference via a shield-icon toggle in the UI.
* Defaults to `false` (show everything). The flat-communities spec treats
* members-only as a MAY feature (see NIP.md §Community-Scoped Content) —
* the protocol makes no recommendation, so the default is the broader view
* and users opt in via the shield-icon toggle.
*
* Implementation: a module-level singleton store (Set of subscribers +
* cached boolean). Every component mounting `useMembersOnlyFilter` shares
@@ -23,21 +23,21 @@ const STORAGE_KEY = 'community:members-only';
* via the browser's `storage` event.
*/
/** Read the persisted boolean, defaulting to `true` when absent or malformed. */
/** Read the persisted boolean, defaulting to `false` when absent or malformed. */
function readFromStorage(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return true;
if (raw === null) return false;
return JSON.parse(raw) === true;
} catch {
return true;
return false;
}
}
// ── Module-level singleton store ────────────────────────────────────────────
// Module initialisation accesses `localStorage` which is unavailable in some
// SSR-ish environments. Guard so the module can still be imported.
let cached: boolean = typeof localStorage !== 'undefined' ? readFromStorage() : true;
let cached: boolean = typeof localStorage !== 'undefined' ? readFromStorage() : false;
const subscribers = new Set<() => void>();
function notify() {
+132 -17
View File
@@ -3,9 +3,11 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { COMMUNITIES_LIST_KIND, parseCommunityBookmarkATag } from './useCommunityBookmarks';
import {
COMMUNITY_DEFINITION_KIND,
BADGE_AWARD_KIND,
isAuthorizedAward,
parseCommunityEvent,
type ParsedCommunity,
} from '@/lib/communityUtils';
@@ -17,15 +19,26 @@ export interface MyCommunityEntry {
event: NostrEvent;
/** Whether the current user is the founder. */
isFounded: boolean;
/** Whether the current user is a validated member. */
isMember: boolean;
/** Whether the current user has bookmarked the community via kind 10004. */
isBookmarked: boolean;
}
/**
* Fetch communities the logged-in user has founded or been recruited into.
* Fetch communities the logged-in user has founded, been recruited into,
* or bookmarked via their NIP-51 Communities list (kind 10004).
*
* Discovery follows the NIP:
* 1. Founded: `{ kinds: [34550], authors: [<user-pubkey>] }`
* 2. Member-of: query kind 8 awards targeting the user, extract badge `a` tags,
* Discovery:
*
* 1. Founded -- `{ kinds: [34550], authors: [user.pubkey] }`
* 2. Member-of -- kind 8 awards targeting the user, extract badge `a` tags,
* then find the community definitions referencing those badges.
* 3. Bookmarked -- read kind 10004 authored by user, extract `a` tags
* pointing at kind 34550 events, and fetch those community definitions.
*
* Priority when the same community appears in multiple sources:
* founded > member > bookmarked.
*/
export function useMyCommunities() {
const { nostr } = useNostr();
@@ -39,24 +52,38 @@ export function useMyCommunities() {
const timeout = AbortSignal.timeout(10_000);
const combinedSignal = AbortSignal.any([signal, timeout]);
// Step 1: Communities founded by the user
// ── Step 1: Communities founded by the user ───────────────────────────
const foundedEvents = await nostr.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], authors: [user.pubkey], limit: 50 }],
{ signal: combinedSignal },
);
// Step 2: Badge awards targeting the user
const awards = await nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#p': [user.pubkey], limit: 200 }],
{ signal: combinedSignal },
);
// ── Step 2: Badge awards targeting the user + Bookmarks list ──────────
//
// Batched into a single relay round-trip. The kind 10004 list is a
// replaceable event, so pulling it here keeps the read path tight and
// reuses the same connection.
const [awards, bookmarkListEvents] = await Promise.all([
nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#p': [user.pubkey], limit: 200 }],
{ signal: combinedSignal },
),
nostr.query(
[{ kinds: [COMMUNITIES_LIST_KIND], authors: [user.pubkey], limit: 1 }],
{ signal: combinedSignal },
),
]);
// Extract badge a-tag coordinates from awards
const badgeATags = new Set<string>();
const awardsByBadgeATag = new Map<string, NostrEvent[]>();
for (const award of awards) {
for (const tag of award.tags) {
if (tag[0] === 'a' && tag[1]?.startsWith('30009:')) {
badgeATags.add(tag[1]);
const list = awardsByBadgeATag.get(tag[1]) ?? [];
list.push(award);
awardsByBadgeATag.set(tag[1], list);
}
}
}
@@ -70,26 +97,114 @@ export function useMyCommunities() {
);
}
// Merge and deduplicate (founded takes priority)
// ── Step 4: Resolve bookmarked community coordinates ──────────────────
//
// NIP-51 kind 10004 stores community definitions as `a` tags like
// `34550:<pubkey>:<d-tag>`. For each bookmarked coordinate we query
// with both `authors` and `#d` so relays return a single authentic
// event per bookmark (per AGENTS.md security guidance on addressable
// events).
//
// Multiple coordinates with the same author are grouped to minimise
// the number of relay queries while keeping the author filter intact.
const bookmarkListEvent = bookmarkListEvents[0];
const bookmarkedCoords: string[] = (bookmarkListEvent?.tags ?? [])
.filter(([n, v]) =>
n === 'a'
&& typeof v === 'string'
&& !!parseCommunityBookmarkATag(v),
)
.map(([, v]) => v);
// Group bookmarked coords by author pubkey: author -> Set<d-tag>
const coordsByAuthor = new Map<string, Set<string>>();
for (const coord of bookmarkedCoords) {
const parsed = parseCommunityBookmarkATag(coord);
if (!parsed) continue;
const existing = coordsByAuthor.get(parsed.pubkey);
if (existing) {
existing.add(parsed.dTag);
} else {
coordsByAuthor.set(parsed.pubkey, new Set([parsed.dTag]));
}
}
let bookmarkedCommunityEvents: NostrEvent[] = [];
if (coordsByAuthor.size > 0) {
bookmarkedCommunityEvents = await nostr.query(
Array.from(coordsByAuthor.entries()).map(([authorPubkey, dTags]) => ({
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [authorPubkey],
'#d': [...dTags],
limit: dTags.size,
})),
{ signal: combinedSignal },
);
}
const bookmarkedATagSet = new Set(bookmarkedCoords);
// ── Merge & deduplicate ──────────────────────────────────────────────
// Priority: founded > member > bookmarked. `isBookmarked` is resolved
// from the bookmark list irrespective of which bucket produced the
// entry, so founders/members who have also bookmarked see both flags.
const seen = new Map<string, MyCommunityEntry>();
for (const event of foundedEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
seen.set(community.aTag, { community, event, isFounded: true });
seen.set(community.aTag, {
community,
event,
isFounded: true,
isMember: false,
isBookmarked: bookmarkedATagSet.has(community.aTag),
});
}
for (const event of memberCommunityEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
if (!seen.has(community.aTag)) {
seen.set(community.aTag, { community, event, isFounded: false });
}
if (!community.memberBadgeATag || !badgeATags.has(community.memberBadgeATag)) continue;
const hasValidAward = (awardsByBadgeATag.get(community.memberBadgeATag) ?? [])
.some((award) => isAuthorizedAward(award, community));
if (!hasValidAward) continue;
if (seen.has(community.aTag)) continue;
seen.set(community.aTag, {
community,
event,
isFounded: false,
isMember: true,
isBookmarked: bookmarkedATagSet.has(community.aTag),
});
}
// Sort: founded first, then by created_at descending
for (const event of bookmarkedCommunityEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
if (!bookmarkedATagSet.has(community.aTag)) continue;
if (seen.has(community.aTag)) continue;
seen.set(community.aTag, {
community,
event,
isFounded: false,
isMember: false,
isBookmarked: true,
});
}
// Sort: founded first, then member, then bookmarked-only;
// tie-break by created_at descending.
const sortRank = (entry: MyCommunityEntry): number => {
if (entry.isFounded) return 0;
if (entry.isMember) return 1;
return 2;
};
return Array.from(seen.values()).sort((a, b) => {
if (a.isFounded !== b.isFounded) return a.isFounded ? -1 : 1;
const rankDiff = sortRank(a) - sortRank(b);
if (rankDiff !== 0) return rankDiff;
return b.event.created_at - a.event.created_at;
});
},
+64 -2
View File
@@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { NSchema as n } from '@nostrify/nostrify';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { useFollowList } from '@/hooks/useFollowActions';
import { useDebounce } from '@/hooks/useDebounce';
@@ -12,6 +13,37 @@ export interface SearchProfile {
event: NostrEvent;
}
function getPubkeyFromNip19(value: string): string | undefined {
const trimmed = value.trim().replace(/^nostr:/, '');
if (!trimmed) return undefined;
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') return decoded.data;
if (decoded.type === 'nprofile') return decoded.data.pubkey;
} catch {
return undefined;
}
return undefined;
}
function makeFallbackProfile(pubkey: string): SearchProfile {
return {
pubkey,
metadata: {},
event: {
id: '',
pubkey,
created_at: 0,
kind: 0,
tags: [],
content: '{}',
sig: '',
},
};
}
/**
* Search cached author profiles in the TanStack Query cache.
* Scans all ['author', pubkey] entries for name/display_name/nip05 matches.
@@ -66,6 +98,31 @@ export function useSearchProfiles(query: string) {
// Debounce the query so we don't hammer the relay on every keystroke
const debouncedQuery = useDebounce(query, 300);
const directPubkey = useMemo(() => getPubkeyFromNip19(debouncedQuery), [debouncedQuery]);
const directProfile = useQuery<SearchProfile | undefined>({
queryKey: ['search-profiles', 'direct', directPubkey],
queryFn: async ({ signal }) => {
if (!directPubkey) return undefined;
const events = await nostr.query(
[{ kinds: [0], authors: [directPubkey], limit: 1 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
const event = events.sort((a, b) => b.created_at - a.created_at)[0];
if (!event) return makeFallbackProfile(directPubkey);
try {
const metadata = n.json().pipe(n.metadata()).parse(event.content);
return { pubkey: event.pubkey, metadata, event };
} catch {
return makeFallbackProfile(directPubkey);
}
},
enabled: !!directPubkey,
staleTime: 30 * 1000,
});
const relayResults = useQuery<SearchProfile[]>({
queryKey: ['search-profiles', debouncedQuery],
@@ -100,7 +157,7 @@ export function useSearchProfiles(query: string) {
return Array.from(seen.values());
},
enabled: debouncedQuery.trim().length >= 1,
enabled: debouncedQuery.trim().length >= 1 && !directPubkey,
staleTime: 30 * 1000,
placeholderData: (prev) => prev,
});
@@ -110,6 +167,10 @@ export function useSearchProfiles(query: string) {
const data = useMemo(() => {
const relayData = relayResults.data;
if (directProfile.data) {
return [directProfile.data];
}
if (relayData && relayData.length > 0) {
return [...relayData].sort((a, b) => {
const aFollowed = followedPubkeys.has(a.pubkey) ? 0 : 1;
@@ -124,11 +185,12 @@ export function useSearchProfiles(query: string) {
}
return relayData;
}, [relayResults.data, followedPubkeys, debouncedQuery, queryClient]);
}, [relayResults.data, directProfile.data, followedPubkeys, debouncedQuery, queryClient]);
return {
...relayResults,
data,
isFetching: relayResults.isFetching || directProfile.isFetching,
followedPubkeys,
};
}
+6
View File
@@ -178,6 +178,9 @@ export function useZaps(
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
if (target.kind === 9041) {
queryClient.invalidateQueries({ queryKey: ['goal-progress', target.id] });
}
// Close dialog last to ensure clean state
onZapSuccess?.();
@@ -223,6 +226,9 @@ export function useZaps(
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
if (target.kind === 9041) {
queryClient.invalidateQueries({ queryKey: ['goal-progress', target.id] });
}
// Close dialog last to ensure clean state
onZapSuccess?.();
+76 -118
View File
@@ -48,16 +48,7 @@ export const NIP56_REPORT_TYPE_META: Record<Nip56ReportType, { label: string; de
other: { label: 'Other', description: 'Something else not listed above' },
};
// ── Rank tier metadata ────────────────────────────────────────────────────────
export interface RankTier {
/** Numeric rank index (0 = founder/moderator, 1+ = badge-based). */
rank: number;
/** Badge `a` tag coordinate (e.g. `30009:<pubkey>:<d-tag>`). Undefined for rank 0. */
badgeATag?: string;
/** Optional relay hint from the community definition's `a` tag. */
relayHint?: string;
}
const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i;
// ── Parsed community ──────────────────────────────────────────────────────────
@@ -74,8 +65,10 @@ export interface ParsedCommunity {
founderPubkey: string;
/** Moderator pubkeys (from `p` tags with role "moderator"). */
moderatorPubkeys: string[];
/** Ordered rank tiers (rank 0 first, then badge-based ranks). */
ranks: RankTier[];
/** Member badge `a` tag coordinate (e.g. `30009:<pubkey>:<d-tag>`). */
memberBadgeATag?: string;
/** Optional relay hint from the community definition's member badge `a` tag. */
memberBadgeRelayHint?: string;
/** Recommended relay URLs. */
relays: string[];
/** The `a` tag coordinate for the community: `34550:<pubkey>:<d-tag>`. */
@@ -98,32 +91,16 @@ export function parseCommunityEvent(event: NostrEvent): ParsedCommunity | null {
const image = sanitizeUrl(rawImage);
// Moderators: p tags with "moderator" role (4th element)
const moderatorPubkeys = event.tags
const moderatorPubkeys = Array.from(new Set(event.tags
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
.map(([, pubkey]) => pubkey)
.filter(Boolean);
.filter((pubkey): pubkey is string => !!pubkey && pubkey !== event.pubkey && HEX_PUBKEY_RE.test(pubkey))));
// Badge rank tiers: a tags pointing to kind 30009 with rank index in 4th element
const badgeRanks: RankTier[] = [];
for (const tag of event.tags) {
if (tag[0] !== 'a') continue;
const coord = tag[1];
if (!coord || !coord.startsWith('30009:')) continue;
const rankStr = tag[3];
const rank = parseInt(rankStr, 10);
if (isNaN(rank) || rank < 1) continue;
badgeRanks.push({
rank,
badgeATag: coord,
relayHint: tag[2] || undefined,
});
}
// Sort badge ranks ascending
badgeRanks.sort((a, b) => a.rank - b.rank);
// Build full rank list: rank 0 (founder/moderators) + badge ranks
const ranks: RankTier[] = [{ rank: 0 }, ...badgeRanks];
const memberBadgeTag = event.tags.find(
([n, coord, , role]) => n === 'a' && coord?.startsWith('30009:') && role === 'member',
);
const memberBadgeATag = memberBadgeTag?.[1];
const memberBadgeRelayHint = memberBadgeTag?.[2] || undefined;
// Relay URLs
const relays = event.tags
@@ -138,7 +115,8 @@ export function parseCommunityEvent(event: NostrEvent): ParsedCommunity | null {
image,
founderPubkey: event.pubkey,
moderatorPubkeys,
ranks,
memberBadgeATag,
memberBadgeRelayHint,
relays,
aTag: `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`,
};
@@ -151,7 +129,7 @@ export interface CommunityMember {
pubkey: string;
/** Their effective rank in this community. */
rank: number;
/** The badge award event that established membership (undefined for rank 0). */
/** The badge award event that established membership (undefined for leadership). */
awardEvent?: NostrEvent;
/** Pubkey of whoever awarded them (undefined for rank 0). */
awardedBy?: string;
@@ -352,7 +330,7 @@ export function parseCommunityReport(event: NostrEvent): CommunityReport | null
// Extract target pubkey (required on all reports)
const pTag = event.tags.find(([n]) => n === 'p');
const targetPubkey = pTag?.[1];
if (!targetPubkey) return null;
if (!targetPubkey || !HEX_PUBKEY_RE.test(targetPubkey)) return null;
// Extract target event ID (optional — determines content vs member action)
const eTag = event.tags.find(([n]) => n === 'e');
@@ -401,13 +379,10 @@ export function parseCommunityReport(event: NostrEvent): CommunityReport | null
* Uses a two-pass approach to prevent banned members from retaining
* moderation authority:
*
* **Pass 1 — Resolve bans (rank-ordered):**
* Collects all valid ban candidates (membership + authority checks), then
* processes them sorted by reporter rank ascending. Because bans require
* `reporter.rank < target.rank`, the ban graph is a DAG — processing in
* rank order guarantees that by the time we evaluate a rank-N reporter's
* bans, we've already finalised whether all lower-ranked members are
* banned. If a reporter is themselves banned, their bans are skipped.
* **Pass 1 — Resolve bans (authority-ordered):**
* Founder/moderators (rank 0) can ban members and non-members. Members
* (rank 1) can ban only non-members. Processing leadership before members
* ensures banned members cannot keep moderation authority.
*
* **Pass 2 — Resolve reports (filtered):**
* Processes non-ban reports, skipping any reporter who ended up in the
@@ -446,14 +421,16 @@ export function resolveCommunityModeration(
parsed.push(p);
}
// ── Pass 1: Resolve bans in rank order ─────────────────────────────
// ── Pass 1: Resolve bans in authority order ────────────────────────
//
// Bans are processed sorted by reporter rank ascending. Because bans
// require `reporter.rank < target.rank`, this guarantees that by the
// time we evaluate a rank-N reporter's bans, we've already finalised
// whether any lower-ranked members they're relying on are themselves
// banned by a higher-ranked moderator. A banned reporter's bans are
// then skipped.
// Rank 0 means founder/moderator and rank 1 means member. Non-members
// are treated as lowest rank (Infinity), so members can only ban
// non-members while founder/moderators can ban anyone.
//
// Candidates are sorted by reporter rank ascending so leadership bans
// are resolved before member bans. A reporter banned by an earlier
// authoritative action must not retain moderation authority for later
// actions in the same pass.
interface BanCandidate {
parsed: CommunityReport;
@@ -465,15 +442,14 @@ export function resolveCommunityModeration(
for (const p of parsed) {
if (p.action !== 'content-ban' && p.action !== 'member-ban') continue;
// Reporter is guaranteed to be a member (filtered above).
const reporter = members.get(p.reporterPubkey)!;
// Authority check: reporter rank must be strictly less than target rank.
// Non-members are treated as lowest rank (Infinity).
// Reporter membership is guaranteed by the parse-time filter above.
const reporterRank = members.get(p.reporterPubkey)!.rank;
const targetRank = members.get(p.targetPubkey)?.rank ?? Infinity;
if (reporter.rank >= targetRank) continue;
banCandidates.push({ parsed: p, reporterRank: reporter.rank });
// Authority check: strict rank inequality.
if (reporterRank >= targetRank) continue;
banCandidates.push({ parsed: p, reporterRank });
}
banCandidates.sort((a, b) => a.reporterRank - b.reporterRank);
@@ -513,29 +489,41 @@ export function resolveCommunityModeration(
}
/**
* Resolve community membership via the chain validation algorithm
* described in the community NIP.
* Whether a kind 8 badge award is a valid membership award for a community.
*
* 1. Seed rank 0 from the community definition (founder + moderators).
* 2. Iteratively validate badge awards — awarder must be a validated
* member with rank strictly less than the awarded badge's rank.
* Three conditions must hold (per NIP.md §Badge Awards):
* 1. The event is a kind 8 badge award.
* 2. The award author is the founder or a current moderator of the community.
* 3. The award contains an `a` tag referencing the community's member badge.
*
* This is the single source of truth for award authorization. Both the
* membership resolver and any discovery path that reaches awards through
* an unfiltered query (e.g. `#p`-based "communities I belong to" lookups)
* MUST apply this check before trusting the award.
*/
export function isAuthorizedAward(award: NostrEvent, community: ParsedCommunity): boolean {
if (award.kind !== BADGE_AWARD_KIND) return false;
if (!community.memberBadgeATag) return false;
if (award.pubkey !== community.founderPubkey && !community.moderatorPubkeys.includes(award.pubkey)) return false;
return award.tags.some(([n, v]) => n === 'a' && v === community.memberBadgeATag);
}
/**
* Resolve flat community membership from founder/moderators plus membership
* awards.
*
* Each award is validated via `isAuthorizedAward`. Callers SHOULD still query
* with `authors: [founder, ...moderators]` so the relay indexes the trust
* boundary, but this resolver enforces the same check client-side so that
* discovery paths which reach awards by other filters (e.g. `#p` on the
* viewer) stay consistent.
*/
export function resolveMembership(
community: ParsedCommunity,
awardEvents: NostrEvent[],
): CommunityMembership {
// Build badge-to-rank lookup
const badgeToRank = new Map<string, number>();
for (const tier of community.ranks) {
if (tier.badgeATag) {
badgeToRank.set(tier.badgeATag, tier.rank);
}
}
// Track validated members: pubkey -> CommunityMember
const validated = new Map<string, CommunityMember>();
// Step 1: Seed rank 0
validated.set(community.founderPubkey, {
pubkey: community.founderPubkey,
rank: 0,
@@ -546,52 +534,22 @@ export function resolveMembership(
}
}
// Step 2: Iterative validation
let changed = true;
const processed = new Set<string>();
for (const award of awardEvents) {
if (!isAuthorizedAward(award, community)) continue;
while (changed) {
changed = false;
for (const award of awardEvents) {
if (processed.has(award.id)) continue;
const recipients = award.tags
.filter(([n]) => n === 'p')
.map(([, pk]) => pk)
.filter((pk): pk is string => !!pk && HEX_PUBKEY_RE.test(pk));
const awarderPubkey = award.pubkey;
const awarder = validated.get(awarderPubkey);
if (!awarder) continue; // Awarder not validated yet
// Find which badge is being awarded
const badgeATag = award.tags.find(
([n, v]) => n === 'a' && v?.startsWith('30009:'),
)?.[1];
if (!badgeATag) continue;
const awardedRank = badgeToRank.get(badgeATag);
if (awardedRank === undefined) continue; // Badge not in this community
// Awarder must have strictly lower rank number
if (awarder.rank >= awardedRank) continue;
// Find recipient(s)
const recipients = award.tags
.filter(([n]) => n === 'p')
.map(([, pk]) => pk)
.filter(Boolean);
for (const recipientPk of recipients) {
const existing = validated.get(recipientPk);
// Only accept if it gives a better (lower) rank or first membership
if (!existing || awardedRank < existing.rank) {
validated.set(recipientPk, {
pubkey: recipientPk,
rank: awardedRank,
awardEvent: award,
awardedBy: awarderPubkey,
});
changed = true;
}
}
processed.add(award.id);
for (const recipientPk of recipients) {
if (validated.has(recipientPk)) continue;
validated.set(recipientPk, {
pubkey: recipientPk,
rank: 1,
awardEvent: award,
awardedBy: award.pubkey,
});
}
}
+2 -2
View File
@@ -329,11 +329,11 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
feedKey: 'feedIncludeCommunities',
extraFeedKinds: [9041],
label: 'Communities',
description: 'Hierarchical communities with ranked membership (NIP-72)',
description: 'Flat communities with badge-based membership (NIP-72)',
route: 'communities',
addressable: true,
section: 'social',
blurb: 'Hierarchical communities on Nostr with ranked membership, badge-based authority chains, and moderation. Founded and managed by community creators.',
blurb: 'Flat communities on Nostr with one member badge, explicit moderators, and community moderation.',
},
{
kind: 62,
+97
View File
@@ -0,0 +1,97 @@
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
/**
* Minimal Nostr query interface that `queryAll` needs. Matches the shape of
* `useNostr().nostr` as well as `nostr.relay()` / `nostr.group()` results,
* so the helper is portable across any pool/relay/group handle.
*/
interface NostrQueryable {
query(filters: NostrFilter[], opts?: { signal?: AbortSignal }): Promise<NostrEvent[]>;
}
interface QueryAllOptions {
/**
* Hard cap on total events collected. Protects against runaway relays
* that never stop returning events. Default: 5_000.
*/
maxEvents?: number;
/**
* Hard cap on paged round-trips. Also protects against misbehaving relays
* (e.g. ones that return duplicates instead of advancing). Default: 10.
*/
maxPages?: number;
/**
* Abort signal forwarded to each page query.
*/
signal?: AbortSignal;
}
/**
* Query a Nostr pool/relay/group exhaustively by paging with the `until`
* cursor, stopping when the relay returns fewer events than the filter's
* `limit` (indicating the underlying set is drained) or when either hard
* cap is reached.
*
* The filter's `limit` is used as the page size. Callers SHOULD set it
* explicitly; relays may interpret missing `limit` very differently.
*
* Caps exist so we bound worst-case work regardless of relay behaviour:
* - `maxEvents` — total events across all pages.
* - `maxPages` — total round-trips.
*
* Deduplication happens by `event.id`. A relay returning a duplicate page
* (no forward progress on the cursor) terminates the loop early.
*
* Returns events in the order they were received across pages. Callers
* that need a stable order should sort the result.
*
* This helper intentionally accepts a single filter object — the `until`
* cursor has to be applied per-filter, so a multi-filter query cannot be
* paged as a single pool. If you need to exhaust multiple independent
* filters, call `queryAll` once per filter and merge the results.
*/
export async function queryAll(
nostr: NostrQueryable,
filter: NostrFilter,
opts: QueryAllOptions = {},
): Promise<NostrEvent[]> {
const { maxEvents = 5_000, maxPages = 10, signal } = opts;
const pageSize = filter.limit;
if (!pageSize || pageSize <= 0) {
throw new Error('queryAll: filter.limit must be a positive integer');
}
const collected: NostrEvent[] = [];
const seen = new Set<string>();
let until = filter.until;
for (let page = 0; page < maxPages; page++) {
const pageFilter: NostrFilter = until !== undefined
? { ...filter, until }
: filter;
const events = await nostr.query([pageFilter], { signal });
let newCount = 0;
let oldest = Infinity;
for (const ev of events) {
if (seen.has(ev.id)) continue;
seen.add(ev.id);
collected.push(ev);
newCount++;
if (ev.created_at < oldest) oldest = ev.created_at;
if (collected.length >= maxEvents) return collected;
}
// Stop when the relay indicates the set is drained (short page) or
// when we made no forward progress (all duplicates).
if (events.length < pageSize) return collected;
if (newCount === 0) return collected;
// Advance the cursor one second past the oldest seen event. Using
// `oldest - 1` avoids re-fetching the boundary event on the next page.
until = oldest - 1;
}
return collected;
}
+56 -9
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useSeoMeta } from '@unhead/react';
import { Loader2, Users } from 'lucide-react';
import { Loader2, Search, Users } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useInView } from 'react-intersection-observer';
import { CommunityCard } from '@/components/CommunityCard';
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { LoginArea } from '@/components/auth/LoginArea';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
@@ -14,6 +16,7 @@ import { PageHeader } from '@/components/PageHeader';
import { PullToRefresh } from '@/components/PullToRefresh';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { CommunityModerationContext, type CommunityModerationContextValue } from '@/contexts/CommunityModerationContext';
import { COMMUNITY_DEFINITION_KIND, EMPTY_MODERATION } from '@/lib/communityUtils';
@@ -78,19 +81,23 @@ export function CommunitiesPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
useLayoutOptions({
hasSubHeader: !!user,
});
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useFeedTab<CommunitiesTab>('communities', [
'activities',
'mine',
]);
useLayoutOptions({
hasSubHeader: !!user,
showFAB: !!user && activeTab === 'mine',
onFabClick: () => setCreateDialogOpen(true),
fabIcon: <Users className="size-5" />,
});
useSeoMeta({
title: `Communities | ${config.appName}`,
description: 'Discover and join hierarchical communities on Nostr',
description: 'Discover and join flat communities on Nostr',
});
return (
@@ -131,6 +138,8 @@ export function CommunitiesPage() {
}
/>
)}
<CreateCommunityDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen} />
</main>
);
}
@@ -177,7 +186,25 @@ function MyCommunitiesContent() {
if (!myCommunities || myCommunities.length === 0) {
return (
<FeedEmptyState message="You haven't founded or joined any communities yet." />
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Users className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No communities yet</h2>
<p className="text-muted-foreground text-sm">
Discover communities to join via the Search page, or create your own using the button below.
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button asChild className="rounded-full">
<Link to="/search?tab=communities">
<Search className="size-4 mr-2" />
Search communities
</Link>
</Button>
</div>
</div>
);
}
@@ -188,6 +215,8 @@ function MyCommunitiesContent() {
key={entry.community.aTag}
event={entry.event}
isFounded={entry.isFounded}
isMember={entry.isMember}
isBookmarked={entry.isBookmarked}
/>
))}
</div>
@@ -294,7 +323,25 @@ function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
) : membersOnly && activityEvents && activityEvents.length > 0 ? (
<FeedEmptyState message="No activity from members of your communities yet. Toggle the shield icon to see all community activity." />
) : (
<FeedEmptyState message="No activity from your communities yet." />
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Users className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No activity yet</h2>
<p className="text-muted-foreground text-sm">
Discover communities to join via the Search page, or create your own from the My Communities tab.
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button asChild className="rounded-full">
<Link to="/search?tab=communities">
<Search className="size-4 mr-2" />
Search communities
</Link>
</Button>
</div>
</div>
)}
{!isLoading && hasNextPage && (
<div ref={scrollRef} className="py-4 flex justify-center">
+11 -2
View File
@@ -1,7 +1,8 @@
import type { NostrEvent } from "@nostrify/nostrify";
import { useSeoMeta } from "@unhead/react";
import { CalendarDays, Loader2 } from "lucide-react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { CreateCommunityEventDialog } from "@/components/CreateCommunityEventDialog";
import { FeedEmptyState } from "@/components/FeedEmptyState";
import { KindInfoButton } from "@/components/KindInfoButton";
import { NoteCard } from "@/components/NoteCard";
@@ -37,6 +38,7 @@ export function EventsFeedPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { muteItems } = useMuteList();
const [createOpen, setCreateOpen] = useState(false);
const [activeTab, setActiveTab] = useFeedTab<FeedTab>("events", [
"follows",
@@ -44,7 +46,12 @@ export function EventsFeedPage() {
]);
useSeoMeta({ title: `Events | ${config.appName}` });
useLayoutOptions({ showFAB: true, fabKind: 31923, hasSubHeader: !!user });
useLayoutOptions({
showFAB: true,
onFabClick: () => setCreateOpen(true),
fabIcon: <CalendarDays className="size-5" />,
hasSubHeader: !!user,
});
// Calendar events feed
const feedQuery = useFeed(activeTab, { kinds: [31922, 31923] });
@@ -160,6 +167,8 @@ export function EventsFeedPage() {
/>
)}
</PullToRefresh>
<CreateCommunityEventDialog open={createOpen} onOpenChange={setCreateOpen} />
</main>
);
}
+1 -1
View File
@@ -91,7 +91,7 @@ const NOTIFICATION_KIND_NOUNS: Record<number, string> = {
34139: 'playlist',
34236: 'divine',
34550: 'community',
9041: 'fundraising goal',
9041: 'goal',
35128: 'nsite',
36787: 'track',
37381: 'Magic deck',
+1 -1
View File
@@ -128,7 +128,7 @@ function shellTitleForKind(kind?: number): string {
if (PODCAST_KINDS.has(kind)) return "Episode Details";
if (CALENDAR_EVENT_KINDS.has(kind)) return "Event Details";
if (kind === 34550) return "Community";
if (kind === 9041) return "Fundraising Goal";
if (kind === 9041) return "Goal";
if (FOLLOW_PACK_KINDS.has(kind)) return "Follow Pack";
if (kind === LIVE_STREAM_KIND) return "Live Stream";
if (kind === 30617) return "Repository";
+2 -3
View File
@@ -62,7 +62,7 @@ type TabType = 'communities' | 'posts' | 'accounts';
const VALID_TABS: TabType[] = ['communities', 'posts', 'accounts'];
function parseTab(value: string | null): TabType {
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'posts';
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'communities';
}
const VALID_AUTHOR_SCOPES = ['anyone', 'follows', 'people'] as const;
@@ -199,7 +199,7 @@ export function SearchPage() {
const setActiveTab = useCallback((tab: TabType) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (tab === 'posts') {
if (tab === 'communities') {
next.delete('tab');
} else {
next.set('tab', tab);
@@ -1208,4 +1208,3 @@ function SaveDestinationRow({
);
}