Merge branch 'main' into feat/dms

This commit is contained in:
sam
2026-04-22 12:19:16 +05:45
22 changed files with 1767 additions and 171 deletions
+311
View File
@@ -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
-25
View File
@@ -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": [
+3
View File
@@ -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",
],
+2
View File
@@ -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 />} />
+129
View File
@@ -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>
);
}
+4 -64
View File
@@ -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>
);
}
+414
View File
@@ -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>
);
}
+2
View File
@@ -399,6 +399,8 @@ function SetupQuestionnaire({
feedIncludePodcastTrailers: false,
showDevelopment: false,
feedIncludeDevelopment: false,
showCommunities: true,
feedIncludeCommunities: true,
showBadges: false,
showBadgeDefinitions: true,
showProfileBadges: true,
+11
View File
@@ -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" }),
+4
View File
@@ -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 */
+72
View File
@@ -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,
});
}
+49
View File
@@ -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,
});
}
+1
View File
@@ -20,6 +20,7 @@ const DEFAULT_SIDEBAR_ORDER: string[] = [
'badges',
'feed',
'notifications',
'communities',
'profile',
'settings',
];
+99
View File
@@ -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,
});
}
+236
View File
@@ -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}`;
}
+12
View File
@@ -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',
+2
View File
@@ -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). */
+3
View File
@@ -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,
+232
View File
@@ -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>
);
}
+7
View File
@@ -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
View File
@@ -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,
}: {
+2
View File
@@ -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,