Skip to content

NIP-XX: window.nostr.content capability for web browsers #2239

@leonacostaok

Description

@leonacostaok

NIP-XX: window.nostr.content capability for web browsers

draft | optional | extension of NIP-07

Abstract

This NIP defines a standard interface for browser extensions to expose content-level mute lists to web applications via window.nostr.content. It extends the signer interface defined in NIP-07 and is designed to complement the Web of Trust interface (window.nostr.wot).

While WoT filtering operates on author identity, this NIP operates on content signals — hashtags, keywords, content warnings, languages, and event kinds — independent of who posted them.


Motivation

Nostr clients running in web browsers need to filter content based on user-defined rules beyond author trust. Today each client must implement its own mute logic, maintain its own lists, and apply its own matching — duplicating work across every client the user touches.

By exposing a common content mute API on window.nostr, any Nostr web client can apply the user's content preferences without reimplementing them. The extension becomes a single source of truth for what the user does not want to see.


Relation to Existing NIPs

  • NIP-07: This NIP extends window.nostr with a content sub-object. The two capabilities are independent and composable.
  • NIP-51: Mute lists (kind:10000) defined in NIP-51 include pubkeys and event IDs. This NIP focuses on the content-signal subset of mute lists (hashtags, keywords, content warnings, languages, kinds) and does not duplicate pubkey filtering, which belongs to WoT.
  • window.nostr.wot: Identity filtering (who) lives in WoT. Content filtering (what) lives here. Clients SHOULD apply both layers.

Detection

Extensions MUST inject window.nostr.content before or at document_start. Clients MUST use the following detection pattern:

function onContentReady(callback) {
  if (window.nostr?.content) {
    callback();
  } else {
    document.addEventListener('nostr-content-ready', callback, { once: true });
  }
}

Extensions MUST dispatch a nostr-content-ready event on document once the API object is available.


Data Model

A content mute list contains zero or more entries across these signal types:

Signal Type Description Example
hashtags Lowercase tag values from t tags "bitcoin", "ai"
keywords Substring or phrase matches against event content "giveaway", "OnlyFans"
contentWarnings Values of content-warning tags "nudity", "violence"
languages Values of l tags (BCP-47 codes) "zh", "es"
kinds Nostr event kind numbers 30023, 1063
events Specific event IDs to mute 64-char hex event ID
threads Root event IDs — mutes the event and all replies 64-char hex event ID

Matching for hashtags, contentWarnings, and languages MUST be case-insensitive. Matching for keywords SHOULD be case-insensitive. Implementations MAY support glob or regex patterns for keywords but MUST document this behavior.


API

All methods return Promises. Event objects passed to this API MUST conform to the standard Nostr event structure.


getMuteList(): Promise<MuteList>

Returns the user's full content mute list.

const list = await window.nostr.content.getMuteList();
// {
//   hashtags: ["ai", "nft"],
//   keywords: ["giveaway", "click here"],
//   contentWarnings: ["nudity"],
//   languages: ["zh"],
//   kinds: [30023],
//   events: ["a1b2c3..."],
//   threads: ["d4e5f6..."]
// }

Return type:

interface MuteList {
  hashtags: string[];
  keywords: string[];
  contentWarnings: string[];
  languages: string[];
  kinds: number[];
  events: string[];   // specific event IDs (hex)
  threads: string[];  // root event IDs (hex) — mutes entire thread
}

All fields MUST be present. Fields with no entries MUST return empty arrays, not null.


isMuted(event: NostrEvent): Promise<MuteResult>

Evaluates a single event against the user's mute list. Returns a result object indicating whether the event is muted and, if so, why.

const result = await window.nostr.content.isMuted(event);
// { muted: true, reason: "hashtag", matched: "ai" }
// { muted: false }

Return type:

interface MuteResult {
  muted: boolean;
  reason?: 'hashtag' | 'keyword' | 'contentWarning' | 'language' | 'kind' | 'event' | 'thread';
  matched?: string | number;
}
  • reason and matched MUST be present when muted is true.
  • matched contains the specific value that triggered the mute (the hashtag string, keyword string, kind number, event ID, or thread root ID).
  • If multiple signals match, implementations SHOULD return the first match in the order: eventthreadkindhashtagcontentWarninglanguagekeyword.

filterMuted(events: NostrEvent[]): Promise<NostrEvent[]>

Returns the subset of events that are not muted. Muted events are excluded from the result. The order of non-muted events MUST be preserved.

