Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Readstr

A Google Reader-style aggregator for RSS feeds and Nostr long-form content.

Readstr is a full-stack web application built with Next.js that provides a unified reading experience for RSS/Atom feeds, Nostr long-form content (NIP-23), and video feeds from YouTube and Rumble. It features a clean, three-panel interface reminiscent of Google Reader, with keyless Nostr authentication (NIP-07) and cross-device sync of subscriptions and read status over Nostr relays — the app never handles a private key.

Where to start

Mobile & PWA Support

Mobile Responsive Design

The app is fully responsive and optimized for mobile devices:

Features

  • Hamburger Menu: On mobile, tap the menu icon to access feeds and tags
  • Adaptive Layout: 3-panel layout adapts to single-column on mobile
  • Touch-Friendly: All buttons and controls are optimized for touch input
  • Back Navigation: Swipe or tap back button to navigate between article list and content
  • Mobile Header: Fixed header with quick access to add feeds

Breakpoints

  • Mobile: < 768px (single column, hamburger menu)
  • Tablet/Desktop: >= 768px (multi-column layout)

Progressive Web App (PWA)

Install Readstr as a standalone app on your device!

Installation

On Mobile (iOS/Android)

  1. Open the app in your mobile browser
  2. Tap the “Share” or “Menu” button
  3. Select “Add to Home Screen”
  4. The app will install with an icon on your home screen

On Desktop (Chrome/Edge)

  1. Look for the install icon in the address bar
  2. Click “Install Readstr”
  3. The app will open in its own window

PWA Features

  • Offline Support: Service worker caches static assets for offline access
  • App-like Experience: Runs in standalone mode without browser UI
  • Fast Loading: Cached resources load instantly
  • Install Prompt: Browser prompts users to install the app
  • Manifest: Full PWA manifest with app name, icons, and theme colors

Technical Details

Service Worker

  • Automatically registered via @ducanh2912/next-pwa
  • Caches static assets (JS, CSS, images, fonts)
  • Network-first strategy for API calls
  • Cache-first for static resources

Build

To build with PWA support:

npm run build -- --webpack

Note: Next.js 16 uses Turbopack by default, but @ducanh2912/next-pwa requires webpack. The --webpack flag ensures proper PWA generation.

Icons

  • SVG icon at /public/icon.svg
  • PNG icons: 192x192 and 512x512
  • Apple touch icon support
  • Maskable icons for Android

Development

When running in development mode (npm run dev), the service worker is disabled to prevent caching issues.

Browser Support

  • iOS Safari: 11.3+
  • Android Chrome: Full support
  • Desktop Chrome/Edge: Full support
  • Firefox: Partial support (no install prompt)

RSS Feed Discovery Feature

Overview

Implemented intelligent RSS/Atom feed discovery that automatically finds feeds when users enter URLs that aren’t direct feed URLs.

How It Works

3-Step Discovery Process

  1. Direct Feed Check (checkIfFeed)

    • Attempts to fetch the URL directly
    • Validates Content-Type header (application/rss+xml, application/atom+xml, etc.)
    • Returns the URL if it’s already a valid feed
  2. HTML Parsing (findFeedInHTML)

    • Fetches the page as HTML
    • Uses cheerio to parse <link> tags with rel="alternate"
    • Looks for type="application/rss+xml" or type="application/atom+xml"
    • Returns the first valid feed URL found
  3. Common Locations (tryCommonFeedLocations)

    • Tests standard feed paths relative to the domain:
      • /feed
      • /feed.xml
      • /rss
      • /rss.xml
      • /atom.xml
      • /index.xml
    • Returns the first valid feed found

User Experience

What Users Can Enter

  • Direct feed URLs: https://example.com/feed.xml
  • Homepage URLs: https://example.com ✅ (will find /feed automatically)
  • Blog URLs: https://example.com/blog ✅ (will check HTML for feed links)

Error Messages

Provides descriptive errors when feeds can’t be found:

  • “Could not find RSS/Atom feed at this URL. Please check the URL and try again.”
  • Displays in red error box in the Add Feed modal
  • Clears when user modifies the URL input

Implementation Files

/src/lib/feed-discovery.ts

  • Core discovery logic
  • Three helper functions + main discoverFeed() orchestrator
  • Uses cheerio for HTML parsing
  • Implements 10-second timeout for network requests

/src/server/api/routers/feed.ts

  • Integrated into subscribeFeed mutation
  • Calls discoverFeed() for RSS feeds before saving
  • Preserves discovered feed title if available
  • Throws descriptive TRPCError on failure

/src/components/add-feed-modal.tsx

  • Displays error messages from backend
  • Clears errors when user types
  • Shows helpful hint: “Enter a feed URL or website homepage - we’ll find the feed for you”

/src/components/feed-reader.tsx

  • Manages error state from tRPC mutation
  • Passes errors to modal via props
  • Clears errors on modal close

Technical Details

Dependencies

  • cheerio: HTML parsing to find feed links
  • node-fetch: Already available in Next.js server environment

Timeout Handling

All HTTP requests have a 10-second timeout using AbortController:

const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000)

Feed Type Detection

Checks Content-Type headers for:

  • application/rss+xml
  • application/atom+xml
  • application/xml
  • text/xml
  • application/feed+json

Testing Scenarios

  1. Direct Feed URL: Should work immediately
  2. Homepage with feed: Should find feed in HTML
  3. Blog with /feed path: Should discover via common locations
  4. Invalid URL: Should show error message
  5. Timeout: Should fail gracefully after 10 seconds

Future Enhancements

Potential improvements:

  • Cache discovered feed URLs to avoid re-discovery
  • Support for more feed formats (JSON Feed, etc.)
  • Show discovery progress indicator
  • Allow users to choose from multiple feeds if found

Tagging System Implementation

Overview

The tagging system allows users to organize their RSS and Nostr feeds using custom tags/categories. Tags enable powerful filtering and organization of feeds.

Features

1. Tag Management

  • Add tags when subscribing: Users can add multiple tags when subscribing to a new feed
  • Tag input with autocomplete: Simple input field with “Add” button or press Enter
  • Visual tag display: Tags shown as pills/badges on feeds
  • Remove tags: Click × on tag to remove it

2. Sidebar Views

The left sidebar has two views that users can toggle between:

Feeds View

  • Shows all subscribed feeds (filtered by selected tags if any)
  • Displays tags for each feed
  • Shows unread count per feed
  • Includes “All Items” aggregated view

Tags View

  • Shows all user’s tags
  • Displays feed count per tag (how many feeds have this tag)
  • Shows unread count per tag (total unread items across all feeds with this tag)
  • Click a tag to filter feeds by that tag

3. Tag Filtering

  • Multi-tag filtering: Select multiple tags to narrow down feeds
  • Active filter indicator: Blue banner shows currently selected tags
  • Clear filters: One-click to remove all tag filters
  • Drill-down: Switch to Feeds view to see filtered results

Database Schema

Subscription Model

model Subscription {
  id          String   @id @default(cuid())
  userPubkey  String
  feedId      String
  createdAt   DateTime @default(now())
  tags        String[] @default([])  // Array of tag strings
  
  feed        Feed     @relation(fields: [feedId], references: [id], onDelete: Cascade)

  @@unique([userPubkey, feedId])
  @@index([userPubkey, tags])
}

API Endpoints

subscribeFeed

Input:

{
  type: 'RSS' | 'NOSTR',
  url?: string,
  npub?: string,
  title?: string,
  tags?: string[]  // Optional array of tags
}

updateSubscriptionTags

Input:

{
  feedId: string,
  tags: string[]  // Replace existing tags with new array
}

getUserTags

Output:

[{
  tag: string,
  unreadCount: number,  // Total unread items across all feeds with this tag
  feedCount: number     // Number of feeds with this tag
}]

getFeeds

Input:

{
  tags?: string[]  // Optional: filter feeds that have ALL specified tags
}

Output:

[{
  id: string,
  title: string,
  type: 'RSS' | 'NOSTR',
  unreadCount: number,
  tags: string[]  // Tags assigned to this feed
  // ... other fields
}]

UI Components

AddFeedModal

  • Tag input field at the bottom of the modal
  • Live tag preview (pills that can be removed before submitting)
  • Tags persist when toggling between RSS/Nostr tabs
  • Tags cleared on modal close

FeedReader Sidebar

  • View Toggle: Feeds ↔️ Tags tabs at top
  • Active Filters Banner: Shows when tags are selected (can clear all or remove individually)
  • Feeds List: Shows tag pills on each feed
  • Tags List: Shows each tag with unread count badge and feed count

Usage Examples

Add a feed with tags

  1. Click “Add Feed”
  2. Enter feed URL or search for Nostr profile
  3. Type tag name in “Tags” field and click “Add” or press Enter
  4. Add multiple tags as needed
  5. Click “Add Feed”

Filter feeds by tags

  1. Click “Tags” tab in sidebar
  2. Click on one or more tags to filter
  3. Switch to “Feeds” tab to see filtered results
  4. Click “Clear” in blue banner to remove filters

View unread counts per tag

  1. Click “Tags” tab in sidebar
  2. See unread count badge next to each tag
  3. Also see how many feeds have each tag

Implementation Notes

Tag Filtering Logic

  • Uses PostgreSQL’s hasEvery operator for array field filtering
  • Filters are AND-based: selecting multiple tags shows only feeds with ALL selected tags
  • Unread counts are calculated by aggregating across all feeds matching the tag(s)

Performance Considerations

  • Added composite index on [userPubkey, tags] for efficient tag queries
  • Tag aggregation happens in-memory for better performance
  • Consider caching tag counts for large datasets

Future Enhancements

  • Tag editing for existing subscriptions
  • Tag rename/merge functionality
  • Tag color customization
  • Export/import tags with feed subscriptions
  • Tag-based RSS export (OPML with categories)
  • Smart tag suggestions based on feed content
  • Tag hierarchies (parent/child tags)

Readstr Subscription Sync

Overview

