Merge branch 'main' into feat/dms
This commit is contained in:
@@ -19,6 +19,12 @@
|
||||
| 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards |
|
||||
| 36639 | Activist Action | Country-scoped activist challenge with a sats bounty |
|
||||
|
||||
### Agora Protocols
|
||||
|
||||
| Protocol | Composed Kinds | Description |
|
||||
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
|
||||
| Hierarchical Communities | 34550, 30009, 8, 1111, 1984, 5 | Ranked community membership via badge award chains (NIP-72 ext) |
|
||||
|
||||
### Community Kinds
|
||||
|
||||
These event kinds were created by community contributors and are supported by Ditto. Full specifications are maintained by their respective authors.
|
||||
@@ -559,6 +565,311 @@ Clients SHOULD only surface events from the last hour (`since = now - 3600`). Ol
|
||||
|
||||
---
|
||||
|
||||
## Hierarchical Communities
|
||||
|
||||
Hierarchical communities on Nostr, composed from existing event kinds. Communities have ranked membership where authority flows downward through a chain of badge awards.
|
||||
|
||||
**No new event kinds are introduced.** The system composes:
|
||||
|
||||
- **Kind 34550** ([NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)) -- Community Definition
|
||||
- **Kind 30009** ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)) -- Badge Definition
|
||||
- **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)) -- Deletion / Revocation
|
||||
|
||||
### Overview
|
||||
|
||||
A hierarchical 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.
|
||||
4. **Posts** (kind `1111`) scoped to the community via NIP-22.
|
||||
5. **Reports** (kind `1984`) scoped to the community for content removal or member bans.
|
||||
6. **Deletion requests** (kind `5`) for revoking awards or rescinding moderation.
|
||||
|
||||
### Membership Derivation
|
||||
|
||||
Community membership is derived from three distinct sources, each resolved differently:
|
||||
|
||||
- **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).
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
#### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|-----|----------|-------------|
|
||||
| `d` | Yes | Unique community identifier (UUID recommended). |
|
||||
| `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"`. |
|
||||
| `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>"]
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
#### Example
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 34550,
|
||||
"pubkey": "<founder-pubkey>",
|
||||
"content": "",
|
||||
"tags": [
|
||||
["d", "a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
|
||||
["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"],
|
||||
["p", "<co-moderator-pubkey>", "", "moderator"],
|
||||
["relay", "wss://relay.example.com"],
|
||||
["alt", "Community: The Arbiter's Guard"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 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 `d` tag SHOULD use the format `<community-d-tag>-<rank-name>` for global uniqueness.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 30009,
|
||||
"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"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
```jsonc
|
||||
// Moderator (rank 0) awarding Staff (rank 1)
|
||||
{
|
||||
"kind": 8,
|
||||
"pubkey": "<founder-pubkey>",
|
||||
"content": "",
|
||||
"tags": [
|
||||
["a", "30009:<founder-pubkey>:a1b2c3d4-...-staff"],
|
||||
["p", "<recipient-pubkey>"],
|
||||
["alt", "Badge award: Staff in The Arbiter's Guard"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Chain Validation
|
||||
|
||||
Membership is **derived state**. Clients compute effective membership by resolving the authority graph from badge awards, then applying moderation overlays.
|
||||
|
||||
#### 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. **Apply moderation**: Query `{ kinds: [1984], #A: [<community-a-tag>] }`. Remove banned members (but not their downstream subtrees -- the chain remains intact). Hide reported posts.
|
||||
|
||||
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.
|
||||
|
||||
### Community Posts
|
||||
|
||||
Community discussion uses kind `1111` ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) scoped to the community definition as the root event.
|
||||
|
||||
#### Top-Level Post
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1111,
|
||||
"content": "Hello clan!",
|
||||
"tags": [
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>", "<relay-hint>"],
|
||||
["K", "34550"],
|
||||
["P", "<founder-pubkey>", "<relay-hint>"],
|
||||
["a", "34550:<founder-pubkey>:<community-d-tag>", "<relay-hint>"],
|
||||
["k", "34550"],
|
||||
["p", "<founder-pubkey>", "<relay-hint>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Reply
|
||||
|
||||
Replies keep the community as root scope and point to the parent comment:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1111,
|
||||
"content": "Great point!",
|
||||
"tags": [
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>", "<relay-hint>"],
|
||||
["K", "34550"],
|
||||
["P", "<founder-pubkey>", "<relay-hint>"],
|
||||
["e", "<parent-comment-id>", "<relay-hint>", "<parent-author-pubkey>"],
|
||||
["k", "1111"],
|
||||
["p", "<parent-author-pubkey>", "<relay-hint>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Querying
|
||||
|
||||
Fetch all community-scoped posts and moderation data in a single request:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kinds": [1111, 1984],
|
||||
"#A": ["34550:<founder-pubkey>:<community-d-tag>"]
|
||||
}
|
||||
```
|
||||
|
||||
Clients then filter client-side: discard kind `1111` posts from non-members, and apply authoritative kind `1984` reports per the moderation rules below.
|
||||
|
||||
### 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. Reports from higher-ranked members are treated as **authoritative moderation actions**.
|
||||
|
||||
#### Authority Rules
|
||||
|
||||
A report is **authoritative** if:
|
||||
|
||||
1. The reporter is a validated community member.
|
||||
2. The reporter's rank is strictly less than the target's rank (or the target is a non-member).
|
||||
|
||||
Reports from non-members or insufficiently-ranked members are ignored.
|
||||
|
||||
#### Content Removal
|
||||
|
||||
Hide a post by publishing kind `1984` with both `e` and `p` tags:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1984,
|
||||
"pubkey": "<moderator-pubkey>",
|
||||
"content": "Spam",
|
||||
"tags": [
|
||||
["e", "<offending-event-id>"],
|
||||
["p", "<offending-author-pubkey>"],
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Member Ban
|
||||
|
||||
Ban a member by publishing kind `1984` with `p` tag only (no `e` tag). 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.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1984,
|
||||
"pubkey": "<moderator-pubkey>",
|
||||
"content": "Violated guidelines",
|
||||
"tags": [
|
||||
["p", "<banned-member-pubkey>"],
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients distinguish content removal (`e` + `p` + `A`) from bans (`p` + `A`, no `e`).
|
||||
|
||||
#### Reinstatement
|
||||
|
||||
Delete 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 can delete it.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 5,
|
||||
"tags": [["e", "<kind-1984-event-id>"], ["k", "1984"]]
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
### Discovery
|
||||
|
||||
**Communities founded by a user:**
|
||||
|
||||
```jsonc
|
||||
{ "kinds": [34550], "authors": ["<user-pubkey>"] }
|
||||
```
|
||||
|
||||
**Communities a user belongs to:**
|
||||
|
||||
1. `{ "kinds": [8], "#p": ["<user-pubkey>"] }`
|
||||
2. Extract badge `a` tags from results.
|
||||
3. `{ "kinds": [34550], "#a": ["30009:...", "..."] }`
|
||||
|
||||
### 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.
|
||||
|
||||
### 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-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
|
||||
|
||||
---
|
||||
|
||||
## Kind 0 Extension: Avatar Shape
|
||||
|
||||
### Summary
|
||||
|
||||
Generated
-25
@@ -5827,7 +5827,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5841,7 +5840,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5855,7 +5853,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5869,7 +5866,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5883,7 +5879,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5897,7 +5892,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5911,7 +5905,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5925,7 +5918,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5939,7 +5931,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5953,7 +5944,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5967,7 +5957,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5981,7 +5970,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5995,7 +5983,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6009,7 +5996,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6023,7 +6009,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6037,7 +6022,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6051,7 +6035,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6065,7 +6048,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6079,7 +6061,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6093,7 +6074,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6107,7 +6087,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6121,7 +6100,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6135,7 +6113,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6149,7 +6126,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6163,7 +6139,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -106,6 +106,8 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludePodcastTrailers: true,
|
||||
showDevelopment: true,
|
||||
feedIncludeDevelopment: true,
|
||||
showCommunities: true,
|
||||
feedIncludeCommunities: true,
|
||||
showBadges: true,
|
||||
showBadgeDefinitions: true,
|
||||
showProfileBadges: true,
|
||||
@@ -125,6 +127,7 @@ const hardcodedConfig: AppConfig = {
|
||||
"feed",
|
||||
"notifications",
|
||||
"messages",
|
||||
"communities",
|
||||
"profile",
|
||||
"settings",
|
||||
],
|
||||
|
||||
@@ -33,6 +33,7 @@ const ActionsPage = lazy(() => import("./pages/ActionsPage"));
|
||||
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
|
||||
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
|
||||
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
|
||||
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
|
||||
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
@@ -166,6 +167,7 @@ export function AppRouter() {
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/world" element={<WorldPage />} />
|
||||
<Route path="/badges" element={<BadgesPage />} />
|
||||
<Route path="/communities" element={<CommunitiesPage />} />
|
||||
<Route path="/letters" element={<LettersPage />} />
|
||||
<Route path="/letters/compose" element={<LetterComposePage />} />
|
||||
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Crown, Shield, Users } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { parseCommunityEvent, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CommunityCardProps {
|
||||
/** The kind 34550 community definition event. */
|
||||
event: NostrEvent;
|
||||
/** Whether the current user founded this community. */
|
||||
isFounded?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const community = useMemo(() => parseCommunityEvent(event), [event]);
|
||||
|
||||
const founderAuthor = useAuthor(event.pubkey);
|
||||
const founderMeta = founderAuthor.data?.metadata;
|
||||
const founderAvatarShape = getAvatarShape(founderMeta);
|
||||
const founderName = founderMeta?.display_name || founderMeta?.name || genUserName(event.pubkey);
|
||||
const founderProfileUrl = useProfileUrl(event.pubkey, founderMeta);
|
||||
|
||||
if (!community) return null;
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
pubkey: event.pubkey,
|
||||
identifier: community.dTag,
|
||||
});
|
||||
|
||||
const rankCount = community.ranks.length - 1; // Exclude rank 0
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${naddr}`}
|
||||
className={cn(
|
||||
'group block rounded-xl border border-border hover:border-primary/30 transition-all hover:shadow-md overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Image banner */}
|
||||
{community.image ? (
|
||||
<div className="relative h-28 overflow-hidden bg-muted">
|
||||
<img
|
||||
src={community.image}
|
||||
alt={community.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-28 bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<Users className="size-10 text-primary/20" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Name + founder badge */}
|
||||
<div className="flex items-start gap-2">
|
||||
<h3 className="text-sm font-semibold truncate flex-1 group-hover:text-primary transition-colors">
|
||||
{community.name}
|
||||
</h3>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{community.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
||||
{community.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer: founder + stats */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Link
|
||||
to={founderProfileUrl}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-1.5 min-w-0"
|
||||
>
|
||||
<Avatar shape={founderAvatarShape} className="size-5">
|
||||
<AvatarImage src={founderMeta?.picture} />
|
||||
<AvatarFallback className="text-[8px] bg-muted">
|
||||
{founderName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[11px] text-muted-foreground truncate hover:underline">
|
||||
{founderName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{community.moderatorPubkeys.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Shield className="size-3" />
|
||||
{community.moderatorPubkeys.length}
|
||||
</span>
|
||||
)}
|
||||
{rankCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Users className="size-3" />
|
||||
{rankCount} rank{rankCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,12 @@ import { useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users, Share2, Globe } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
@@ -43,46 +40,15 @@ function parseCommunityEvent(event: NostrEvent) {
|
||||
return { name, description, image, moderators, relays };
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function ModeratorRow({ pubkey }: { pubkey: string }) {
|
||||
const { data } = useAuthor(pubkey);
|
||||
const metadata: NostrMetadata | undefined = data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
return (
|
||||
<Link to={profileUrl} className="flex items-center gap-3 group py-1.5">
|
||||
<Avatar shape={avatarShape} className="size-9 ring-2 ring-background">
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate group-hover:underline">{name}</p>
|
||||
{metadata?.nip05 && (
|
||||
<p className="text-xs text-muted-foreground truncate">{metadata.nip05}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">Moderator</Badge>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
const { toast } = useToast();
|
||||
const { name, description, image, moderators, relays } = useMemo(
|
||||
const { name, description, image } = useMemo(
|
||||
() => parseCommunityEvent(event),
|
||||
[event],
|
||||
);
|
||||
|
||||
// Batch-fetch moderator profiles
|
||||
useAuthors(moderators);
|
||||
|
||||
// Owner
|
||||
const ownerAuthor = useAuthor(event.pubkey);
|
||||
const ownerMetadata = ownerAuthor.data?.metadata;
|
||||
@@ -144,18 +110,8 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick stats + share */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{moderators.length} moderator{moderators.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{relays.length > 0 && (
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<Globe className="size-3.5" />
|
||||
{relays.length} relay{relays.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
{/* 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>
|
||||
@@ -198,22 +154,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Moderators */}
|
||||
{moderators.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Users className="size-3.5" />
|
||||
Moderators
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
{moderators.map((pk) => (
|
||||
<ModeratorRow key={pk} pubkey={pk} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
Crown,
|
||||
MessageCircle,
|
||||
Shield,
|
||||
Share2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
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 { ComposeBox } from '@/components/ComposeBox';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { parseCommunityEvent, type CommunityMember } from '@/lib/communityUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ── Calendar event kinds (NIP-52) ─────────────────────────────────────────────
|
||||
const CALENDAR_EVENT_KINDS = [31922, 31923];
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: string; size?: 'sm' | 'md' }) {
|
||||
const { data } = useAuthor(pubkey);
|
||||
const metadata: NostrMetadata | undefined = data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
const avatarCls = size === 'sm' ? 'size-8' : 'size-10';
|
||||
const fallbackCls = size === 'sm' ? 'text-xs' : '';
|
||||
|
||||
return (
|
||||
<Link to={profileUrl} className="flex items-center gap-3 group py-1">
|
||||
<Avatar shape={avatarShape} className={cn(avatarCls, 'ring-2 ring-background')}>
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className={cn('bg-muted text-muted-foreground', fallbackCls)}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('font-medium truncate group-hover:underline', size === 'sm' ? 'text-sm' : 'text-[15px]')}>{name}</p>
|
||||
{metadata?.nip05 && (
|
||||
<p className="text-xs text-muted-foreground truncate">{metadata.nip05}</p>
|
||||
)}
|
||||
</div>
|
||||
{label && (
|
||||
<Badge variant="secondary" className="ml-auto capitalize text-xs shrink-0">{label}</Badge>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-[180px] w-full rounded-lg" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyCardSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
const navigate = useNavigate();
|
||||
const { nostr } = useNostr();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Parse community definition
|
||||
const community = useMemo(() => parseCommunityEvent(event), [event]);
|
||||
const name = community?.name ?? 'Unnamed Community';
|
||||
const description = community?.description ?? '';
|
||||
const image = community?.image;
|
||||
const communityATag = community?.aTag ?? '';
|
||||
|
||||
// Extract website URL from description
|
||||
const descriptionUrl = useMemo(() => {
|
||||
const urlMatch = description.match(/https?:\/\/[^\s]+/);
|
||||
return sanitizeUrl(urlMatch?.[0]);
|
||||
}, [description]);
|
||||
|
||||
const descriptionText = useMemo(() => {
|
||||
if (!descriptionUrl) return description;
|
||||
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
|
||||
}, [description, descriptionUrl]);
|
||||
|
||||
// ── Members ─────────────────────────────────────────────────────────────────
|
||||
const { data: membership, isLoading: membersLoading } = useCommunityMembers(community);
|
||||
|
||||
// Batch-fetch profiles for all members
|
||||
const allMemberPubkeys = useMemo(
|
||||
() => membership?.members.map((m) => m.pubkey) ?? [],
|
||||
[membership],
|
||||
);
|
||||
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);
|
||||
}
|
||||
// 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]);
|
||||
|
||||
// ── Events (calendar events tagging this community) ─────────────────────────
|
||||
const { data: communityEvents, isLoading: eventsLoading } = useQuery({
|
||||
queryKey: ['community-events', communityATag],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!communityATag) return [];
|
||||
const events = await nostr.query(
|
||||
[{ kinds: CALENDAR_EVENT_KINDS, '#a': [communityATag], limit: 100 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8_000)]) },
|
||||
);
|
||||
// Sort by start date descending
|
||||
return events.sort((a, b) => {
|
||||
const aStart = parseInt(a.tags.find(([n]) => n === 'start')?.[1] ?? '0', 10);
|
||||
const bStart = parseInt(b.tags.find(([n]) => n === 'start')?.[1] ?? '0', 10);
|
||||
return bStart - aStart;
|
||||
});
|
||||
},
|
||||
enabled: !!communityATag,
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
|
||||
// ── Comments (NIP-22 on the community event) ───────────────────────────────
|
||||
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
|
||||
|
||||
const replyTree = useMemo((): ReplyNode[] => {
|
||||
if (!commentsData) return [];
|
||||
const topLevel = commentsData.topLevelComments ?? [];
|
||||
|
||||
const buildNode = (ev: NostrEvent): ReplyNode => {
|
||||
const allChildren = commentsData.getDirectReplies(ev.id) ?? [];
|
||||
if (allChildren.length <= 1) {
|
||||
return {
|
||||
event: ev,
|
||||
children: allChildren.map((c) => buildNode(c)),
|
||||
};
|
||||
}
|
||||
const [first, ...rest] = allChildren;
|
||||
return {
|
||||
event: ev,
|
||||
children: [buildNode(first)],
|
||||
hiddenChildren: rest.map((c) => buildNode(c)),
|
||||
};
|
||||
};
|
||||
|
||||
return [...topLevel].sort((a, b) => a.created_at - b.created_at).map((r) => buildNode(r));
|
||||
}, [commentsData]);
|
||||
|
||||
// ── Share handler ───────────────────────────────────────────────────────────
|
||||
const handleShare = useCallback(async () => {
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
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]);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Founder */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Founded by</p>
|
||||
<PersonRow pubkey={event.pubkey} />
|
||||
</div>
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
<Tabs defaultValue="members" className="-mx-5">
|
||||
<TabsList className="w-full 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"
|
||||
>
|
||||
<Users className="size-4 mr-1.5" />
|
||||
Members
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="events"
|
||||
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"
|
||||
>
|
||||
<CalendarDays className="size-4 mr-1.5" />
|
||||
Events
|
||||
</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"
|
||||
>
|
||||
<MessageCircle className="size-4 mr-1.5" />
|
||||
Comments
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Members tab ── */}
|
||||
<TabsContent value="members" className="mt-0">
|
||||
{membersLoading ? (
|
||||
<MembersSkeleton />
|
||||
) : membersByRank.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">
|
||||
<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" />}
|
||||
{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) {
|
||||
roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator';
|
||||
}
|
||||
return (
|
||||
<PersonRow
|
||||
key={m.pubkey}
|
||||
pubkey={m.pubkey}
|
||||
label={roleLabel}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Events tab ── */}
|
||||
<TabsContent value="events" className="mt-0">
|
||||
{eventsLoading ? (
|
||||
<EventsSkeleton />
|
||||
) : !communityEvents || communityEvents.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground text-sm">No events yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{communityEvents.map((ev) => (
|
||||
<NoteCard key={ev.id} event={ev} compact />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Comments tab ── */}
|
||||
<TabsContent value="comments" className="mt-0">
|
||||
<ComposeBox compact replyTo={event} />
|
||||
|
||||
{commentsLoading ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<ThreadedReplyList roots={replyTree} />
|
||||
) : (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
No comments yet. Be the first to comment!
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -399,6 +399,8 @@ function SetupQuestionnaire({
|
||||
feedIncludePodcastTrailers: false,
|
||||
showDevelopment: false,
|
||||
feedIncludeDevelopment: false,
|
||||
showCommunities: true,
|
||||
feedIncludeCommunities: true,
|
||||
showBadges: false,
|
||||
showBadgeDefinitions: true,
|
||||
showProfileBadges: true,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
PodcastTrailerContent,
|
||||
} from "@/components/AudioKindContent";
|
||||
import { BadgeContent } from "@/components/BadgeContent";
|
||||
import { CommunityContent } from "@/components/CommunityContent";
|
||||
import { CalendarEventContent } from "@/components/CalendarEventContent";
|
||||
import {
|
||||
ColorMomentContent,
|
||||
@@ -404,6 +405,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isBadgeDefinition = event.kind === 30009;
|
||||
const isProfileBadges = event.kind === 10008 || event.kind === 30008;
|
||||
const isBadge = isBadgeDefinition || isProfileBadges;
|
||||
const isCommunity = event.kind === 34550;
|
||||
const isReaction = event.kind === 7;
|
||||
const isPollVote = event.kind === 1018;
|
||||
const isRepost = event.kind === 6 || event.kind === 16;
|
||||
@@ -449,6 +451,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isCalendarEvent &&
|
||||
!isEmojiPack &&
|
||||
!isBadge &&
|
||||
!isCommunity &&
|
||||
!isReaction &&
|
||||
!isPollVote &&
|
||||
!isRepost &&
|
||||
@@ -603,6 +606,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
<BadgeContent event={event} />
|
||||
) : isProfileBadges ? (
|
||||
<ProfileBadgesContent event={event} />
|
||||
) : isCommunity ? (
|
||||
<CommunityContent event={event} />
|
||||
) : isTheme ? (
|
||||
<ThemeContent event={event} />
|
||||
) : isVoiceMessage ? (
|
||||
@@ -1704,6 +1709,12 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
noun: "emoji pack",
|
||||
nounRoute: "/emojis",
|
||||
},
|
||||
34550: {
|
||||
icon: Users,
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
|
||||
noun: "community",
|
||||
nounRoute: "/communities",
|
||||
},
|
||||
30009: {
|
||||
icon: Award,
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
|
||||
|
||||
@@ -136,6 +136,10 @@ export interface FeedSettings {
|
||||
showDevelopment: boolean;
|
||||
/** Include Development content in the follows/global feed */
|
||||
feedIncludeDevelopment: boolean;
|
||||
/** Show Communities (NIP-72 kind 34550) link in sidebar */
|
||||
showCommunities: boolean;
|
||||
/** Include community definitions (kind 34550) in the follows/global feed */
|
||||
feedIncludeCommunities: boolean;
|
||||
/** Show Badges (NIP-58 kind 30009) link in sidebar */
|
||||
showBadges: boolean;
|
||||
/** Show badge definitions (kind 30009) on the Badges page */
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useMyCommunities } from './useMyCommunities';
|
||||
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
|
||||
|
||||
/**
|
||||
* Fetches a chronological activity feed for communities the current user
|
||||
* belongs to (founded or joined).
|
||||
*
|
||||
* The feed merges:
|
||||
* 1. Kind 34550 community definition events for the user's communities
|
||||
* 2. Kind 1111 NIP-22 comments scoped to those communities (via #A tag)
|
||||
*
|
||||
* Sorted by created_at descending.
|
||||
*/
|
||||
export function useCommunityActivityFeed() {
|
||||
const { nostr } = useNostr();
|
||||
const { data: myCommunities, isLoading: communitiesLoading } = useMyCommunities();
|
||||
|
||||
const aTags = myCommunities?.map((c) => c.community.aTag).filter(Boolean) ?? [];
|
||||
const aTagsKey = aTags.join(',');
|
||||
|
||||
return useQuery<NostrEvent[]>({
|
||||
queryKey: ['community-activity-feed', aTagsKey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (aTags.length === 0) return [];
|
||||
|
||||
const timeout = AbortSignal.timeout(8_000);
|
||||
const combinedSignal = AbortSignal.any([signal, timeout]);
|
||||
|
||||
// Fetch community definition events and scoped comments in parallel
|
||||
const [definitionEvents, comments] = await Promise.all([
|
||||
// The community definitions themselves
|
||||
nostr.query(
|
||||
[{
|
||||
kinds: [COMMUNITY_DEFINITION_KIND],
|
||||
authors: myCommunities!.map((c) => c.event.pubkey),
|
||||
'#d': myCommunities!.map((c) => c.community.dTag),
|
||||
limit: 50,
|
||||
}],
|
||||
{ signal: combinedSignal },
|
||||
),
|
||||
// Kind 1111 comments scoped to these communities via uppercase A tag
|
||||
nostr.query(
|
||||
[{
|
||||
kinds: [1111],
|
||||
'#A': aTags,
|
||||
limit: 100,
|
||||
}],
|
||||
{ signal: combinedSignal },
|
||||
),
|
||||
]);
|
||||
|
||||
// Merge and deduplicate
|
||||
const seen = new Set<string>();
|
||||
const merged: NostrEvent[] = [];
|
||||
|
||||
for (const event of [...definitionEvents, ...comments]) {
|
||||
if (seen.has(event.id)) continue;
|
||||
seen.add(event.id);
|
||||
merged.push(event);
|
||||
}
|
||||
|
||||
// Sort by created_at descending
|
||||
return merged.sort((a, b) => b.created_at - a.created_at);
|
||||
},
|
||||
enabled: !communitiesLoading && aTags.length > 0,
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
type ParsedCommunity,
|
||||
type CommunityMembership,
|
||||
BADGE_AWARD_KIND,
|
||||
resolveMembership,
|
||||
} from '@/lib/communityUtils';
|
||||
|
||||
/**
|
||||
* Fetch and resolve the full membership tree for a community.
|
||||
*
|
||||
* Queries badge awards (kind 8) and runs the chain validation algorithm
|
||||
* from the community NIP.
|
||||
*
|
||||
* TODO: add kind 1984 (reports) and kind 5 (deletions) for moderation overlay.
|
||||
*/
|
||||
export function useCommunityMembers(community: ParsedCommunity | null | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<CommunityMembership>({
|
||||
queryKey: ['community-members', community?.aTag ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!community) return { members: [], totalCount: 0 };
|
||||
|
||||
// Collect all badge a-tag coordinates from the community definition
|
||||
const badgeATags = community.ranks
|
||||
.filter((r) => r.badgeATag)
|
||||
.map((r) => r.badgeATag!);
|
||||
|
||||
if (badgeATags.length === 0) {
|
||||
// No badge ranks defined — only founder + moderators
|
||||
return resolveMembership(community, []);
|
||||
}
|
||||
|
||||
// Fetch badge awards scoped to this community's badge definitions
|
||||
const awards = await nostr.query(
|
||||
[{ kinds: [BADGE_AWARD_KIND], '#a': badgeATags, limit: 500 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]) },
|
||||
);
|
||||
|
||||
// TODO: query kind 1984 reports and kind 5 deletions for moderation overlay
|
||||
return resolveMembership(community, awards);
|
||||
},
|
||||
enabled: !!community,
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const DEFAULT_SIDEBAR_ORDER: string[] = [
|
||||
'badges',
|
||||
'feed',
|
||||
'notifications',
|
||||
'communities',
|
||||
'profile',
|
||||
'settings',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import {
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
BADGE_AWARD_KIND,
|
||||
parseCommunityEvent,
|
||||
type ParsedCommunity,
|
||||
} from '@/lib/communityUtils';
|
||||
|
||||
export interface MyCommunityEntry {
|
||||
/** The parsed community data. */
|
||||
community: ParsedCommunity;
|
||||
/** The raw kind 34550 event. */
|
||||
event: NostrEvent;
|
||||
/** Whether the current user is the founder. */
|
||||
isFounded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch communities the logged-in user has founded or been recruited into.
|
||||
*
|
||||
* 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,
|
||||
* then find the community definitions referencing those badges.
|
||||
*/
|
||||
export function useMyCommunities() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useQuery<MyCommunityEntry[]>({
|
||||
queryKey: ['my-communities', user?.pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user) return [];
|
||||
|
||||
const timeout = AbortSignal.timeout(10_000);
|
||||
const combinedSignal = AbortSignal.any([signal, timeout]);
|
||||
|
||||
// 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 },
|
||||
);
|
||||
|
||||
// Extract badge a-tag coordinates from awards
|
||||
const badgeATags = new Set<string>();
|
||||
for (const award of awards) {
|
||||
for (const tag of award.tags) {
|
||||
if (tag[0] === 'a' && tag[1]?.startsWith('30009:')) {
|
||||
badgeATags.add(tag[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Find community definitions that reference these badges
|
||||
let memberCommunityEvents: NostrEvent[] = [];
|
||||
if (badgeATags.size > 0) {
|
||||
memberCommunityEvents = await nostr.query(
|
||||
[{ kinds: [COMMUNITY_DEFINITION_KIND], '#a': [...badgeATags], limit: 100 }],
|
||||
{ signal: combinedSignal },
|
||||
);
|
||||
}
|
||||
|
||||
// Merge and deduplicate (founded takes priority)
|
||||
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 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: founded first, then by created_at descending
|
||||
return Array.from(seen.values()).sort((a, b) => {
|
||||
if (a.isFounded !== b.isFounded) return a.isFounded ? -1 : 1;
|
||||
return b.event.created_at - a.event.created_at;
|
||||
});
|
||||
},
|
||||
enabled: !!user,
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
// ── Kind constants ────────────────────────────────────────────────────────────
|
||||
|
||||
/** NIP-72 community definition (addressable). */
|
||||
export const COMMUNITY_DEFINITION_KIND = 34550;
|
||||
|
||||
/** NIP-58 badge definition. */
|
||||
export const BADGE_DEFINITION_KIND = 30009;
|
||||
|
||||
/** NIP-58 badge award. */
|
||||
export const BADGE_AWARD_KIND = 8;
|
||||
|
||||
// TODO: kind 1984 (NIP-56 reports) and kind 5 (NIP-09 deletions) will be
|
||||
// added when the moderation overlay is implemented.
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
// ── Parsed community ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ParsedCommunity {
|
||||
/** The `d` tag value (community identifier). */
|
||||
dTag: string;
|
||||
/** Human-readable name. */
|
||||
name: string;
|
||||
/** Description text. */
|
||||
description: string;
|
||||
/** Sanitized image URL. */
|
||||
image?: string;
|
||||
/** Founder pubkey (the event publisher). */
|
||||
founderPubkey: string;
|
||||
/** Moderator pubkeys (from `p` tags with role "moderator"). */
|
||||
moderatorPubkeys: string[];
|
||||
/** Ordered rank tiers (rank 0 first, then badge-based ranks). */
|
||||
ranks: RankTier[];
|
||||
/** Recommended relay URLs. */
|
||||
relays: string[];
|
||||
/** The `a` tag coordinate for the community: `34550:<pubkey>:<d-tag>`. */
|
||||
aTag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a kind 34550 community definition event into structured data.
|
||||
* Returns `null` if the event is invalid or missing required tags.
|
||||
*/
|
||||
export function parseCommunityEvent(event: NostrEvent): ParsedCommunity | null {
|
||||
if (event.kind !== COMMUNITY_DEFINITION_KIND) return null;
|
||||
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
if (!dTag) return null;
|
||||
|
||||
const name = event.tags.find(([n]) => n === 'name')?.[1] || dTag;
|
||||
const description = event.tags.find(([n]) => n === 'description')?.[1] || '';
|
||||
const rawImage = event.tags.find(([n]) => n === 'image')?.[1];
|
||||
const image = sanitizeUrl(rawImage);
|
||||
|
||||
// Moderators: p tags with "moderator" role (4th element)
|
||||
const moderatorPubkeys = event.tags
|
||||
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
|
||||
.map(([, pubkey]) => pubkey)
|
||||
.filter(Boolean);
|
||||
|
||||
// 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];
|
||||
|
||||
// Relay URLs
|
||||
const relays = event.tags
|
||||
.filter(([n]) => n === 'relay')
|
||||
.map(([, url]) => url)
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
dTag,
|
||||
name,
|
||||
description,
|
||||
image,
|
||||
founderPubkey: event.pubkey,
|
||||
moderatorPubkeys,
|
||||
ranks,
|
||||
relays,
|
||||
aTag: `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Community member ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface CommunityMember {
|
||||
/** Member's pubkey. */
|
||||
pubkey: string;
|
||||
/** Their effective rank in this community. */
|
||||
rank: number;
|
||||
/** The badge award event that established membership (undefined for rank 0). */
|
||||
awardEvent?: NostrEvent;
|
||||
/** Pubkey of whoever awarded them (undefined for rank 0). */
|
||||
awardedBy?: string;
|
||||
}
|
||||
|
||||
export interface CommunityMembership {
|
||||
/** All validated members grouped by rank. */
|
||||
members: CommunityMember[];
|
||||
/** Convenience: count of all members (including founder + moderators). */
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve community membership via the chain validation algorithm
|
||||
* described in the community NIP.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* TODO: Step 3 (moderation overlay via kind 1984 bans + kind 5 deletions)
|
||||
* is not yet implemented.
|
||||
*/
|
||||
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,
|
||||
});
|
||||
for (const modPk of community.moderatorPubkeys) {
|
||||
if (!validated.has(modPk)) {
|
||||
validated.set(modPk, { pubkey: modPk, rank: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Iterative validation
|
||||
let changed = true;
|
||||
const processed = new Set<string>();
|
||||
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const award of awardEvents) {
|
||||
if (processed.has(award.id)) continue;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const members = Array.from(validated.values());
|
||||
members.sort((a, b) => a.rank - b.rank);
|
||||
|
||||
return {
|
||||
members,
|
||||
totalCount: members.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `a` tag coordinate string for a community event.
|
||||
*/
|
||||
export function getCommunityATag(event: NostrEvent): string {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
return `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`;
|
||||
}
|
||||
@@ -351,6 +351,18 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
blurb: 'Curated lists of people to follow. Browse or create your own.',
|
||||
sites: [{ url: 'https://following.space', name: 'following.space' }, { url: 'https://following.party', name: 'following.party' }],
|
||||
},
|
||||
{
|
||||
kind: 34550,
|
||||
id: 'communities',
|
||||
showKey: 'showCommunities',
|
||||
feedKey: 'feedIncludeCommunities',
|
||||
label: 'Communities',
|
||||
description: 'Hierarchical communities with ranked 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.',
|
||||
},
|
||||
{
|
||||
kind: 62,
|
||||
id: 'vanish',
|
||||
|
||||
@@ -181,6 +181,8 @@ export const FeedSettingsSchema = z.looseObject({
|
||||
showDevelopment: z.boolean().optional(),
|
||||
feedIncludeDevelopment: z.boolean().optional(),
|
||||
feedIncludeBlobbi: z.boolean().optional(),
|
||||
showCommunities: z.boolean().optional(),
|
||||
feedIncludeCommunities: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** Schema for a NIP-01 filter object (lenient — allows variable placeholder strings). */
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Smile,
|
||||
SmilePlus,
|
||||
User,
|
||||
Users,
|
||||
Vote,
|
||||
WalletMinimal,
|
||||
Zap,
|
||||
@@ -158,6 +159,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
|
||||
{ id: "articles", label: "Articles", path: "/articles", icon: Newspaper },
|
||||
{ id: "polls", label: "Polls", path: "/polls", icon: Vote },
|
||||
{ id: "badges", label: "Badges", path: "/badges", icon: Award },
|
||||
{ id: "communities", label: "Communities", path: "/communities", icon: Users },
|
||||
{ id: "world", label: "World", path: "/world", icon: Earth },
|
||||
];
|
||||
|
||||
@@ -195,6 +197,7 @@ export const CONTENT_KIND_ICONS: Record<string, IconComponent> = {
|
||||
emojis: SmilePlus,
|
||||
development: Code,
|
||||
badges: HelpCircle,
|
||||
communities: Users,
|
||||
world: Earth,
|
||||
archive: HelpCircle,
|
||||
bluesky: HelpCircle,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
import { CommunityCard } from '@/components/CommunityCard';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCommunityActivityFeed } from '@/hooks/useCommunityActivityFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedTab } from '@/hooks/useFeedTab';
|
||||
import { useMyCommunities } from '@/hooks/useMyCommunities';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CommunitiesTab = 'activities' | 'mine';
|
||||
|
||||
// ─── Skeletons ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function CommunityCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<div className="p-3 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCardSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-6 mt-3 -ml-2">
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CommunitiesPage() {
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useLayoutOptions({
|
||||
hasSubHeader: !!user,
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useFeedTab<CommunitiesTab>('communities', [
|
||||
'activities',
|
||||
'mine',
|
||||
]);
|
||||
|
||||
useSeoMeta({
|
||||
title: `Communities | ${config.appName}`,
|
||||
description: 'Discover and join hierarchical communities on Nostr',
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="pb-16 sidebar:pb-0">
|
||||
<PageHeader title="Communities" icon={<Users className="size-5" />} />
|
||||
|
||||
{/* Activities / My Communities tabs */}
|
||||
{user && (
|
||||
<SubHeaderBar>
|
||||
<TabButton
|
||||
label="Activities"
|
||||
active={activeTab === 'activities'}
|
||||
onClick={() => setActiveTab('activities')}
|
||||
/>
|
||||
<TabButton
|
||||
label="My Communities"
|
||||
active={activeTab === 'mine'}
|
||||
onClick={() => setActiveTab('mine')}
|
||||
/>
|
||||
</SubHeaderBar>
|
||||
)}
|
||||
|
||||
{/* Arc overhang spacer */}
|
||||
{user && <div style={{ height: 20 }} />}
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'mine' ? (
|
||||
<MyCommunitiesTab />
|
||||
) : (
|
||||
<ActivitiesTab
|
||||
onRefresh={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['community-activity-feed'],
|
||||
exact: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// My Communities Tab
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function MyCommunitiesTab() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<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">Your communities</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Log in to see communities you've founded or joined.
|
||||
</p>
|
||||
</div>
|
||||
<LoginArea className="max-w-60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MyCommunitiesContent />;
|
||||
}
|
||||
|
||||
function MyCommunitiesContent() {
|
||||
const { data: myCommunities, isLoading } = useMyCommunities();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 py-4 grid gap-3 grid-cols-1 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<CommunityCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!myCommunities || myCommunities.length === 0) {
|
||||
return (
|
||||
<FeedEmptyState message="You haven't founded or joined any communities yet." />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 grid gap-3 grid-cols-1 sm:grid-cols-2">
|
||||
{myCommunities.map((entry) => (
|
||||
<CommunityCard
|
||||
key={entry.community.aTag}
|
||||
event={entry.event}
|
||||
isFounded={entry.isFounded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Activities Tab
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: activityEvents, isLoading } = useCommunityActivityFeed();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<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">Community activity</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Log in to see activity from your communities.
|
||||
</p>
|
||||
</div>
|
||||
<LoginArea className="max-w-60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
{isLoading ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<NoteCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : activityEvents && activityEvents.length > 0 ? (
|
||||
<div>
|
||||
{activityEvents.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<FeedEmptyState message="No activity from your communities yet." />
|
||||
)}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { Link, useNavigate } from "react-router-dom";
|
||||
const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent })));
|
||||
import { BadgeDetailContent } from "@/components/BadgeDetailContent";
|
||||
import { CalendarEventDetailPage } from "@/components/CalendarEventDetailPage";
|
||||
import { CommunityDetailPage } from "@/components/CommunityDetailPage";
|
||||
|
||||
import {
|
||||
ColorMomentContent,
|
||||
@@ -125,6 +126,7 @@ function shellTitleForKind(kind?: number): string {
|
||||
if (MUSIC_KINDS.has(kind)) return "Track Details";
|
||||
if (PODCAST_KINDS.has(kind)) return "Episode Details";
|
||||
if (CALENDAR_EVENT_KINDS.has(kind)) return "Event Details";
|
||||
if (kind === 34550) return "Community";
|
||||
if (FOLLOW_PACK_KINDS.has(kind)) return "Follow Pack";
|
||||
if (kind === LIVE_STREAM_KIND) return "Live Stream";
|
||||
if (kind === 30617) return "Repository";
|
||||
@@ -377,6 +379,11 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Communities (NIP-72) get a dedicated detail page with members, events, and comments
|
||||
if (resolvedEvent.kind === 34550) {
|
||||
return <CommunityDetailPage event={resolvedEvent} />;
|
||||
}
|
||||
|
||||
// Calendar events (NIP-52) get a dedicated detail page with RSVP
|
||||
if (CALENDAR_EVENT_KINDS.has(resolvedEvent.kind)) {
|
||||
return <CalendarEventDetailPage event={resolvedEvent} />;
|
||||
|
||||
+172
-82
@@ -57,9 +57,9 @@ import { PageHeader } from '@/components/PageHeader';
|
||||
import { isRepostKind, parseRepostContent } from '@/lib/feedUtils';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
type TabType = 'posts' | 'accounts';
|
||||
type TabType = 'communities' | 'posts' | 'accounts';
|
||||
|
||||
const VALID_TABS: TabType[] = ['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';
|
||||
@@ -422,6 +422,7 @@ export function SearchPage() {
|
||||
<main className="flex-1 min-w-0">
|
||||
<PageHeader title="Search" icon={<SearchIcon className="size-5" />} />
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Communities" active={activeTab === 'communities'} onClick={() => setActiveTab('communities')} />
|
||||
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
|
||||
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} />
|
||||
</SubHeaderBar>
|
||||
@@ -435,8 +436,8 @@ export function SearchPage() {
|
||||
onDebouncedChange={setDebouncedSearchQuery}
|
||||
/>
|
||||
|
||||
{/* Add to feed button (posts tab only) */}
|
||||
{activeTab === 'posts' && user && (
|
||||
{/* Add to feed button (posts & communities tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'communities') && user && (
|
||||
<div className={cn(!debouncedSearchQuery.trim() && !hasActiveFilters ? 'hidden' : undefined)}>
|
||||
<Popover open={savePopoverOpen} onOpenChange={(o) => {
|
||||
setSavePopoverOpen(o);
|
||||
@@ -508,8 +509,8 @@ export function SearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter popover (posts tab only) */}
|
||||
{activeTab === 'posts' && (
|
||||
{/* Filter popover (posts & communities tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'communities') && (
|
||||
<Popover open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
@@ -629,89 +630,95 @@ export function SearchPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Media + Protocol */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Media</span>
|
||||
<Select value={mediaType} onValueChange={(v) => setMediaType(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="images">Images</SelectItem>
|
||||
<SelectItem value="videos">Videos</SelectItem>
|
||||
<SelectItem value="vines">Shorts</SelectItem>
|
||||
<SelectItem value="none">No media</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Protocol <HelpTip faqId="vs-mastodon-bluesky" iconSize="size-3" /></span>
|
||||
<Select value={platform} onValueChange={(v) => setPlatform(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nostr">Nostr</SelectItem>
|
||||
<SelectItem value="activitypub">Mastodon</SelectItem>
|
||||
<SelectItem value="atproto">Bluesky</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* Posts-only filters (hidden on communities tab) */}
|
||||
{activeTab === 'posts' && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Language + Kind */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Language</span>
|
||||
<Select value={language} onValueChange={(v) => setLanguage(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="es">Spanish</SelectItem>
|
||||
<SelectItem value="fr">French</SelectItem>
|
||||
<SelectItem value="de">German</SelectItem>
|
||||
<SelectItem value="ja">Japanese</SelectItem>
|
||||
<SelectItem value="zh">Chinese</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Kind</span>
|
||||
<KindPicker value={kindFilter} options={kindOptions} onChange={(v) => setKindFilter(v)} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Media + Protocol */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Media</span>
|
||||
<Select value={mediaType} onValueChange={(v) => setMediaType(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="images">Images</SelectItem>
|
||||
<SelectItem value="videos">Videos</SelectItem>
|
||||
<SelectItem value="vines">Shorts</SelectItem>
|
||||
<SelectItem value="none">No media</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Protocol <HelpTip faqId="vs-mastodon-bluesky" iconSize="size-3" /></span>
|
||||
<Select value={platform} onValueChange={(v) => setPlatform(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nostr">Nostr</SelectItem>
|
||||
<SelectItem value="activitypub">Mastodon</SelectItem>
|
||||
<SelectItem value="atproto">Bluesky</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kindFilter === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 1, 30023"
|
||||
value={customKindText}
|
||||
onChange={(e) => setCustomKindText(e.target.value)}
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8"
|
||||
/>
|
||||
{/* Language + Kind */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Language</span>
|
||||
<Select value={language} onValueChange={(v) => setLanguage(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="es">Spanish</SelectItem>
|
||||
<SelectItem value="fr">French</SelectItem>
|
||||
<SelectItem value="de">German</SelectItem>
|
||||
<SelectItem value="ja">Japanese</SelectItem>
|
||||
<SelectItem value="zh">Chinese</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Kind</span>
|
||||
<KindPicker value={kindFilter} options={kindOptions} onChange={(v) => setKindFilter(v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kindFilter === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 1, 30023"
|
||||
value={customKindText}
|
||||
onChange={(e) => setCustomKindText(e.target.value)}
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Include replies toggle */}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Include replies</span>
|
||||
<Switch checked={includeReplies} onCheckedChange={setIncludeReplies} className="scale-90" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Include replies toggle */}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Include replies</span>
|
||||
<Switch checked={includeReplies} onCheckedChange={setIncludeReplies} className="scale-90" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active filter summary chips (posts tab only) */}
|
||||
{activeTab === 'posts' && activeFilterLabels.length > 0 && (
|
||||
{/* Active filter summary chips (posts & communities tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'communities') && activeFilterLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{activeFilterLabels.map((label) => (
|
||||
<Badge key={label} variant="secondary" className="text-xs font-normal">
|
||||
@@ -727,8 +734,8 @@ export function SearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NIP-50 search query debug block (posts tab only) */}
|
||||
{activeTab === 'posts' && debouncedSearchQuery.trim() && (
|
||||
{/* NIP-50 search query debug block (posts & communities tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'communities') && debouncedSearchQuery.trim() && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -748,6 +755,22 @@ export function SearchPage() {
|
||||
</div>
|
||||
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{/* ─── Communities Tab ─── */}
|
||||
{activeTab === 'communities' && (
|
||||
<CommunitiesSearchTab
|
||||
searchQuery={debouncedSearchQuery}
|
||||
includeReplies={includeReplies}
|
||||
mediaType={mediaType}
|
||||
language={language}
|
||||
protocols={protocols}
|
||||
authorPubkeys={streamAuthorPubkeys}
|
||||
sort={sort}
|
||||
activeFilterLabels={activeFilterLabels}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
resetFilters={resetFilters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─── Posts Tab ─── */}
|
||||
{activeTab === 'posts' && (
|
||||
<>
|
||||
@@ -1093,6 +1116,73 @@ function SearchInput({
|
||||
);
|
||||
}
|
||||
|
||||
/** Communities tab — isolated component so useStreamPosts only subscribes when active. */
|
||||
function CommunitiesSearchTab({
|
||||
searchQuery,
|
||||
includeReplies,
|
||||
mediaType,
|
||||
language,
|
||||
protocols,
|
||||
authorPubkeys,
|
||||
sort,
|
||||
activeFilterLabels,
|
||||
hasActiveFilters,
|
||||
resetFilters,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
includeReplies: boolean;
|
||||
mediaType: 'all' | 'images' | 'videos' | 'vines' | 'none';
|
||||
language: string;
|
||||
protocols: string[];
|
||||
authorPubkeys: string[] | undefined;
|
||||
sort: 'recent' | 'hot' | 'trending';
|
||||
activeFilterLabels: string[];
|
||||
hasActiveFilters: boolean;
|
||||
resetFilters: () => void;
|
||||
}) {
|
||||
const { posts, isLoading: postsLoading } = useStreamPosts(searchQuery, {
|
||||
includeReplies,
|
||||
mediaType,
|
||||
language,
|
||||
protocols,
|
||||
kindsOverride: [34550],
|
||||
authorPubkeys,
|
||||
sort,
|
||||
});
|
||||
|
||||
if (postsLoading && posts.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<PostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (posts.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
{posts.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
return (
|
||||
<EmptyState
|
||||
message="No communities found matching your search."
|
||||
activeFilters={activeFilterLabels}
|
||||
onResetFilters={hasActiveFilters ? resetFilters : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <EmptyState message="Search for communities or browse the latest." />;
|
||||
}
|
||||
|
||||
function SaveDestinationRow({
|
||||
icon, label, description, onClick, disabled, loading,
|
||||
}: {
|
||||
|
||||
@@ -83,6 +83,8 @@ export function TestApp({ children }: TestAppProps) {
|
||||
feedIncludePodcastTrailers: false,
|
||||
showDevelopment: false,
|
||||
feedIncludeDevelopment: false,
|
||||
showCommunities: false,
|
||||
feedIncludeCommunities: false,
|
||||
showBadges: false,
|
||||
showBadgeDefinitions: true,
|
||||
showProfileBadges: true,
|
||||
|
||||
Reference in New Issue
Block a user