const visible = await window.nostr.content.filterMuted(feedEvents);

This is the primary method for feed-level filtering. Clients SHOULD prefer this over calling isMuted in a loop.


getStatus(): Promise<ContentStatus>

Returns the readiness state of the content module.

const status = await window.nostr.content.getStatus();
// { configured: true, muteCount: 12 }

Return type:

interface ContentStatus {
  configured: boolean;
  muteCount: number; // total number of mute entries across all signal types
}

Matching Rules

Implementations MUST apply the following matching logic when evaluating an event:

event: Muted if event.id is in events.

thread: Muted if the event is part of a muted thread. An event is part of a muted thread if:

  • Its id matches a thread root in threads, OR
  • It has an e tag with marker "root" whose value is in threads, OR
  • It has an e tag with marker "reply" whose value is in threads (for clients that do not set root markers), OR
  • It has an A or a tag referencing a muted thread root (for parameterized replaceable events)

Implementations SHOULD resolve thread membership transitively: if event B is a reply to muted root A, and event C is a reply to B, C MUST also be muted. Clients passing events to filterMuted SHOULD include enough tag context for the extension to determine thread membership. Implementations MAY request that clients pass a rootId hint if thread context cannot be determined from tags alone.

Open question: Should threads accept both event IDs (hex, for kind:1 threads) and NIP-19 naddr coordinates (for kind:30023 long-form articles and other parameterized replaceable events whose replies reference them via a tags)? Restricting to hex event IDs keeps the API uniform but excludes a common thread root type. Accepting coordinates adds complexity but covers the full range of Nostr thread patterns.

kind: Muted if event.kind is in kinds.

hashtag: Muted if any value in the event's t tags matches a hashtag in the mute list (case-insensitive).

contentWarning: Muted if any content-warning tag value matches an entry in contentWarnings (case-insensitive). Events with a content-warning tag present but with an empty value SHOULD be muted if "" is in the contentWarnings list.

language: Muted if any l tag value matches an entry in languages (case-insensitive).

keyword: Muted if the event's content field contains any keyword as a substring (case-insensitive). Implementations MAY also match against tag values.


Security Considerations

  • Read-only API. Web pages MUST NOT be able to modify the mute list through this API.
  • List privacy. The mute list reveals user preferences. Implementations MAY require the page to have prior user consent (e.g., via NIP-07 getPublicKey approval) before returning list contents via getMuteList. isMuted and filterMuted MAY be available without consent since they return less information than the raw list.
  • Event and thread ID privacy. Muted event IDs and thread root IDs may reveal which specific content a user found objectionable. Implementations SHOULD apply the same consent gate to events and threads in getMuteList as to other list fields. isMuted and filterMuted MUST still function correctly without exposing raw ID lists to the page.
  • No identity signals. This API MUST NOT expose pubkeys. Identity filtering belongs to WoT. If a client passes an event to isMuted, the extension MUST NOT use the event's pubkey field in its evaluation.
  • Performance. Implementations SHOULD cache compiled keyword matchers and MUST NOT perform synchronous I/O during evaluation.

Error Handling

All methods MUST reject with an Error if called with malformed input (e.g., invalid event structure). Implementations MUST NOT reject when an event simply does not match — return { muted: false } instead.


Rate Limiting

Implementations SHOULD enforce per-method rate limits to prevent abuse by malicious pages. Rate-limited requests MUST reject with a descriptive error (e.g., "Rate limited").


Example Usage

function onContentReady(callback) {
  if (window.nostr?.content) callback();
  else document.addEventListener('nostr-content-ready', callback, { once: true });
}

onContentReady(async () => {
  const { configured } = await window.nostr.content.getStatus();
  if (!configured) return;

  // Filter a feed in one call
  const visible = await window.nostr.content.filterMuted(feedEvents);

  // Check a single event with reason
  const result = await window.nostr.content.isMuted(event);
  if (result.muted) {
    console.log(`Muted: ${result.reason} → "${result.matched}"`);
  }

  // Combine with WoT for full filtering
  const authors = visible.map(e => e.pubkey);
  const trustedAuthors = new Set(await window.nostr.wot.filterByWoT(authors, 2));
  const feed = visible.filter(e => trustedAuthors.has(e.pubkey));
});

Reference Implementation

Authors

  • npub1gxdhmu9swqduwhr6zptjy4ya693zp3ql28nemy4hd97kuufyrqdqwe5zfk

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions