# Stars (NIP-25) & Watch (NIP-51 kind 10018)

Code snippets for **Star** and **Watch** on Nostr git repos.

| UI action | Kind | Spec |
| --- | --- | --- |
| **Star** / unstar | `7` | NIP-25 reaction on the repo’s `30617` event |
| **Watch** / unwatch | `10018` | NIP-51 *Git repositories list* (full replaceable `a` list) |

**Synced:** 2026-08-24 from gittr `repo-stars.ts` + `events.ts` (kind 10018) + `layout-client.tsx` `handleWatch`.

**Star needs the kind 30617 event id from the same wide discovery as file fetch** (`GET /api/nostr/profile-repos` / `PROFILE_REPOS_RELAYS`), not only the user’s default relay list. Snippet `queryRepoStars` still takes an event id — production gittr resolves that id with `resolveLiveRepoAnnouncement` before calling star helpers. Watch uses `a` tag `30617:owner:d` and does not need the event id.

## Files

| File | Role |
| --- | --- |
| `repo-stars.ts` | NIP-25 star aggregate / query / publish (`KIND_REACTION=7`, `KIND_REPOSITORY_NIP34=30617` local constants) |
| `git-repositories-list.ts` | NIP-51 kind **10018** build + parse (`30617:pubkey:d` `a` tags) — this is **Watch** |

## `repo-stars.ts`

Functions for querying, publishing, and removing star reactions (NIP-25) for repositories.
SessionStorage / CustomEvent announcement-id cache helpers from production are **stripped** — add your own if needed.

**What it does:**

- Queries star reactions (Kind 7) for a repository event
- Publishes positive reactions ("+") to star a repository
- Publishes negative reactions ("-") to unstar a repository
- Aggregates star counts from multiple users

**Usage:**

```typescript
import {
  queryRepoStars,
  publishStarReaction,
  removeStarReaction,
} from "./repo-stars";

// Query star count for a repository
const { count, starers } = await queryRepoStars(subscribe, repoEventId);

// Star a repository
await publishStarReaction(repoEventId, repoOwnerPubkey, publish, getSigner);

// Unstar a repository
await removeStarReaction(repoEventId, repoOwnerPubkey, publish, getSigner);
```

**Extracted from:** `gittr/ui/src/lib/nostr/repo-stars.ts`

---

This document explains how to implement repository starring and following using **NIP-25** (Reactions) and **NIP-51** (Lists) in a Nostr-based Git hosting platform.

## Overview

Following the [Nostr community discussion](https://github.com/nostr-protocol/nips/pull/880), we use:

- **NIP-25 (Kind 7)** for repository stars (reactions to Kind 30617 repository events)
- **NIP-51 (Kind 10018)** for following/watching repositories (Git repositories list)

**Key Benefits:**

- Platform-wide visibility (everyone sees who starred what)
- No server storage needed (each user publishes their own events)
- Decentralized aggregation (clients query and count reactions)
- Standard NIPs (no custom event kinds needed)

## Dependencies

This implementation uses standard Nostr libraries:

- `nostr-tools` - For event creation, signing, and hashing
- No additional dependencies required for starring/following functionality

## Event Kinds Used

- **Kind 7** (NIP-25: Reactions) - Star reactions to repositories
- **Kind 10018** (NIP-51: Git Repositories List) - Following/watching repositories
- **Kind 30617** (NIP-34: Replaceable Events) - Repository announcements (what we're reacting to)

## NIP-25 Star Reactions

### Event Structure

When a user stars a repository, publish a **Kind 7** event:

```typescript
{
  kind: 7, // NIP-25: Reaction
  created_at: Math.floor(Date.now() / 1000),
  tags: [
    ["e", repoEventId],      // Reference to the repository event (Kind 30617)
    ["k", "30617"],          // Indicates this is a reaction to a Kind 30617 event
    ["p", repoOwnerPubkey],  // Repository owner's pubkey
  ],
  content: "+",  // Star reaction (NIP-25 standard: "+" for like/star)
  pubkey: userPubkey,
  // ... id, sig
}
```

### Publishing a Star

```typescript
import { KIND_REACTION } from "./repo-stars";

export async function publishStarReaction(
  repoEventId: string,
  repoOwnerPubkey: string,
  publish: (event: Event) => Promise<void>,
  getSigner: () => Promise<{ signEvent: (event: any) => Promise<any> }>,
): Promise<{ success: boolean; eventId?: string; error?: string }> {
  try {
    const signer = await getSigner();

    // Create unsigned event
    const unsignedEvent = {
      kind: KIND_REACTION, // 7
      created_at: Math.floor(Date.now() / 1000),
      tags: [
        ["e", repoEventId],
        ["k", "30617"],
        ["p", repoOwnerPubkey],
      ],
      content: "+", // Star reaction
      pubkey: "", // Will be set by signer
    };

    // Sign the event
    const signedEvent = await signer.signEvent(unsignedEvent);

    // Publish to relays
    await publish(signedEvent);

    return {
      success: true,
      eventId: signedEvent.id,
    };
  } catch (error: any) {
    return {
      success: false,
      error: error?.message || "Failed to publish star reaction",
    };
  }
}
```

### Removing a Star (Unstar)

Publish a negative reaction:

```typescript
export async function removeStarReaction(
  repoEventId: string,
  repoOwnerPubkey: string,
  publish: (event: Event) => Promise<void>,
  getSigner: () => Promise<{ signEvent: (event: any) => Promise<any> }>,
): Promise<{ success: boolean; error?: string }> {
  try {
    const signer = await getSigner();

    // Create negative reaction (NIP-25: "-" means remove reaction)
    const unsignedEvent = {
      kind: KIND_REACTION, // 7
      created_at: Math.floor(Date.now() / 1000),
      tags: [
        ["e", repoEventId],
        ["k", "30617"],
        ["p", repoOwnerPubkey],
      ],
      content: "-", // Negative reaction (unstar)
      pubkey: "", // Will be set by signer
    };

    const signedEvent = await signer.signEvent(unsignedEvent);
    await publish(signedEvent);

    return { success: true };
  } catch (error: any) {
    return {
      success: false,
      error: error?.message || "Failed to remove star reaction",
    };
  }
}
```

### Querying Star Counts

Aggregate star counts by querying all Kind 7 reactions for a repository:

```typescript
export async function queryRepoStars(
  subscribe: (filters: Filter[], onEvent: (event: Event) => void) => () => void,
  repoEventId: string,
): Promise<{ count: number; starers: string[] }> {
  return new Promise((resolve) => {
    const starers = new Set<string>();

    const filters: Filter[] = [
      {
        kinds: [KIND_REACTION], // 7
        "#e": [repoEventId], // Reactions to this repo event
        // Prefer #e only; many relays do not index #k on kind 7. Filter k=30617 client-side.
      },
    ];

    const unsubscribe = subscribe(filters, (event: Event) => {
      // Only count positive reactions (stars)
      if (event.content === "+" || event.content === "⭐") {
        starers.add(event.pubkey);
      }
      // Negative reactions ("-") are ignored (they cancel out)
    });

    // Wait for events to come in, then resolve
    setTimeout(() => {
      unsubscribe();
      resolve({
        count: starers.size,
        starers: Array.from(starers),
      });
    }, 2000); // 2 second timeout
  });
}
```

### UI Integration

```typescript
// ui/src/app/[entity]/[repo]/layout.tsx

const handleStar = useCallback(async () => {
  if (!pubkey) return;

  const repoEventId = repo?.nostrEventId || repo?.lastNostrEventId;
  const repoOwnerPubkey = ownerPubkey || repo?.ownerPubkey;

  if (isStarred) {
    // Unstar: publish negative reaction
    await removeStarReaction(repoEventId, repoOwnerPubkey, publish, getSigner);
    setIsStarred(false);
  } else {
    // Star: publish positive reaction
    await publishStarReaction(repoEventId, repoOwnerPubkey, publish, getSigner);
    setIsStarred(true);
  }

  // Query aggregated star count
  if (repoEventId) {
    const { count } = await queryRepoStars(subscribe, repoEventId);
    setStarCount(count);
  }
}, [isStarred, pubkey, repo, ownerPubkey, publish, subscribe]);

// Query star count when repo loads
useEffect(() => {
  if (!repo) return;
  const repoEventId = repo?.nostrEventId || repo?.lastNostrEventId;
  if (!repoEventId) return;

  queryRepoStars(subscribe, repoEventId).then(({ count }) => {
    setStarCount(count);
  });
}, [repo, subscribe]);
```

## Watch (NIP-51 kind 10018)

**Watch** = the user’s replaceable **git repositories list**. It is **not** kind 3 (people follow) and **not** Star (kind 7).

Canonical behavior matches gittr `createGitRepositoriesListEvent` / `parseGitRepositoriesListEvent` and repo header **Watch**.

### Event structure

Tags are **`a` only**, each value `30617:<64-hex-owner-pubkey>:<repositoryName>` (use the NIP-34 `d` / repositoryName). Each publish carries the **full** current watched set (standard replaceable list — merge locally, then republish once).

```typescript
{
  kind: 10018,
  created_at: Math.floor(Date.now() / 1000),
  tags: [
    ["a", "30617:<hex64>:<repoId>"],
    // ... all watched repos for this user
  ],
  content: "",
  pubkey: userPubkey,
}
```

Snippet helpers: `git-repositories-list.ts` (`buildGitRepositoriesListEvent` / parse).

### Client flow (Watch / Unwatch)

1. Load latest `10018` for the signed-in user (`kinds: [10018]`, `authors: [hex]`).
2. Parse `a` tags → set of `30617:owner:repo`.
3. On **Watch**: add address if missing. On **Unwatch**: remove it.
4. Publish a **new** `10018` with the full updated `a` list (signed).
5. Optional local cache (gittr uses `gittr_watched_repos`) for snappy UI; relays remain canonical.

### Querying

`kinds: [10018]`, `authors: [<watcher-hex>]`, then read **`a`** tags from the **latest** event (no `#d` filter in gittr).

## Implementation Notes

### Why NIP-25 for Stars?

- **Standard NIP**: No custom event kinds needed
- **Platform-wide**: Anyone can query and see who starred what
- **Decentralized**: Each user publishes their own reactions
- **Simple aggregation**: Clients count positive reactions

### Why NIP-51 kind 10018 for following / watch?

- Standard **git repositories list** in NIP-51
- **Replaceable**: newest `10018` is the list; body is the full `a` set (client merges old + change, then publishes once)

### Handling Negative Reactions

NIP-25 allows negative reactions (`content: "-"`). When aggregating:

- Count only positive reactions (`content: "+"` or `"⭐"`)
- Ignore negative reactions (they cancel out the positive)
- Or: Track both and calculate net count

### Relay Configuration

Ensure your relays allow:

- **Kind 7** (Reactions) - for stars
- **Kind 10018** (Git repositories list) - for following
- **Kind 30617** (Replaceable Events) - for repository announcements
- **Kind 30618** (NIP-34: Repository State) - for repository state (required for ngit clients)

Example relay config (nostr-rs-relay):

```toml
[relay]
allowed_kinds = [0, 1, 7, 50, 51, 52, 10018, 30617, 30618, 9735, 9803, 9804]
```

## References

- **NIP-25**: https://nips.nostr.com/25 (Reactions)
- **NIP-51**: https://nips.nostr.com/51 (Lists)
- **NIP-34**: https://nips.nostr.com/34 (Replaceable Events)
- **Discussion**: https://github.com/nostr-protocol/nips/pull/880

## Example Implementation

See upstream gittr/ngit:

- **Star reactions**: `ui/src/lib/nostr/repo-stars.ts`
- **Kind 10018**: `ui/src/lib/nostr/events.ts` (`createGitRepositoriesListEvent`, `parseGitRepositoriesListEvent`)
- **Watch**: `ui/src/app/[entity]/[repo]/layout-client.tsx` (`handleWatch`)
