Sharing and reading community content in edufeed
Von Edufeed ·
Sharing and reading community content in edufeed
STILL UNDER DEVELOPMENT
A community in edufeed is a Nostr keypair that has published a kind 10222 community definition. Content belongs to a community when the content event carries the community's pubkey in an h tag. Anyone can publish such an event to any relay; what a community page shows is decided by the reader, section by section, against the community's publishing rules. This article describes both halves so another client can share into edufeed communities and render them the same way.
The normative tag grammar lives in the NIP draft "Communikey Community Types & Group Membership" (communikey-groups); this article is the implementer's walkthrough of it.
The mechanism in one line
event with ["h", <community-pubkey>] → published to the community's relays → reader queries {"#h": [<community-pubkey>]} → reader keeps only events whose author (or sharer) passes the section's access rule at read time
Two consequences follow from it:
- Reading is always public. Open and moderated communities have no read gating. Closed communities publish nothing except an optional publisher window (below).
- Gating is write gating, enforced by the reader. Relays accept anything. A moderated community's rules say who counts as an author; a client drops everything else when rendering, and it re-evaluates against the current roster every time, so removing a member removes their content from the community view retroactively. The events themselves stay on the relays and keep appearing in global feeds and on the author's profile.
Three community types
The type is never declared. It is derived from which pointer tags the kind 10222 carries:
| Type | Derived from | Membership | Who may read |
|---|---|---|---|
| open | neither tag below | none (followers only) | everyone |
| moderated | ["membership", <group-id>, <wss://group-relay>] |
the NIP-29 root group's roster | everyone |
| closed | ["concord", <area-id>, <wss://relay>] |
end-to-end encrypted (Concord) | members; the public sees only the window |
membership and concord must not coexist. A definition carrying both is treated as open.
Everything else on the 10222 is shared by all types:
{
"kind": 10222,
"pubkey": "<community-pubkey>",
"tags": [
["r", "wss://relay.example"],
["r", "wss://enforced.example", "enforced"],
["blossom", "https://blossom.example"],
["strict", "content"],
["content", "Learning"],
["k", "30142"],
["access", "role", "publisher"],
["content", "Calendar"],
["k", "31922"],
["k", "31923"],
["k", "31924"],
["content", "Articles"],
["k", "30023"],
["access", "members"],
["membership", "<root-group-id>", "wss://groups.edufeed.org"]
],
"content": ""
}
| Tag | Meaning |
|---|---|
r |
Community relay. A third slot enforced marks a relay that promises to store only rule-conforming content; content in the third slot inside a section scopes the relay to that section. |
blossom |
Blossom server for the community's uploads |
strict |
["strict", "content"]: the section list is exhaustive. Without it the sections are advisory and clients fail open (show every content type). |
content |
Starts a content section; the value is its display name. Tags that follow belong to that section until the next content tag. |
k |
Event kind offered in the section |
access |
Publishing rule of the section (moderated only, see below) |
a |
Legacy gates: 30000:<pubkey>:<d> profile list, 30009:... badge. Read-only, never written by current clients, except the publisher window. |
Name, picture and description come from the community pubkey's kind 0.
Following a community: kind 30000
A user's communities are one NIP-51 follow set, kind 30000 with d = communities, one p tag per community pubkey, published to the user's own write relays:
{
"kind": 30000,
"pubkey": "<user-pubkey>",
"tags": [
["d", "communities"],
["p", "<community-pubkey>"],
["p", "<another-community-pubkey>"]
],
"content": ""
}
Joining appends a p tag, leaving removes it, and each change replaces the whole event with a strictly newer created_at. Query a user's communities with {"kinds": [30000], "authors": [<user>], "#d": ["communities"]}.
Following is open to anyone for every community type and is not membership. Membership exists only for moderated communities (the NIP-29 roster) and closed communities (the encrypted roster). The older kind 30382 relationship event is retired; edufeed neither writes nor reads it.
Sharing content into a community
1. Original content: add the h tag
Any event created for a community carries the community pubkey as an h tag, alongside its normal tags:
["h", "<community-pubkey>"]
An event may carry several h tags to target several communities at once. Chat messages (kind 9) and forum threads (kind 11) are the exception: they are single-community and carry exactly one.
2. Existing content: a NIP-18 repost with h tags
To share an event that already exists, publish a repost, kind 6 for a kind 1 note and kind 16 for everything else. The repost references the original with an e tag (plus p for the author) or, for addressable kinds, an a tag, and adds one h tag per target community. One repost may target several communities:
{
"kind": 16,
"pubkey": "<sharer-pubkey>",
"tags": [
["a", "30142:<author-pubkey>:<d-tag>", "wss://relay-hint.example"],
["k", "30142"],
["p", "<author-pubkey>"],
["h", "<community-pubkey>"],
["h", "<another-community-pubkey>"]
],
"content": ""
}
Leave content empty when an a tag is present. Do not share an event its author has deleted (NIP-09): the repost publishes fine, but nobody can resolve it.
3. Where to publish
A community only sees what reaches the relays it reads from. edufeed publishes community content to the union of:
- the author's NIP-65 write relays,
- the app's relays for the event kind (for example the AMB relays for kind 30142),
-
the community's
rrelays: section-scoped relays for the kind if declared, else the global ones, and always the ones markedenforced.
Reposts additionally go to the app's communikey relays, because community pages look for shares there.
4. Legacy: kind 30222 targeted publications
Older clients targeted content with a separate kind 30222 event (p = community, e/a = content, k = kind). Readers still honour these; nothing writes them any more.
Reading community content
Step 1: load the definition
Fetch the community's kind 10222 and kind 0. Parse the content sections in tag order. If the definition carries ["strict", "content"], offer only the declared sections; otherwise show every content type you support.
Step 2 (moderated only): apply a section override
Root-group admins usually do not hold the community key, so they may reshape the section block with a kind 30223 signed by their own key:
{
"kind": 30223,
"pubkey": "<admin-pubkey>",
"tags": [
["d", "<community-pubkey>"],
["h", "<community-pubkey>"],
["content", "Learning"],
["k", "30142"],
["access", "role", "publisher"]
],
"content": ""
}
Query {"kinds": [30223], "#d": [<community-pubkey>]} on the communikey app relays and the community's relays (never on the auth-gated group relay: logged-out readers need this to render the tabs). An override is valid only if its d tag equals the community pubkey and its author currently holds a moderation role (admin, king or moderator) in the root group's kind 39001. Among valid overrides the newest created_at wins, ties broken by the lower id, and the winner applies only while it is newer than the kind 10222 itself. It replaces the section block as a whole. Only content, k and access tags are honoured; relays, profile lists, badges and form references on an override are ignored.
Open and closed communities have no roster to check against, so they ignore kind 30223 entirely.
Step 3: query three sources per section
For a section offering kinds K, on the community's relays plus the app relays for those kinds:
{"kinds": K, "#h": ["<community-pubkey>"]}
Reposts live on the sharer's outbox, so query them on a broad relay set (all app relays plus the community relays):
{"kinds": [6, 16], "#h": ["<community-pubkey>"]}
Legacy targeted publications, on the communikey relays:
{"kinds": [30222], "#p": ["<community-pubkey>"], "#k": ["<kind>", "..."]}
Resolve the e/a references of reposts and 30222 events, using their relay hints and the same broad relay set. Do not add an authors restriction to these queries: the h tag is the scoping, and the access rule is applied after loading.
Step 4: resolve the section's allowed authors
Compute, per section, either "everyone" or the set of pubkeys allowed to author it right now (see the table below). Until the roster has arrived, edufeed renders the section unfiltered rather than empty; the composer, by contrast, is conservative while loading and lets only the community key publish.
Step 5: filter
Render an item only if its author is allowed, or, for a repost, if the sharer is allowed. An event carrying several h tags is judged per community and may render in one and be dropped in another.
Access rules per section
| Section tag | Who may author | Source of truth |
|---|---|---|
no access tag |
everyone | — |
["access", "members"] |
the root group's members | kind 39002 of the root group, unioned with every pubkey in 39001 |
["access", "role", "<role>"] |
members holding <role> |
kind 39001 entries whose role list contains <role> |
["a", "30000:<pubkey>:<d>", <relay>] |
the p tags of that follow set |
the referenced kind 30000 event (legacy profile list; also the publisher window) |
["a", "30009:<pubkey>:<badge>"] |
badge holders | legacy, read-only |
Rules that hold for all of them:
- The community key itself is always allowed.
-
The first
accesstag in a section wins; a malformed one (unknown value, empty role) leaves the section open. -
accesstags only mean something on a moderated community. On a definition without amembershippointer there is no roster, so treat the section as open. - Rosters are read at render time, never cached as "was a member when the event was signed".
The root group
The membership tag points at a NIP-29 group. Its relay signs three addressable events with d = group id:
| Kind | Content |
|---|---|
| 39000 | group metadata (name, public/private, open/closed) |
| 39001 | admins: ["p", <pubkey>, <role>, <role>, ...] |
| 39002 | members: ["p", <pubkey>] |
NIP-29 has only one place to record a role, kind 39001, so a role that grants nothing beyond publishing lands in the "admins" list next to the real moderators. Read the role names: admin, king and moderator carry moderation authority; publisher is the conventional publish-only role and must never be treated as moderation. A pubkey listed in 39001 is a member even when 39002 omits it.
Joining is a kind 9021 join request to the root group's relay, optionally with an invite code tag; admins accept with a kind 9000 put-user (which replaces the target's whole role set), and members leave with kind 9022. All of these go to the group's host relay only. The application-form join flow described in early drafts was removed; today joining is by invite code or join request.
Removing content from a community
Sometimes an item that passes every access rule still does not belong: a share that landed in the wrong community, an off-topic resource, something a moderator wants gone from this page. Nothing in Nostr lets a moderator delete someone else's event. NIP-09 deletions are author-only (a client must check that the deletion's pubkey matches the target's), and NIP-29's delete-event (kind 9005) only covers events the group relay itself stores under the group's h tag, which community content never is. So edufeed does what Nostr offers for exactly this case, NIP-32 "distributed moderation": whoever runs the community publishes a signed label saying "this item is removed here", and readers honour the label when its author has authority.
The removal label
{
"kind": 1985,
"pubkey": "<moderator-or-community-pubkey>",
"tags": [
["L", "communikey.moderation"],
["l", "remove", "communikey.moderation"],
["h", "<community-pubkey>"],
["k", "30023"],
["e", "<event-id>", "wss://relay.example/"],
["a", "30023:<author-pubkey>:<d-tag>", "wss://relay.example/"]
],
"content": ""
}
-
hscopes the removal to one community. The same event stays visible in every other community that shows it, on the author's profile and in discovery; the event itself is never touched. -
enames the removed event;ais added for addressable kinds so an edit (new id, samed) stays removed. edufeed writes one label per item so that undoing it is a single deletion. -
A removal targets the content, not the share that brought it in. The item leaves the community however it arrived (direct
htag, any repost, legacy 30222) and stays out if someone shares it again.
Who may remove
A label counts only if, at read time, its author is the community key itself, or, in a moderated community, currently holds a moderation role (admin, king or moderator) in the root group's kind 39001. This is the same rule as for the kind 30223 section override, and for the same reasons: a publisher-only or custom role must not be able to hide content, and judging against the current roster makes removals retroactive. Demote a moderator and everything they removed comes back, with nothing to delete. Open communities have a single authority, the key holder; closed communities too for now.
In edufeed, owners and moderators find "Aus der Community entfernen" in the menu of an item's detail view inside the community (articles, resources, wikis, threads, calendar events). When the acting user holds a moderation role, edufeed signs with their own key, so the label records who acted; the community key is used only when no personal authority exists.
Reading removals
Query the labels alongside the content:
{"kinds": [1985], "#h": ["<community-pubkey>"], "#L": ["communikey.moderation"]}
on the community's relays plus the usual lookup relays (a label also lives on its signer's outbox). Keep the valid ones and drop every item whose id or address a valid label names. Removal labels are ordinary public events, published to the community's relays and the signer's write relays, never only to the auth-gated group relay: the logged-out readers of a community page have to see them too.
Undoing a removal
Restoring an item is the label author's own NIP-09 deletion of the label, which every relay and client already handles. It follows that only whoever removed an item can restore it: a moderator their own removals, the key holder the community-signed ones. edufeed lists all current removals under "Entfernte Inhalte" in the community settings, with who removed what and when, and offers "Wiederherstellen" where the browser holds the matching key.
Closed communities and the publisher window
A closed community's 10222 is a discoverable shell: kind 0 plus the concord pointer, no public content. Everything inside is end-to-end encrypted, and so is the roster, which makes ordinary members/role gating impossible: a reader cannot check an author against a list nobody may see.
The publisher window solves this with a consented public subset of the membership:
-
Private grant. Inside the Concord area the owner creates a role named
Publisherand grants it to members. Nothing leaves the area. -
Private consent. A member who agrees to be listed publicly publishes a kind 3320 rumor into the area's guestbook plane with
["t", "publisher-window"]and["status", "accepted"](or"revoked"). The newest rumor per member wins; on a same-second tie the loweridwins. -
Public list. When both the grant and an accepted consent exist, the community key publishes a follow set listing exactly those pubkeys:
{ "kind": 30000, "pubkey": "<community-pubkey>", "tags": [ ["d", "publishers"], ["p", "<consenting-publisher>"] ], "content": "" }Revoking either the role or the consent removes the pubkey on the next update. Only the community key can sign this event, so updates land when a key-holding client is online.
-
Window sections. The owner chooses which content types the window exposes. Each becomes an ordinary section whose only gate is the list:
["content", "Learning"], ["k", "30142"], ["a", "30000:<community-pubkey>:publishers", "wss://relay.example"]Chat and meet rooms are never window content; the window shares content outward, it does not host conversation.
For a reader nothing is special: a window section is a profile-list-gated section, so steps 3 to 5 above apply unchanged. The public learns only who consented to be listed, never the membership size or anyone else. A community whose public sections are all window sections still counts as closed; it becomes open only if it declares at least one section without the publishers-list gate.
Conversation lanes are separate
Chat (kind 9) and forum (kind 11) are also h-tagged, but they follow different transport rules. In an open community chat messages go to the community's relays like any content. In a moderated community the channels are NIP-29 groups declared with ["group", <id>, <relay>, <name>, <access>] tags on the 10222, and every event for a channel goes to that group's host relay only. This article does not cover the channel lane.
Resolution rules in short
-
Community identity: the pubkey of the kind 10222. Type:
concordtag → closed,membershiptag → moderated, neither → open. -
Content membership:
["h", <community-pubkey>]on the event, or on a kind 6/16 repost of it. -
Read filters:
#hfor content and reposts,#pplus#kfor legacy kind 30222. - Display rule: author allowed, or sharer allowed, per section, per community, against the roster as it is now.
- Roster: root group 39002 ∪ 39001; roles from 39001. Owner always passes.
-
Section override: kind 30223,
d= community pubkey, author holds a moderation role now, newest wins, must be newer than the 10222. -
Publisher window:
30000:<community-pubkey>:publishers, signed by the community key, listing granted and consenting members only. -
Removal: kind 1985 label,
L/l=communikey.moderation/remove,h= community pubkey,e(+a) = the item; counts when its author is the community key or holds a moderation role now; undone by the author's NIP-09 deletion of the label.
Known limits
-
Readers do not yet trust
enforcedrelays instead of filtering; client-side filtering is the only enforcement that ships. - Legacy gates (profile lists other than the publisher window, badges, kind 30222) are read for backward compatibility and will be dropped once old communities have migrated.