Readstr uses Nostr events to sync RSS and Nostr long-form content subscriptions across devices. This enables users to maintain a single subscription list that works on mobile, desktop, and web - all tied to their Nostr identity.

New in v2: The sync system now tracks deleted subscriptions and read status across devices for a seamless experience.

How It Works

Event Types

Kind 30404 - Subscription List Sync

Subscription lists are stored as replaceable events using kind 30404. This is in the 30000-39999 range, which means:

  • Events are replaceable (newer versions overwrite older ones)
  • The d tag identifies the specific list
  • Only the most recent event per pubkey + d tag combination is kept

Kind 30405 - Read Status Sync (New)

Read status for feed items is synced using kind 30405:

  • Tracks which items have been marked as read
  • Syncs across all devices
  • Uses item GUIDs for identification

Event Structure - Subscription List

{
  "kind": 30404,
  "pubkey": "<user's hex pubkey>",
  "created_at": 1732645747,
  "tags": [
    ["d", "readstr-subscriptions"],
    ["client", "readstr"]
  ],
  "content": "{\"rss\":[...],\"nostr\":[...],\"deleted\":[...],\"tags\":{...},\"lastUpdated\":1732645747}",
  "id": "<event id>",
  "sig": "<signature>"
}

Event Structure - Read Status

{
  "kind": 30405,
  "pubkey": "<user's hex pubkey>",
  "created_at": 1732645747,
  "tags": [
    ["d", "readstr-read-status"],
    ["client", "readstr"]
  ],
  "content": "{\"itemGuids\":[...],\"lastUpdated\":1732645747}",
  "id": "<event id>",
  "sig": "<signature>"
}

Content Schema

The content field for kind 30404 (subscriptions) contains:

interface SubscriptionList {
  // RSS feed URLs
  rss: string[]
  
  // Nostr npubs for long-form content authors
  nostr: string[]
  
  // NEW: Explicitly deleted feeds (URLs or npubs)
  deleted?: string[]
  
  // Optional: tags/categories per feed
  // Key is the feed URL or npub
  tags?: Record<string, string[]>
  
  // Optional: category info per feed (NEW)
  // Key is the feed URL or npub
  categories?: Record<string, { name: string; color?: string; icon?: string }>
  
  // Unix timestamp of last update
  lastUpdated?: number
}

The content field for kind 30405 (read status) contains:

interface ReadStatusList {
  // GUIDs of feed items that have been read
  itemGuids: string[]
  
  // Unix timestamp of last update
  lastUpdated?: number
}

Example Content

{
  "rss": [
    "https://example.com/feed.xml",
    "https://www.youtube.com/feeds/videos.xml?channel_id=UCxyz..."
  ],
  "nostr": [
    "npub1cj8znuztfqkvq89pl8hceph0svvvqk0qay6nydgk9uyq7fhpfsgsqwrz4u",
    "npub1v5ufyh4lkeslgxxcclg8f0hzazhaw7rsrhvfquxzm2fk64c72hps45n0v5"
  ],
  "tags": {
    "https://example.com/feed.xml": ["tech", "news"],
    "npub1cj8znuztfqkvq89pl8hceph0svvvqk0qay6nydgk9uyq7fhpfsgsqwrz4u": ["bitcoin", "nostr"]
  },
  "categories": {
    "https://example.com/feed.xml": {
      "name": "Technology",
      "color": "#3b82f6",
      "icon": "💻"
    },
    "npub1v5ufyh4lkeslgxxcclg8f0hzazhaw7rsrhvfquxzm2fk64c72hps45n0v5": {
      "name": "Bitcoin",
      "color": "#f59e0b",
      "icon": "₿"
    }
  },
  "lastUpdated": 1732645747
}

Implementation Guide

Prerequisites

  • nostr-tools library
  • NIP-07 browser extension support (Alby, nos2x, etc.) or custom signing

1. Publishing Subscriptions

import { SimplePool, nip19 } from 'nostr-tools'
import type { UnsignedEvent, Event } from 'nostr-tools'

const SUBSCRIPTION_LIST_KIND = 30404

interface SubscriptionList {
  rss: string[]
  nostr: string[]
  tags?: Record<string, string[]>
  categories?: Record<string, { name: string; color?: string; icon?: string }>
  lastUpdated?: number
}

async function publishSubscriptionList(
  subscriptionList: SubscriptionList,
  relays: string[]
): Promise<string> {
  const pool = new SimplePool()
  
  // Get pubkey from NIP-07 extension
  const pubkey = await window.nostr.getPublicKey()
  
  // Create unsigned event
  const unsignedEvent: UnsignedEvent = {
    kind: SUBSCRIPTION_LIST_KIND,
    pubkey,
    created_at: Math.floor(Date.now() / 1000),
    tags: [
      ['d', 'readstr-subscriptions'],
      ['client', 'your-app-name'],  // Identify your app
    ],
    content: JSON.stringify({
      ...subscriptionList,
      lastUpdated: Math.floor(Date.now() / 1000),
    }),
  }
  
  // Sign with NIP-07 extension
  const signedEvent = await window.nostr.signEvent(unsignedEvent)
  
  // Publish to relays
  const publishPromises = pool.publish(relays, signedEvent)
  await Promise.race(publishPromises)
  
  pool.close(relays)
  
  return signedEvent.id
}

2. Fetching Subscriptions

async function fetchSubscriptionList(
  userPubkey: string,
  relays: string[]
): Promise<SubscriptionList | null> {
  const pool = new SimplePool()
  
  // Convert npub to hex if needed
  let pubkeyHex = userPubkey
  if (userPubkey.startsWith('npub')) {
    const decoded = nip19.decode(userPubkey)
    if (decoded.type === 'npub') {
      pubkeyHex = decoded.data
    }
  }
  
  // Query for the subscription list
  const event = await pool.get(relays, {
    kinds: [SUBSCRIPTION_LIST_KIND],
    authors: [pubkeyHex],
    '#d': ['readstr-subscriptions'],
  })
  
  pool.close(relays)
  
  if (!event) {
    return null
  }
  
  return JSON.parse(event.content) as SubscriptionList
}

3. Merging Local and Remote

When syncing, you’ll want to merge local subscriptions with remote ones:

interface Feed {
  type: 'RSS' | 'NOSTR'
  url: string
  tags?: string[]
  category?: { name: string; color?: string; icon?: string }
}

function mergeSubscriptions(
  localFeeds: Feed[],
  remoteList: SubscriptionList
): {
  toAdd: Feed[]      // Remote feeds not in local
  localOnly: Feed[]  // Local feeds not in remote
} {
  const localRssUrls = new Set(
    localFeeds
      .filter(f => f.type === 'RSS')
      .map(f => f.url.toLowerCase())
  )
  
  const localNpubs = new Set(
    localFeeds
      .filter(f => f.type === 'NOSTR')
      .map(f => {
        const match = f.url.match(/npub\w+/)
        return match ? match[0].toLowerCase() : f.url.toLowerCase()
      })
  )
  
  const toAdd: Feed[] = []
  
  // Find remote RSS feeds not in local
  for (const rssUrl of remoteList.rss) {
    if (!localRssUrls.has(rssUrl.toLowerCase())) {
      toAdd.push({
        type: 'RSS',
        url: rssUrl,
        tags: remoteList.tags?.[rssUrl],
        category: remoteList.categories?.[rssUrl],
      })
    }
  }
  
  // Find remote Nostr feeds not in local
  for (const npub of remoteList.nostr) {
    if (!localNpubs.has(npub.toLowerCase())) {
      toAdd.push({
        type: 'NOSTR',
        url: npub,
        tags: remoteList.tags?.[npub],
        category: remoteList.categories?.[npub],
      })
    }
  }
  
  // Find local-only feeds
  const remoteRssLower = new Set(remoteList.rss.map(u => u.toLowerCase()))
  const remoteNostrLower = new Set(remoteList.nostr.map(n => n.toLowerCase()))
  
  const localOnly = localFeeds.filter(f => {
    if (f.type === 'RSS') {
      return !remoteRssLower.has(f.url.toLowerCase())
    } else {
      const match = f.url.match(/npub\w+/)
      const npub = match ? match[0].toLowerCase() : f.url.toLowerCase()
      return !remoteNostrLower.has(npub)
    }
  })
  
  return { toAdd, localOnly }
}

4. Complete Sync Flow

async function syncSubscriptions(
  localFeeds: Feed[],
  relays: string[]
): Promise<void> {
  // 1. Get user's pubkey
  const pubkey = await window.nostr.getPublicKey()
  const npub = nip19.npubEncode(pubkey)
  
  // 2. Fetch remote subscriptions
  const remoteList = await fetchSubscriptionList(npub, relays)
  
  if (!remoteList) {
    // No remote list exists, publish local
    await publishSubscriptionList(
      buildSubscriptionList(localFeeds),
      relays
    )
    return
  }
  
  // 3. Merge
  const { toAdd, localOnly } = mergeSubscriptions(localFeeds, remoteList)
  
  // 4. Prompt user
  if (toAdd.length > 0) {
    const shouldImport = confirm(
      `Found ${toAdd.length} subscriptions on Nostr. Import them?`
    )
    if (shouldImport) {
      // Add remote feeds to local
      for (const feed of toAdd) {
        await addFeedLocally(feed)
      }
    }
  }
  
  // 5. Upload merged list
  const allFeeds = [...localFeeds, ...toAdd]
  await publishSubscriptionList(
    buildSubscriptionList(allFeeds),
    relays
  )
}

function buildSubscriptionList(feeds: Feed[]): SubscriptionList {
  const rss: string[] = []
  const nostr: string[] = []
  const tags: Record<string, string[]> = {}
  const categories: Record<string, { name: string; color?: string; icon?: string }> = {}
  
  for (const feed of feeds) {
    if (feed.type === 'RSS') {
      rss.push(feed.url)
      if (feed.tags?.length) {
        tags[feed.url] = feed.tags
      }
      if (feed.category) {
        categories[feed.url] = feed.category
      }
    } else {
      const npubMatch = feed.url.match(/npub\w+/)
      const npub = npubMatch ? npubMatch[0] : feed.url
      nostr.push(npub)
      if (feed.tags?.length) {
        tags[npub] = feed.tags
      }
      if (feed.category) {
        categories[npub] = feed.category
      }
    }
  }
  
  return { rss, nostr, tags, categories }
}

Use multiple relays for redundancy:

const SYNC_RELAYS = [
  'wss://relay.damus.io',
  'wss://nos.lol',
  'wss://relay.snort.social',
  'wss://relay.nostr.band',
  'wss://nostr-pub.wellorder.net',
]

Platform-Specific Notes

Web (Browser)

Use NIP-07 browser extensions for signing:

if (window.nostr) {
  const pubkey = await window.nostr.getPublicKey()
  const signedEvent = await window.nostr.signEvent(unsignedEvent)
}

React Native / Mobile

Use a Nostr signing library or implement NIP-46 (Nostr Connect) for remote signing:

// Using @nostr-dev-kit/ndk
import NDK from '@nostr-dev-kit/ndk'

const ndk = new NDK({ explicitRelayUrls: SYNC_RELAYS })
await ndk.connect()

// With a signer
const signer = new NDKPrivateKeySigner(privateKey)
ndk.signer = signer

const event = new NDKEvent(ndk)
event.kind = 30404
event.tags = [['d', 'readstr-subscriptions']]
event.content = JSON.stringify(subscriptionList)
await event.publish()

iOS Swift

Use NostrSDK or similar:

import NostrSDK

func publishSubscriptions(_ list: SubscriptionList) async throws {
    let content = try JSONEncoder().encode(list)
    
    let event = Event(
        kind: 30404,
        tags: [
            ["d", "readstr-subscriptions"],
            ["client", "my-ios-app"]
        ],
        content: String(data: content, encoding: .utf8)!
    )
    
    let signedEvent = try event.sign(with: privateKey)
    
    for relay in relays {
        try await relay.publish(signedEvent)
    }
}

Android Kotlin

Use nostr-java or similar:

import nostr.event.Event
import nostr.event.Kind

fun publishSubscriptions(list: SubscriptionList) {
    val content = gson.toJson(list)
    
    val event = Event.Builder()
        .kind(30404)
        .tags(listOf(
            listOf("d", "readstr-subscriptions"),
            listOf("client", "my-android-app")
        ))
        .content(content)
        .build()
    
    val signedEvent = event.sign(privateKey)
    
    relays.forEach { relay ->
        relay.send(signedEvent)
    }
}

Interoperability

Any app that follows this specification can read and write subscription lists. The key identifiers are:

FieldValuePurpose
kind30404Event type for subscription lists
d tagreadstr-subscriptionsIdentifies this specific list type
client tagYour app nameOptional, for analytics/debugging

Reading Lists from Other Apps

When fetching, check for any 30404 events with the d tag:

const events = await pool.querySync(relays, {
  kinds: [30404],
  authors: [pubkeyHex],
  '#d': ['readstr-subscriptions'],
})

// Get the most recent one
const latest = events.sort((a, b) => b.created_at - a.created_at)[0]

Extending the Schema

If you need additional fields, add them to the content JSON. Existing fields should be preserved:

{
  "rss": [...],
  "nostr": [...],
  "tags": {...},
  "categories": {...},
  "lastUpdated": 1732645747,
  "yourAppField": "custom data"
}

Testing

Verify Events on Relays

# Check for subscription sync events
node -e "
const { SimplePool, nip19 } = require('nostr-tools');

const pool = new SimplePool();
const relays = ['wss://relay.damus.io', 'wss://nos.lol'];

async function check() {
  const events = await pool.querySync(relays, {
    kinds: [30404],
    '#d': ['readstr-subscriptions'],
    limit: 10,
  });
  
  console.log('Found', events.length, 'subscription sync events');
  
  for (const event of events) {
    const npub = nip19.npubEncode(event.pubkey);
    console.log('User:', npub);
    console.log('Content:', JSON.parse(event.content));
  }
  
  pool.close(relays);
}

check();
"

Manual Event Inspection

Use nostr.band or njump.me to search for kind 30404 events.

Security Considerations

  1. Private Data: Subscription lists are public on relays. Don’t include sensitive information.

  2. Event Verification: Always verify event signatures before trusting content.

  3. Content Validation: Parse and validate JSON content before using.

  4. Rate Limiting: Don’t publish too frequently. Once per subscription change is enough.

Summary

Readstr subscription sync enables cross-device, cross-app subscription management using standard Nostr events. By following this specification, your app can:

  • Export user subscriptions to Nostr relays
  • Import subscriptions from other devices/apps
  • Merge local and remote subscription lists
  • Interoperate with Readstr and other compatible apps

The user’s Nostr identity becomes their universal subscription identity.

Bidirectional Automatic Sync

Overview

Readstr now features fully automatic bidirectional sync - your subscriptions, categories, and tags sync seamlessly across all your devices without any manual intervention.

How It Works

🔄 Two-Way Sync

Upload (This Device → Nostr Relays)

  • ✅ Happens automatically after every change
  • ✅ Triggers: Add feed, remove feed, update tags, change category
  • ✅ Timing: 500ms after the change
  • ✅ Includes deleted feeds for proper sync

Download (Nostr Relays → This Device)

  • ✅ Happens automatically every 15 minutes
  • ✅ Triggers: On page load, when fetching feeds
  • ✅ Creates categories if they don’t exist
  • ✅ Only pulls if >15 minutes since last sync

📱 User Experience

From the User’s Perspective:

  1. On Device A: Add a feed to “Technology” category

    • Feed is added immediately
    • 500ms later: Automatically syncs to Nostr (silent, background)
  2. On Device B: Open the app (or wait up to 15 minutes)

    • Automatically detects new feed from Nostr
    • Creates “Technology” category if it doesn’t exist
    • Adds the feed with proper category assignment
    • All happens seamlessly, no prompts or buttons
  3. Back on Device A: Remove a feed

    • Feed disappears immediately
    • 500ms later: Deletion syncs to Nostr
  4. On Device B: Next refresh (within 15 minutes)

    • Feed automatically removed
    • Deleted feed tracked in sync to prevent re-adding

🎯 What Gets Synced

Automatically synced on every change:

  • RSS feed URLs
  • Nostr npub subscriptions
  • Feed tags (multiple tags per feed)
  • Feed categories (name, color, icon)
  • Deleted feeds (to properly sync removals)

Preserved properties:

  • Category colors
  • Category icons
  • Tag associations
  • Subscription metadata

🔒 Requirements

For Auto-Export (Upload):

  • ✅ Nostr browser extension installed (Alby, nos2x, etc.)
  • ✅ Signed in with Nostr
  • ✅ Extension must be available (window.nostr)

For Auto-Import (Download):

  • ✅ Signed in with Nostr (any method)
  • ✅ Access to Nostr relays

If requirements not met:

  • Export: Silently skips (no error shown to user)
  • Import: Still works via server-side fetch

Technical Details

Architecture

User Action (Add/Remove/Edit)
    ↓
Local Mutation (tRPC)
    ↓
UI Updates Immediately
    ↓
500ms delay
    ↓
autoExportToNostr()
    ↓
Fetch ALL subscriptions (including deleted)
    ↓
Build subscription list with categories
    ↓
Sign with Nostr extension
    ↓
Publish to relays (Kind 30404)
    ↓
Done (silent success/failure)

Mutations That Trigger Auto-Export

  1. subscribeFeedMutation - Adding a new feed
  2. unsubscribeFeedMutation - Removing a feed (soft delete)
  3. updateTagsMutation - Changing feed tags
  4. updateCategoryMutation - Moving feed to different category

Auto-Import Triggers

  1. getFeeds query - On every feed list fetch
  2. Interval check - Max once per 15 minutes
  3. Manual sync - Settings > Sync > Import

Data Flow

Kind 30404 Event Structure:

{
  "kind": 30404,
  "created_at": 1738095789,
  "tags": [
    ["d", "readstr-subscriptions"],
    ["client", "readstr"]
  ],
  "content": {
    "rss": ["https://example.com/feed.xml"],
    "nostr": ["npub1abc..."],
    "tags": {
      "https://example.com/feed.xml": ["tech", "news"]
    },
    "categories": {
      "https://example.com/feed.xml": {
        "name": "Technology",
        "color": "#3b82f6",
        "icon": "💻"
      }
    },
    "deleted": ["https://old-feed.com/rss"],
    "lastUpdated": 1738095789
  }
}

Performance & Throttling

Upload Throttling

  • 500ms delay after mutation completes
  • Allows UI to settle before network request
  • Prevents rapid-fire uploads during bulk operations

Download Throttling

  • 15-minute cooldown between auto-imports
  • Prevents excessive relay queries
  • Balances freshness with resource usage

Error Handling

  • Silent failures on export (logged to console)
  • User never sees error modals for background sync
  • Manual sync option available if auto-sync fails

Resource Usage

  • Export: ~1-2 KB per event
  • Import: Single relay query every 15 minutes
  • No polling or WebSocket connections
  • Minimal battery/bandwidth impact

Migration from Manual Sync

Before (Manual Only)

❌ User adds feed on Device A
❌ Feed stays local to Device A
❌ User must remember to click "Export to Nostr"
❌ User switches to Device B
❌ Must click "Import from Nostr"
❌ Feed finally appears on Device B

After (Automatic)

✅ User adds feed on Device A
✅ Automatically syncs to Nostr (500ms later)
✅ User switches to Device B
✅ Feed appears automatically (within 15 minutes)
✅ Zero manual intervention required

Debugging

Check if Auto-Export is Working

Open browser console and look for:

🔄 Auto-exporting subscriptions to Nostr...
✅ Auto-export successful: <eventId>

Check if Auto-Import is Working

Look for server logs during feed fetch:

🔍 Sync merge - Remote RSS URLs: [...]
🔍 Sync merge - Remote Nostr npubs: [...]
🔍 Sync result: X to add, Y to remove, Z local-only

Common Issues

Auto-export not triggering:

  • Check if window.nostr is available
  • Verify user is signed in
  • Check console for errors

Auto-import not working:

  • Wait 15 minutes since last sync
  • Check relay connectivity
  • Verify subscription list exists on relays

Categories not syncing:

  • Ensure both devices have category support
  • Check that color/icon are being set
  • Verify category names match exactly

Future Enhancements

  • Conflict resolution for simultaneous edits
  • Sync status indicator in UI
  • Configurable sync interval (user preference)
  • Offline queue for failed uploads
  • Selective sync (choose what to sync)
  • Sync history/audit log
  • Category sort order syncing
  • Hierarchical categories

Category Sync Implementation

Overview

Categories are now synced alongside tags in the Nostr subscription sync system (Kind 30404 events). This enables users to maintain their feed organization across different devices and clients.

NEW: Automatic Bidirectional Sync - Changes sync automatically in both directions:

  • Download (Import): Every 15 minutes, checks for new feeds/categories from other devices
  • Upload (Export): Immediately after any change (add/remove feed, change category, update tags)

What Changed

1. Nostr Sync Library (src/lib/nostr-sync.ts)

Updated SubscriptionList interface:

export interface SubscriptionList {
  rss: string[]
  nostr: string[]
  tags?: Record<string, string[]>
  categories?: Record<string, { name: string; color?: string; icon?: string }> // NEW
  deleted?: string[]
  lastUpdated?: number
}

Updated buildSubscriptionListFromFeeds function:

  • Now accepts and exports category information for each feed
  • Maps feed URLs/npubs to category objects containing name, color, and icon

Updated mergeSubscriptionLists function:

  • Returns category information in the toAdd array
  • Enables proper category restoration when importing feeds from other devices

2. Server-Side Sync (src/server/api/routers/feed.ts)

Auto-sync in getFeeds endpoint:

  • When importing feeds from remote, automatically creates categories if they don’t exist
  • Maps remote category names to local category IDs
  • Associates imported feeds with their categories

Category creation logic:

  • Checks for existing categories by name
  • Creates new categories with synced color and icon properties
  • Maintains proper sort order for new categories

3. Client-Side Components

Settings Dialog (src/components/settings-dialog.tsx):

  • Updated type definitions to include category information
  • Passes category data when building subscription lists for export

Feed Reader (src/components/feed-reader.tsx):

  • Updated handleImportFeeds to handle categories
  • Automatically creates categories during import if they don’t exist
  • Maps feeds to categories using the synced category names
  • Added createCategoryMutation for on-the-fly category creation

4. Documentation

Updated SUBSCRIPTION_SYNC.md:

  • Added categories field to the schema documentation
  • Updated example JSON to show category structure
  • Updated code examples to handle categories

How It Works

Automatic Export (Upload to Nostr)

Changes are automatically exported to Nostr immediately after:

  1. Adding a feed
  2. Removing/unsubscribing from a feed
  3. Updating a feed’s tags
  4. Changing a feed’s category

Process:

  1. User makes a change (e.g., adds a feed)
  2. System waits 500ms for UI to update
  3. Fetches all subscriptions (including deleted ones)
  4. Builds subscription list with categories
  5. Signs and publishes Kind 30404 event to Nostr relays
  6. Happens silently in background (no user interruption)

Automatic Import (Download from Nostr)

The system automatically imports changes every 15 minutes:

  1. On page load/refresh
  2. Every time feeds are fetched
  3. Only if >15 minutes since last sync

Process:

  1. Fetches Kind 30404 event from Nostr relays
  2. Compares with local subscriptions
  3. For each new feed with a category:
    • Checks if category exists locally by name
    • Creates category if needed (with synced color/icon)
    • Associates feed with the category
  4. Imports feed with tags and category assignment

Manual Export (Settings)

Users can still manually trigger export from Settings > Sync > “Export to Nostr”

  • Useful for forcing an immediate sync
  • Same process as automatic export

Exporting Subscriptions (Legacy Documentation - Now Automatic)

  1. User clicks “Export to Nostr” in Settings > Sync (Now happens automatically)
  2. System calls buildSubscriptionListFromFeeds with current feeds (including categories)
  3. Creates a Kind 30404 event with:
    • Feed URLs/npubs
    • Tags per feed
    • Categories per feed (name, color, icon)
    • Deleted feeds (for proper sync)
  4. Publishes to configured Nostr relays

Importing Subscriptions (Automatic Every 15 Minutes)

  1. User clicks “Import from Nostr” or automatic sync triggers
  2. System fetches Kind 30404 event from relays
  3. For each new feed with a category:
    • Checks if category exists locally by name
    • Creates category if needed (with synced color/icon)
    • Associates feed with the category
  4. Imports feed with tags and category assignment

Cross-Device Sync

When syncing between devices:

  • Categories are matched by name (case-sensitive)
  • If a category doesn’t exist, it’s created with synced properties
  • Feeds maintain their category associations
  • Color and icon preferences are preserved

Example Synced Data

{
  "rss": ["https://example.com/feed.xml"],
  "nostr": ["npub1abc..."],
  "tags": {
    "https://example.com/feed.xml": ["tech", "news"]
  },
  "categories": {
    "https://example.com/feed.xml": {
      "name": "Technology",
      "color": "#3b82f6",
      "icon": "💻"
    },
    "npub1abc...": {
      "name": "Bitcoin",
      "color": "#f59e0b",
      "icon": "₿"
    }
  },
  "lastUpdated": 1738095789
}

Benefits

  1. Full Organization Sync: Users can switch between devices and maintain their complete feed organization
  2. Cross-Client Compatibility: Other Nostr clients can read and preserve category information
  3. Automatic Category Creation: No manual setup needed when moving to a new device
  4. Visual Consistency: Colors and icons sync across devices
  5. Backwards Compatible: Clients that don’t support categories will simply ignore the field
  6. Real-Time Sync: Changes appear on other devices within 15 minutes (or immediately on next page load)
  7. Zero User Effort: No manual “Export” button clicking required
  8. Deleted Feed Tracking: Properly syncs feed removals across devices

Technical Implementation

New Endpoint: getAllSubscriptionsForSync

Added to support automatic export with deleted feeds:

getAllSubscriptionsForSync: protectedProcedure
  .query(async ({ ctx }) => {
    // Returns ALL subscriptions including deleted ones
    // Used for building complete sync payload
  })

Auto-Export Function

Located in feed-reader.tsx:

const autoExportToNostr = useCallback(async () => {
  // Fetches all subscriptions (including deleted)
  const allSubscriptions = await utils.feed.getAllSubscriptionsForSync.fetch()
  // Builds and publishes to Nostr
  const result = await publishSubscriptionList(subscriptionList, signEvent)
}, [user?.npub, utils.feed])

Called automatically after these mutations:

  • subscribeFeedMutation (add feed)
  • unsubscribeFeedMutation (remove feed)
  • updateTagsMutation (update tags)
  • updateCategoryMutation (change category)

Throttling & Performance

  • 500ms delay after mutations (allows UI to update first)
  • Runs asynchronously (doesn’t block UI)
  • Silent failure (errors logged but don’t interrupt user)
  • Import happens max once per 15 minutes (prevents excessive relay queries)

Implementation Notes

  • Categories are matched by name during import
  • If multiple feeds reference the same category name, only one category is created
  • Category sort order is preserved locally but not synced (to allow per-device customization)
  • The system gracefully handles missing categories (feeds import without category if creation fails)
  • Categories are created on-demand during import to avoid conflicts

Future Enhancements

  • Category sort order syncing
  • Category merge/rename detection
  • Category-level metadata (description, rules)
  • Hierarchical categories

Sync System Improvements - Implementation Summary

Overview

Enhanced the Nostr subscription sync system to properly track feed deletions and read status across devices.

Changes Made

1. Database Schema Updates (prisma/schema.prisma)

Subscription Model

  • Added updatedAt field to track when subscriptions change
  • Added deletedAt field for soft-delete tracking (instead of hard deletes)
  • Added indexes for efficient querying of deleted items

ReadItem Model

  • Added syncedAt field to track when read status was last synced to Nostr
  • Added index for efficient querying of unsynced items

2. Sync Library Updates (src/lib/nostr-sync.ts)

New Interfaces

  • Extended SubscriptionList with deleted?: string[] array
  • Added ReadStatusList interface for read status sync

New Event Kind

  • Added READ_STATUS_KIND = 30405 for syncing read status

Enhanced Functions

  • buildSubscriptionListFromFeeds() - Now includes deleted feeds in output
  • mergeSubscriptionLists() - Now returns toRemove array for feeds deleted remotely
  • Added publishReadStatus() - Publish read items to Nostr
  • Added fetchReadStatus() - Fetch read items from Nostr

3. API Updates (src/server/api/routers/feed.ts)

Modified Endpoints

  • getFeeds - Now filters out soft-deleted subscriptions (deletedAt: null)
  • unsubscribeFeed - Changed from hard delete to soft delete (sets deletedAt)

New Endpoints

  • getSubscriptionsForSync - Returns all subscriptions including deleted ones for sync
  • cleanupDeletedSubscriptions - Hard deletes old soft-deleted records (cleanup)
  • markReadItemsSynced - Marks read items as synced after publishing
  • getUnsyncedReadItems - Gets read items that haven’t been synced yet

4. Documentation Updates (SUBSCRIPTION_SYNC.md)

  • Added documentation for kind 30405 (read status sync)
  • Added deleted field to subscription list schema
  • Added ReadStatusList interface documentation

How It Works

Deletion Tracking

  1. When a user unsubscribes from a feed, it’s soft-deleted (deletedAt timestamp set)
  2. During sync, deleted feeds are included in the deleted array in the Nostr event
  3. When another device syncs, it sees the deleted feeds and removes them locally
  4. Old soft-deleted records can be cleaned up after 90 days (configurable)

Read Status Sync

  1. When items are marked as read, they’re tracked in the local database
  2. Unsynced read items can be published to Nostr (kind 30405)
  3. When syncing on another device, the read status is fetched and applied
  4. Items are marked with syncedAt timestamp after successful sync

Migration

A database migration has been created:

  • File: prisma/migrations/20260128144700_add_sync_tracking/migration.sql
  • Adds deletedAt and updatedAt to Subscription table
  • Adds syncedAt to ReadItem table
  • Creates necessary indexes

To apply: Run npx prisma migrate deploy in production or npx prisma migrate dev in development

Benefits

  1. No More Re-adding Deleted Feeds: Deletions are now tracked and synced across devices
  2. Read Status Sync: Read/unread status can be synced across devices (optional feature)
  3. Conflict Resolution: Soft deletes allow for better conflict resolution in sync scenarios
  4. Data Integrity: No loss of information during sync operations
  5. Cleanup: Old deleted records can be purged after a grace period

Next Steps for Implementation

  1. Apply the database migration
  2. Update client-side sync logic to use new toRemove array from merge function
  3. Implement UI for read status sync (optional feature)
  4. Add periodic cleanup job for old deleted subscriptions
  5. Test cross-device sync scenarios

Backward Compatibility

  • Existing sync events (kind 30404) without deleted field will continue to work
  • The deleted field is optional and only included when there are deletions
  • Read status sync (kind 30405) is completely new and optional

Readstr Guide API Implementation

Overview

Create a REST API that exposes the Readstr guide (curated list of RSS and Nostr long-form content feeds) to native mobile apps and external integrations. The API should enable:

  1. Feed Discovery - List all curated feeds with filtering/search
  2. Feed Details - Get individual feed info with recent posts
  3. One-Click Subscribe - Deep-link web pages for native app → web subscription flow

Tech Stack

  • Framework: Next.js App Router (API Routes)
  • Database: PostgreSQL via Prisma ORM
  • Models: GuideFeed (curated feeds), GuideFeedPost (cached posts)

Database Schema Reference

model GuideFeed {
  id          String   @id @default(cuid())
  type        FeedType // RSS | NOSTR | NOSTR_VIDEO
  url         String?  // RSS feed URL
  npub        String?  // Nostr pubkey for NOSTR types
  title       String
  description String?
  category    String?  // e.g., "Bitcoin", "Technology", "News"
  tags        String[] // ["bitcoin", "lightning", "privacy"]
  imageUrl    String?  // Feed avatar/logo
  isActive    Boolean  @default(true)
  featured    Boolean  @default(false)
  posts       GuideFeedPost[]
}

model GuideFeedPost {
  id          String    @id @default(cuid())
  feedId      String
  feed        GuideFeed @relation(fields: [feedId], references: [id])
  title       String
  content     String?
  url         String?
  author      String?
  publishedAt DateTime
}

API Endpoints

1. GET /api/guide - List All Feeds

Query Parameters:

ParameterTypeDescription
categorystringFilter by category
tagstringFilter by tag
typestringFilter by feed type: RSS, NOSTR, NOSTR_VIDEO
featuredbooleanOnly featured feeds
searchstringSearch title/description
limitnumberResults per page (default: 50, max: 100)
offsetnumberPagination offset

Response:

{
  "feeds": [
    {
      "id": "clxx...",
      "type": "NOSTR",
      "npub": "npub1abc...",
      "title": "Author Name",
      "description": "Long-form Bitcoin content",
      "category": "Bitcoin",
      "tags": ["bitcoin", "lightning"],
      "imageUrl": "https://...",
      "featured": true,
      "subscribeUrl": "https://readstr.privkey.io/subscribe?npub=npub1abc..."
    }
  ],
  "total": 42,
  "limit": 50,
  "offset": 0,
  "categories": ["Bitcoin", "Technology", "News"],
  "tags": ["bitcoin", "nostr", "privacy", "lightning"]
}

2. GET /api/guide/[id] - Single Feed Details

Path Parameter: Feed ID or npub

Query Parameters:

ParameterTypeDescription
includePostsbooleanInclude recent posts
postLimitnumberNumber of posts (default: 10)

Response:

{
  "feed": {
    "id": "clxx...",
    "type": "NOSTR",
    "npub": "npub1abc...",
    "title": "Author Name",
    "description": "...",
    "category": "Bitcoin",
    "tags": ["bitcoin"],
    "imageUrl": "https://...",
    "subscribeUrl": "https://readstr.privkey.io/subscribe?npub=npub1abc...",
    "posts": [
      {
        "id": "post123",
        "title": "Article Title",
        "content": "Preview text...",
        "url": "https://habla.news/a/naddr...",
        "author": "npub1abc...",
        "publishedAt": "2025-11-26T12:00:00Z"
      }
    ]
  }
}

3. GET /subscribe - Subscribe Redirect Page

This is a web page (not JSON API) for handling deep-link subscriptions from native apps.

Query Parameters:

ParameterTypeDescription
npubstringRequired. The guide feed’s Nostr pubkey to subscribe to. Missing → error “Missing npub parameter”.
tagsstringOptional comma-separated tags. Falls back to the guide feed’s own tags when omitted.
returnstringOptional same-origin path to redirect to after subscribing. Must start with a single / (e.g. /reader). Off-origin URLs, protocol-relative //host, and custom schemes are rejected and fall back to /reader.

Behavior:

  1. If user is logged in → Add subscription and redirect to the return path
  2. If user is not logged in → Show login prompt, then subscribe
  3. After success → Redirect to the sanitized same-origin return path, defaulting to /reader

Implementation Notes:

  • Use Next.js page component at src/app/subscribe/page.tsx
  • Check auth state via useNostrAuth() context
  • Call api.feed.subscribeFeed.useMutation() for subscription
  • Authenticate via NIP-07 browser extension (connect('nip07'))

4. GET /api/guide/docs - API Documentation

Return OpenAPI-style documentation as JSON for developer reference.

Implementation Details

File Structure

src/app/api/guide/
├── route.ts              # GET /api/guide (list feeds)
├── [id]/
│   └── route.ts          # GET /api/guide/[id] (single feed)
└── docs/
    └── route.ts          # GET /api/guide/docs (API docs)

src/app/subscribe/
└── page.tsx              # Subscribe page (web, not API)

Response Headers

All API responses should include:

const headers = {
  'Content-Type': 'application/json',
  'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
  'Access-Control-Allow-Origin': '*',  // Allow native apps
  'Access-Control-Allow-Methods': 'GET, OPTIONS',
}

Error Responses

{
  "error": "Feed not found",
  "code": "NOT_FOUND"
}

Status codes: 200 (success), 400 (bad request), 404 (not found), 500 (server error)

Subscribe Page UX Flow

┌─────────────────────────────────────────────────┐
│  Native App                                      │
│  ┌─────────────────────────────────────────────┐│
│  │ Feed: Bitcoin Magazine                      ││
│  │ [Subscribe on Readstr]  ← Opens browser ││
│  └─────────────────────────────────────────────┘│
└─────────────────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────┐
│  Browser: readstr.privkey.io/subscribe?npub=... │
│  ┌─────────────────────────────────────────────┐│
│  │       Subscribe to Bitcoin Magazine          ││
│  │                                              ││
│  │  ┌────────────────────────────────────────┐ ││
│  │  │ 🔐 Sign in with Nostr Extension       │ ││
│  │  └────────────────────────────────────────┘ ││
│  └─────────────────────────────────────────────┘│
└─────────────────────────────────────────────────┘
                      │
                      ▼ (after login)
┌─────────────────────────────────────────────────┐
│  ✓ Subscribed! Redirecting to reader...         │
└─────────────────────────────────────────────────┘

Native App Integration Examples

A native app simply opens the subscribe URL in the browser. There is no app-scheme callback: the /subscribe page only accepts npub, optional tags, and an optional same-origin return path. Custom schemes like myapp://subscribed are rejected by the page and fall back to /reader, so do not pass them.

iOS Swift

func subscribeToFeed(npub: String) {
    let subscribeUrl = "https://readstr.privkey.io/subscribe?npub=\(npub)&return=/reader"
    UIApplication.shared.open(URL(string: subscribeUrl)!)
}

Android Kotlin

fun subscribeToFeed(npub: String) {
    val intent = Intent(Intent.ACTION_VIEW, Uri.parse(
        "https://readstr.privkey.io/subscribe?npub=$npub&return=/reader"
    ))
    startActivity(intent)
}

React Native

import { Linking } from 'react-native';

const subscribeToFeed = (npub) => {
  const url = `https://readstr.privkey.io/subscribe?npub=${npub}&return=/reader`;
  Linking.openURL(url);
};

Flutter

import 'package:url_launcher/url_launcher.dart';

void subscribeToFeed(String npub) async {
  final url = 'https://readstr.privkey.io/subscribe?npub=$npub&return=/reader';
  if (await canLaunch(url)) {
    await launch(url);
  }
}

Testing

# List all feeds
curl "https://readstr.privkey.io/api/guide"

# Filter by category
curl "https://readstr.privkey.io/api/guide?category=Bitcoin&featured=true"

# Search feeds
curl "https://readstr.privkey.io/api/guide?search=bitcoin&limit=10"

# Get single feed with posts
curl "https://readstr.privkey.io/api/guide/npub1abc...?includePosts=true&postLimit=5"

# Get API documentation
curl "https://readstr.privkey.io/api/guide/docs"

# Test subscribe page in browser
open "https://readstr.privkey.io/subscribe?npub=npub1abc..."

Summary

This API design enables native Nostr apps to:

  • Discover curated RSS and Nostr long-form content feeds
  • Display feed previews with recent posts
  • Subscribe seamlessly via web handoff with Nostr authentication

The one-click subscribe flow leverages the user’s existing Nostr identity (via NIP-07 browser extension) without requiring native apps to implement their own authentication.

Readstr CLI Development Guide

Project Overview

Build a command-line interface (CLI) client for Readstr using Go and Charm’s Bubble Tea framework for TUI components. The CLI should work both as a standalone RSS/Nostr feed reader and optionally sync with the Readstr web app via Nostr protocol and REST APIs.

Target Features

  • RSS & Nostr Feed Management: Subscribe, list, read feeds
  • TUI Interface: Beautiful terminal UI using Bubble Tea, Lip Gloss, and Bubbles
  • Nostr Sync: Cross-device subscription sync via Nostr events (kind 30404)
  • Offline Support: Local SQLite database for feeds and read status
  • Categories & Tags: Organize feeds like the web version
  • Mark as Read: Track read status locally and optionally sync
  • Favorites: Star articles for later
  • Search & Filter: Find articles across feeds

Tech Stack

Core

Optional Dependencies

  • HTTP Client: Standard library net/http
  • JSON: Standard library encoding/json
  • Config: viper for config management
  • Logging: zerolog

Architecture

readstr-cli/
├── cmd/
│   └── readstr/
│       └── main.go              # Entry point
├── internal/
│   ├── app/
│   │   └── app.go               # Main Bubble Tea model
│   ├── ui/
│   │   ├── feeds.go             # Feed list view
│   │   ├── articles.go          # Article list view
│   │   ├── reader.go            # Article reader view
│   │   ├── categories.go        # Category picker
│   │   └── help.go              # Help/key bindings
│   ├── db/
│   │   ├── sqlite.go            # SQLite operations
│   │   └── models.go            # Data models
│   ├── nostr/
│   │   ├── client.go            # Nostr client wrapper
│   │   ├── sync.go              # Subscription sync (kind 30404)
│   │   └── signer.go            # Event signing
│   ├── feed/
│   │   ├── fetcher.go           # RSS/Nostr feed fetcher
│   │   └── parser.go            # Content parser
│   ├── api/
│   │   └── client.go            # Readstr API client
│   └── config/
│       └── config.go            # Configuration management
├── pkg/
│   └── styles/
│       └── theme.go             # Lip Gloss styles
├── go.mod
├── go.sum
└── README.md

Database Schema (SQLite)

-- Feeds
CREATE TABLE feeds (
    id TEXT PRIMARY KEY,
    type TEXT NOT NULL,           -- 'RSS' | 'NOSTR' | 'NOSTR_VIDEO'
    url TEXT,                     -- RSS URL or empty for Nostr
    npub TEXT,                    -- Nostr pubkey (npub format)
    title TEXT NOT NULL,
    description TEXT,
    last_fetched_at INTEGER,      -- Unix timestamp
    category_id TEXT,             -- FK to categories.id
    created_at INTEGER NOT NULL,
    UNIQUE(type, url),
    UNIQUE(type, npub)
);

-- Feed Items
CREATE TABLE feed_items (
    id TEXT PRIMARY KEY,
    feed_id TEXT NOT NULL,
    guid TEXT NOT NULL,           -- RSS guid or Nostr event ID
    title TEXT NOT NULL,
    content TEXT,
    url TEXT,
    author TEXT,
    published_at INTEGER NOT NULL,
    is_read INTEGER DEFAULT 0,    -- Boolean: 0=unread, 1=read
    is_favorite INTEGER DEFAULT 0,
    thumbnail TEXT,               -- Video thumbnail URL
    video_id TEXT,                -- YouTube/Rumble video ID
    created_at INTEGER NOT NULL,
    FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
    UNIQUE(feed_id, guid)
);

CREATE INDEX idx_feed_items_feed_id ON feed_items(feed_id);
CREATE INDEX idx_feed_items_published_at ON feed_items(published_at DESC);
CREATE INDEX idx_feed_items_is_read ON feed_items(is_read);

-- Tags (many-to-many with feeds)
CREATE TABLE tags (
    id TEXT PRIMARY KEY,
    name TEXT UNIQUE NOT NULL
);

CREATE TABLE feed_tags (
    feed_id TEXT NOT NULL,
    tag_id TEXT NOT NULL,
    PRIMARY KEY(feed_id, tag_id),
    FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
    FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
);

-- Categories
CREATE TABLE categories (
    id TEXT PRIMARY KEY,
    name TEXT UNIQUE NOT NULL,
    color TEXT,                   -- Hex color code
    icon TEXT,                    -- Emoji icon
    sort_order INTEGER DEFAULT 0
);

-- User Preferences
CREATE TABLE preferences (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL
);

-- Common preferences:
-- 'user_npub' - User's Nostr public key
-- 'user_nsec' - User's Nostr private key (encrypted)
-- 'organization_mode' - 'tags' | 'categories'
-- 'mark_read_behavior' - 'on-open' | 'after-10s' | 'never'
-- 'sync_enabled' - '1' | '0'
-- 'nostr_relays' - JSON array of relay URLs

Nostr Integration

Readstr uses two types of Nostr events for cross-device synchronization:

  • Kind 30404 - Subscription list sync (which feeds you’re subscribed to)
  • Kind 30405 - Read status sync (which articles you’ve read)

Subscription Sync (Kind 30404)

Use Nostr replaceable events (kind 30404) to sync subscriptions across devices.

Event Structure:

{
  "kind": 30404,
  "pubkey": "<user's hex pubkey>",
  "created_at": 1732645747,
  "tags": [
    ["d", "readstr-subscriptions"],
    ["client", "readstr-cli"]
  ],
  "content": "{\"rss\":[...],\"nostr\":[...],\"tags\":{...},\"deleted\":[...],\"lastUpdated\":1732645747}"
}

Content Schema:

type SubscriptionList struct {
    RSS         []string            `json:"rss"`         // RSS feed URLs
    Nostr       []string            `json:"nostr"`       // Nostr npubs
    Tags        map[string][]string `json:"tags"`        // URL/npub -> tags
    Deleted     []string            `json:"deleted"`     // Removed feeds
    LastUpdated int64               `json:"lastUpdated"` // Unix timestamp
}

Implementation:

package nostr

import (
    "context"
    "encoding/json"
    "github.com/nbd-wtf/go-nostr"
)

const (
    SubscriptionListKind = 30404
    ReadStatusKind       = 30405
    SubscriptionDTag     = "readstr-subscriptions"
    ReadStatusDTag       = "readstr-read-status"
)

type SyncClient struct {
    pool   *nostr.SimplePool
    relays []string
    signer nostr.EventSigner
}

func (c *SyncClient) PublishSubscriptions(list SubscriptionList) error {
    content, _ := json.Marshal(list)
    
    event := nostr.Event{
        Kind:      SubscriptionListKind,
        CreatedAt: nostr.Now(),
        Tags: nostr.Tags{
            {"d", SubscriptionDTag},
            {"client", "readstr-cli"},
        },
        Content: string(content),
    }
    
    event.Sign(c.signer)
    
    ctx := context.Background()
    for _, relay := range c.relays {
        c.pool.Publish(ctx, relay, event)
    }
    
    return nil
}

func (c *SyncClient) FetchSubscriptions(pubkey string) (*SubscriptionList, error) {
    ctx := context.Background()
    filter := nostr.Filter{
        Kinds:   []int{SubscriptionListKind},
        Authors: []string{pubkey},
        Tags:    nostr.TagMap{"d": []string{SubscriptionDTag}},
        Limit:   1,
    }
    
    events := c.pool.QuerySync(ctx, c.relays, filter)
    if len(events) == 0 {
        return nil, nil
    }
    
    var list SubscriptionList
    json.Unmarshal([]byte(events[0].Content), &list)
    return &list, nil
}

Default Relays:

var DefaultRelays = []string{
    "wss://relay.damus.io",
    "wss://nos.lol",
    "wss://relay.snort.social",
    "wss://relay.nostr.band",
    "wss://nostr-pub.wellorder.net",
}

Read Status Sync (Kind 30405)

Use Nostr replaceable events (kind 30405) to sync which articles have been read across devices.

Event Structure:

{
  "kind": 30405,
  "pubkey": "<user's hex pubkey>",
  "created_at": 1732645747,
  "tags": [
    ["d", "readstr-read-status"],
    ["client", "readstr-cli"]
  ],
  "content": "{\"itemGuids\":[\"guid1\",\"guid2\",\"guid3\",...],\"lastUpdated\":1732645747}"
}

Content Schema:

type ReadStatusList struct {
    ItemGuids   []string `json:"itemGuids"`   // GUIDs of read feed items
    LastUpdated int64    `json:"lastUpdated"` // Unix timestamp
}

Implementation:

func (c *SyncClient) PublishReadStatus(readStatus ReadStatusList) error {
    content, _ := json.Marshal(readStatus)
    
    event := nostr.Event{
        Kind:      ReadStatusKind,
        CreatedAt: nostr.Now(),
        Tags: nostr.Tags{
            {"d", ReadStatusDTag},
            {"client", "readstr-cli"},
        },
        Content: string(content),
    }
    
    event.Sign(c.signer)
    
    ctx := context.Background()
    for _, relay := range c.relays {
        c.pool.Publish(ctx, relay, event)
    }
    
    return nil
}

func (c *SyncClient) FetchReadStatus(pubkey string) (*ReadStatusList, error) {
    ctx := context.Background()
    filter := nostr.Filter{
        Kinds:   []int{ReadStatusKind},
        Authors: []string{pubkey},
        Tags:    nostr.TagMap{"d": []string{ReadStatusDTag}},
        Limit:   1,
    }
    
    events := c.pool.QuerySync(ctx, c.relays, filter)
    if len(events) == 0 {
        return nil, nil
    }
    
    var status ReadStatusList
    json.Unmarshal([]byte(events[0].Content), &status)
    return &status, nil
}

Sync Strategy:

The read status list can grow large over time. Consider these strategies:

  1. Incremental Sync: Only sync GUIDs from the last 90 days
  2. Batch Updates: Accumulate local changes and sync every N items or M minutes
  3. Merge Logic: When importing, mark items as read if they exist in remote list
func (c *SyncClient) MergeReadStatus(local, remote ReadStatusList) []string {
    // Create a set from remote GUIDs
    remoteSet := make(map[string]bool)
    for _, guid := range remote.ItemGuids {
        remoteSet[guid] = true
    }
    
    // Add local GUIDs
    for _, guid := range local.ItemGuids {
        remoteSet[guid] = true
    }
    
    // Convert back to slice
    merged := []string{}
    for guid := range remoteSet {
        merged = append(merged, guid)
    }
    
    return merged
}

Nostr Feed Fetching (NIP-23 Long-form Content)

Fetch long-form articles from Nostr users:

func (c *NostrFetcher) FetchUserArticles(npub string, since time.Time) ([]*FeedItem, error) {
    pubkey, _ := nostr.GetPublicKey(npub) // Convert npub to hex
    
    filter := nostr.Filter{
        Kinds:   []int{30023}, // NIP-23 long-form
        Authors: []string{pubkey},
        Since:   nostr.Timestamp(since.Unix()),
        Limit:   50,
    }
    
    events := c.pool.QuerySync(context.Background(), c.relays, filter)
    
    items := []*FeedItem{}
    for _, event := range events {
        item := &FeedItem{
            GUID:        event.ID,
            Title:       event.Tags.GetFirst([]string{"title", ""}).Value(),
            Content:     event.Content,
            Author:      npub,
            PublishedAt: time.Unix(int64(event.CreatedAt), 0),
            URL:         buildNostrURL(event), // Link to habla.news or njump.me
        }
        items = append(items, item)
    }
    
    return items, nil
}

API Integration

Guide API (Public Feed Directory)

Base URL: https://readstr.privkey.io/api/guide

List Feeds

type GuideFeed struct {
    ID          string   `json:"id"`
    Type        string   `json:"type"` // "RSS" | "NOSTR" | "NOSTR_VIDEO"
    NPUB        string   `json:"npub,omitempty"`
    URL         string   `json:"url,omitempty"`
    Title       string   `json:"title"`
    Description string   `json:"description"`
    Category    string   `json:"category"`
    Tags        []string `json:"tags"`
    ImageURL    string   `json:"imageUrl"`
    Featured    bool     `json:"featured"`
}

func (c *APIClient) GetGuideFeeds(category, tag string, limit int) ([]GuideFeed, error) {
    url := fmt.Sprintf("%s/api/guide?category=%s&tag=%s&limit=%d", 
        c.baseURL, category, tag, limit)
    
    resp, _ := http.Get(url)
    defer resp.Body.Close()
    
    var result struct {
        Feeds []GuideFeed `json:"feeds"`
    }
    json.NewDecoder(resp.Body).Decode(&result)
    
    return result.Feeds, nil
}

Get Feed Details

func (c *APIClient) GetFeedDetails(id string, includePosts bool) (*GuideFeed, error) {
    url := fmt.Sprintf("%s/api/guide/%s?includePosts=%v", c.baseURL, id, includePosts)
    
    resp, _ := http.Get(url)
    defer resp.Body.Close()
    
    var result struct {
        Feed GuideFeed `json:"feed"`
    }
    json.NewDecoder(resp.Body).Decode(&result)
    
    return &result.Feed, nil
}

UI Components (Bubble Tea)

Main App Model

package app

import (
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/bubbles/list"
    "github.com/charmbracelet/bubbles/viewport"
)

type View int

const (
    FeedsView View = iota
    ArticlesView
    ReaderView
    CategoriesView
)

type Model struct {
    currentView View
    
    // Components
    feedList     list.Model
    articleList  list.Model
    readerView   viewport.Model
    
    // Data
    feeds        []Feed
    articles     []Article
    currentFeed  *Feed
    currentArticle *Article
    
    // State
    width, height int
    err           error
}

func (m Model) Init() tea.Cmd {
    return tea.Batch(
        loadFeedsCmd(),
        tea.EnterAltScreen,
    )
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        case "1":
            m.currentView = FeedsView
        case "2":
            m.currentView = ArticlesView
        case "3":
            m.currentView = ReaderView
        case "enter":
            return m.handleEnter()
        }
    
    case tea.WindowSizeMsg:
        m.width = msg.Width
        m.height = msg.Height
        m.readerView.Width = msg.Width - 4
        m.readerView.Height = msg.Height - 10
    }
    
    return m, nil
}

func (m Model) View() string {
    switch m.currentView {
    case FeedsView:
        return renderFeedsView(m)
    case ArticlesView:
        return renderArticlesView(m)
    case ReaderView:
        return renderReaderView(m)
    default:
        return "Unknown view"
    }
}

Three-Panel Layout (like Google Reader)

┌─────────────────────────────────────────────────────────────────────┐
│ Readstr CLI                                          [Help: ?]  │
├───────────────┬───────────────────────┬─────────────────────────────┤
│   Feeds       │   Articles            │   Reader                    │
│               │                       │                             │
│ 📰 All Items  │ ▸ Article Title 1     │ # Article Title             │
│               │   Published: 2h ago   │                             │
│ 📁 Bitcoin    │                       │ Lorem ipsum dolor sit       │
│   ├─ Blog 1   │ ▸ Article Title 2     │ amet, consectetur...        │
│   └─ Blog 2   │   Published: 5h ago   │                             │
│               │                       │ - Bullet point 1            │
│ 📁 Tech       │ ▸ Article Title 3     │ - Bullet point 2            │
│   ├─ HN       │   Published: 1d ago   │                             │
│   └─ Blog 3   │                       │ More content here...        │
│               │   [10 unread]         │                             │
│ ⭐ Favorites  │                       │                             │
│               │                       │                             │
│               │                       │ [Space: Scroll Down]        │
│ [2 feeds]     │ [Page 1/3]            │ [j/k: Navigate]             │
└───────────────┴───────────────────────┴─────────────────────────────┘
│ q: Quit  1: Feeds  2: Articles  3: Reader  a: Add Feed  r: Refresh │
└─────────────────────────────────────────────────────────────────────┘

Styles (Lip Gloss)

package styles

import "github.com/charmbracelet/lipgloss"

var (
    // Colors
    PrimaryColor   = lipgloss.Color("#7C3AED")
    SecondaryColor = lipgloss.Color("#6B7280")
    AccentColor    = lipgloss.Color("#3B82F6")
    ErrorColor     = lipgloss.Color("#EF4444")
    
    // Component styles
    TitleStyle = lipgloss.NewStyle().
        Bold(true).
        Foreground(PrimaryColor).
        PaddingLeft(2)
    
    FeedItemStyle = lipgloss.NewStyle().
        PaddingLeft(2).
        Foreground(lipgloss.Color("#1F2937"))
    
    SelectedStyle = lipgloss.NewStyle().
        Bold(true).
        Foreground(AccentColor).
        Background(lipgloss.Color("#EFF6FF")).
        PaddingLeft(2)
    
    UnreadBadge = lipgloss.NewStyle().
        Background(AccentColor).
        Foreground(lipgloss.Color("#FFFFFF")).
        Padding(0, 1).
        Bold(true)
        
    PanelBorder = lipgloss.NewStyle().
        Border(lipgloss.RoundedBorder()).
        BorderForeground(lipgloss.Color("#E5E7EB"))
)

Key Features to Implement

Phase 1: Core Functionality

  • ✅ SQLite database setup
  • ✅ RSS feed fetching and parsing
  • ✅ Nostr feed fetching (NIP-23)
  • ✅ Three-panel TUI layout
  • ✅ Navigate between feeds, articles, and reader
  • ✅ Mark articles as read
  • ✅ Basic keyboard shortcuts

Phase 2: Organization

  • ✅ Tag support (assign multiple tags to feeds)
  • ✅ Category support (folders with icons)
  • ✅ Filter by tags/categories
  • ✅ Favorites system

Phase 3: Sync

  • ✅ Nostr subscription sync (kind 30404)
  • ✅ Export subscriptions to Nostr
  • ✅ Import subscriptions from Nostr
  • ✅ Merge local and remote subscriptions
  • ✅ Conflict resolution

Phase 4: Polish

  • ✅ Search across articles
  • ✅ Video feed support (YouTube, Rumble)
  • ✅ Markdown rendering with syntax highlighting
  • ✅ Configuration file (~/.config/readstr/config.yaml)
  • ✅ Color themes
  • ✅ Guide directory integration

Configuration File

Location: ~/.config/readstr/config.yaml

# User Identity
nostr:
  npub: "npub1..."              # Your Nostr public key
  nsec: "nsec1..."              # Your Nostr private key (optional, for signing)
  relays:
    - "wss://relay.damus.io"
    - "wss://nos.lol"
    - "wss://relay.snort.social"

# Sync Settings
sync:
  enabled: true
  auto_sync_interval: 15m       # Auto-sync every 15 minutes

# Reading Preferences
reading:
  mark_read_behavior: "on-open" # "on-open" | "after-10s" | "never"
  organization_mode: "tags"     # "tags" | "categories"

# Display
display:
  theme: "default"              # "default" | "dark" | "light"
  feed_list_width: 30
  article_list_width: 40

# Database
database:
  path: "~/.local/share/readstr/feeds.db"

Commands and Key Bindings

Global

  • q / Ctrl+C - Quit
  • ? - Show help
  • 1 - Switch to Feeds view
  • 2 - Switch to Articles view
  • 3 - Switch to Reader view
  • Tab - Cycle between panels
  • / - Search

Feed List

  • / k - Previous feed
  • / j - Next feed
  • Enter - Open feed (show articles)
  • a - Add new feed
  • d - Delete feed
  • e - Edit feed (tags/category)
  • r - Refresh feed
  • R - Refresh all feeds
  • s - Sync with Nostr
  • t - Filter by tags
  • c - Filter by category

Article List

  • / k - Previous article
  • / j - Next article
  • Enter - Open article
  • m - Mark as read/unread
  • M - Mark all as read
  • f - Toggle favorite
  • o - Open in browser
  • Space - Preview article

Reader

  • / k - Scroll up
  • / j - Scroll down
  • g - Go to top
  • G - Go to bottom
  • Space - Page down
  • b - Page up
  • o - Open in browser
  • f - Toggle favorite
  • n - Next article
  • p - Previous article

CLI Commands

# Basic usage
readstr                      # Launch TUI

# Feed management
readstr add <url>            # Add RSS feed
readstr add -n <npub>        # Add Nostr feed
readstr list                 # List all feeds
readstr remove <id>          # Remove feed
readstr refresh              # Refresh all feeds
readstr refresh <id>         # Refresh specific feed

# Sync
readstr sync export          # Export subscriptions to Nostr
readstr sync import          # Import subscriptions from Nostr
readstr sync status          # Show sync status

# Articles
readstr articles             # List recent articles
readstr read <id>            # Read article in terminal
readstr mark-read <id>       # Mark article as read
readstr favorite <id>        # Add to favorites
readstr search <query>       # Search articles

# Organization
readstr tags                 # List all tags
readstr categories           # List all categories
readstr tag <feed-id> <tag>  # Add tag to feed

# Guide
readstr guide list           # Browse guide directory
readstr guide search <query> # Search guide
readstr guide add <id>       # Subscribe to guide feed

# Config
readstr config init          # Create config file
readstr config show          # Show current config
readstr config set <key> <value>

Testing Checklist

RSS Feeds

  • Add RSS feed by URL
  • Fetch and parse articles
  • Mark articles as read
  • Refresh feeds
  • Handle feed errors gracefully

Nostr Feeds

  • Add Nostr user by npub
  • Fetch NIP-23 long-form articles
  • Display author profiles
  • Handle relay errors

Sync

  • Export subscriptions to Nostr
  • Import subscriptions from Nostr
  • Merge local and remote lists
  • Handle conflicts (deleted feeds)
  • Auto-sync on interval

UI

  • Three-panel layout renders correctly
  • Keyboard navigation works
  • Resize handling
  • Color themes apply
  • Help screen displays

Organization

  • Assign tags to feeds
  • Create categories
  • Filter by tags
  • Filter by categories
  • View unread counts per tag/category

Resources

Libraries

  • Bubble Tea: https://github.com/charmbracelet/bubbletea
  • Lip Gloss: https://github.com/charmbracelet/lipgloss
  • Bubbles: https://github.com/charmbracelet/bubbles
  • Glamour: https://github.com/charmbracelet/glamour
  • go-nostr: https://github.com/nbd-wtf/go-nostr
  • gofeed: https://github.com/mmcdole/gofeed

Nostr Specifications

  • NIP-01 (Events): https://github.com/nostr-protocol/nips/blob/master/01.md
  • NIP-07 (Browser Extension): https://github.com/nostr-protocol/nips/blob/master/07.md
  • NIP-23 (Long-form Content): https://github.com/nostr-protocol/nips/blob/master/23.md
  • NIP-33 (Replaceable Events): https://github.com/nostr-protocol/nips/blob/master/33.md

API Documentation

  • Guide API: https://readstr.privkey.io/api/guide/docs
  • Subscription Sync: See SUBSCRIPTION_SYNC.md in web repo

Example: Complete Feed Fetcher

package feed

import (
    "time"
    "github.com/mmcdole/gofeed"
)

type Fetcher struct {
    rssParser   *gofeed.Parser
    nostrClient *nostr.Client
}

func (f *Fetcher) FetchFeed(feed *Feed) ([]*FeedItem, error) {
    switch feed.Type {
    case "RSS":
        return f.fetchRSS(feed)
    case "NOSTR":
        return f.fetchNostr(feed)
    default:
        return nil, fmt.Errorf("unknown feed type: %s", feed.Type)
    }
}

func (f *Fetcher) fetchRSS(feed *Feed) ([]*FeedItem, error) {
    parsed, err := f.rssParser.ParseURL(feed.URL)
    if err != nil {
        return nil, err
    }
    
    items := []*FeedItem{}
    for _, item := range parsed.Items {
        feedItem := &FeedItem{
            FeedID:      feed.ID,
            GUID:        item.GUID,
            Title:       item.Title,
            Content:     getContent(item),
            URL:         item.Link,
            Author:      getAuthor(item),
            PublishedAt: *item.PublishedParsed,
        }
        items = append(items, feedItem)
    }
    
    return items, nil
}

func (f *Fetcher) fetchNostr(feed *Feed) ([]*FeedItem, error) {
    since := time.Now().AddDate(0, 0, -30) // Last 30 days
    return f.nostrClient.FetchUserArticles(feed.NPUB, since)
}

Next Steps

  1. Initialize Go Module

    go mod init github.com/yourusername/readstr-cli
    go get github.com/charmbracelet/bubbletea
    go get github.com/charmbracelet/lipgloss
    go get github.com/charmbracelet/bubbles
    go get github.com/nbd-wtf/go-nostr
    go get github.com/mmcdole/gofeed
    go get github.com/mattn/go-sqlite3
    
  2. Set up SQLite Database

    • Create database schema
    • Write CRUD operations
    • Add migrations support
  3. Build Basic TUI

    • Implement main Bubble Tea model
    • Create three-panel layout
    • Add keyboard navigation
  4. Implement Feed Fetching

    • RSS parser
    • Nostr client
    • Background refresh
  5. Add Sync

    • Nostr event signing
    • Subscription sync
    • Merge logic
  6. Polish

    • Add color themes
    • Implement search
    • Write documentation

Contact

For questions or collaboration:

  • Web App: https://readstr.privkey.io
  • GitHub: https://github.com/privkeyio/readstr
  • Nostr: npub13hyx3qsqk3r7ctjqrr49uskut4yqjsxt8uvu4rekr55p08wyhf0qq90nt7

Happy coding! 🚀

PayWithFlash Integration

Readstr uses PayWithFlash for Bitcoin Lightning subscription payments.

Setup

1. Create a Subscription Plan on Flash

  1. Go to Flash Dashboard
  2. Navigate to New Subs > Create a Subscription Plan
  3. Configure your plan:
    • Name: Readstr Reader
    • Price: 1750 sats
    • Billing Period: Monthly
    • Trial Period: 7 days

2. Configure Webhook

  1. Click “Use Advanced Webhook Features” checkbox
  2. Set Webhook URL: https://readstr.privkey.io/api/webhooks/flash
  3. Save and copy your Subscription Key (you’ll need this for JWT verification)
  4. Copy your Checkout Page URL

3. Set Environment Variables

Add to your .env.production file:

# Flash Configuration
NEXT_PUBLIC_FLASH_CHECKOUT_URL="https://app.paywithflash.com/subscription-page?flashId=3238"
FLASH_SUBSCRIPTION_KEY="97ee08713791860b5c849941dd596d0208928ab7ae9a468cec993504b06daca4"

Note: These values are already configured in the production environment.

How It Works

Payment Flow

  1. User subscribes: User clicks “Subscribe” button → tRPC creates checkout URL with pre-filled npub
  2. Pre-filled form: Flash checkout page auto-fills user’s Nostr npub (no manual entry needed)
  3. Payment completed: Flash sends webhook to /api/webhooks/flash
  4. Webhook verified: JWT token is verified using FLASH_SUBSCRIPTION_KEY
  5. Subscription activated: User record created/updated in database with 30-day access

Pre-Filled User Details

When authenticated users click subscribe, their Nostr npub is automatically passed to Flash using Base64-encoded JSON:

{
  "npub": "npub1...",
  "external_uuid": "npub1...",  // Same as npub for user mapping
  "is_verified": true            // Skip verification (already logged in)
}

This saves users from manually entering their npub and ensures the webhook links to the correct account.

Webhook Events

Flash sends the following events:

  • user_signed_up: New subscription created → Status: ACTIVE
  • renewal_successful: Monthly renewal succeeded → Extend 30 days
  • renewal_failed: Payment failed → Status: PAST_DUE
  • user_paused_subscription: User paused → Status: CANCELLED
  • user_cancelled_subscription: User cancelled → Status: CANCELLED

Security

  • All webhooks include a JWT token in the Authorization header
  • Token is verified using HS256 algorithm with your subscription key
  • Tokens expire after 1 hour
  • Invalid tokens are rejected with 401 status

Testing

Test Webhook Locally

  1. Use ngrok to expose your local server:

    ngrok http 3000
    
  2. Update Flash webhook URL to: https://your-ngrok-url.ngrok.io/api/webhooks/flash

  3. Make a test payment on Flash checkout page

  4. Check your server logs for webhook processing

Manual Webhook Testing

# Get a valid JWT token from Flash (check their test tools)
curl -X POST https://readstr.privkey.io/api/webhooks/flash \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "eventType": {"id": "1", "name": "user_signed_up"},
    "data": {
      "npub": "npub1test...",
      "email": "test@example.com"
    }
  }'

Database Schema

Subscriptions are stored in the UserSubscription table:

model UserSubscription {
  userPubkey         String   @unique // Nostr npub or external_uuid
  status             SubscriptionStatus
  trialEndsAt        DateTime
  subscriptionEndsAt DateTime?
  cancelledAt        DateTime?
  ...
}

Subscription Statuses

  • TRIAL: In 7-day free trial (not used with Flash, goes directly to ACTIVE)
  • ACTIVE: Paid and active subscription
  • PAST_DUE: Payment failed, grace period
  • CANCELLED: User cancelled, valid until end date
  • EXPIRED: Subscription expired

Troubleshooting

Webhooks not received

  1. Check Flash dashboard for webhook delivery logs
  2. Verify webhook URL is accessible publicly (use curl)
  3. Check server logs for errors
  4. Ensure HTTPS is enabled (Flash requires HTTPS)

JWT verification fails

  1. Verify FLASH_SUBSCRIPTION_KEY matches the key in Flash dashboard
  2. Check token hasn’t expired (1 hour validity)
  3. Ensure secret key has no extra whitespace

User not getting access

  1. Check if webhook was received (server logs)
  2. Verify user identifier (npub/external_uuid) is correct
  3. Check database for UserSubscription record
  4. Verify subscription status is ACTIVE

Documentation