Files
client/plans/viewed-mode-feature.md
2026-04-17 16:52:51 -04:00

16 KiB

Plan: viewed=true Option for post.html

Overview

Add a new URL parameter viewed=true to post.html that transforms the page into an RSS-reader-style interface. Instead of endless scrolling, it tracks which posts the user has already seen per-follow, and only shows unread posts.

The left column shows a list of follows with unread counts. The right column shows unread posts. Clicking a follow name filters the feed. A "Mark All As Read" button updates the viewed state and publishes it as an encrypted kind 30078 event.


URL Parameter

Parameter Values Default Description
viewed true / false false Enable RSS-reader-style viewed mode

Constraints When viewed=true

  • post=false is implied — no post composition box
  • npub parameter is ignored — only works for the logged-in user
  • follows=true behavior is implied — we need the contact list

Example URL

post.html?viewed=true

Kind 30078 Encrypted "Viewed" Event

Kind 30078 is a parameterized replaceable event (NIP-78 App-Specific Data). It is already supported by the NDK bundle as NDKKind.AppSpecificData = 30078.

Event Structure

{
  "kind": 30078,
  "tags": [["d", "viewed"]],
  "content": "<NIP-44 encrypted JSON string>",
  "created_at": 1708646400
}

The d tag value "viewed" makes this a unique parameterized replaceable event per user. Publishing a new one replaces the old one on relays.

Decrypted Content JSON

{
  "v": 1,
  "lastGlobalView": 1708646000,
  "follows": {
    "abcdef1234...hex_pubkey": 1708646400,
    "fedcba4321...hex_pubkey": 1708640000,
    "112233aabb...hex_pubkey": 1708600000
  }
}
Field Type Description
v number Schema version for future migrations
lastGlobalView number Unix timestamp — the oldest per-follow timestamp. Used as the since filter for the single subscription that fetches all follows posts.
follows object Map of pubkey -> unix_timestamp. Each timestamp is the newest post the user has marked as read for that follow.

Encryption

Uses NIP-44 encryption to self (own pubkey as recipient) via window.nostr.nip44 from nostr-lite.js.

Encrypt before publish:

const jsonStr = JSON.stringify(viewedData);
const encrypted = await window.nostr.nip44.encrypt(currentPubkey, jsonStr);

Decrypt on load:

const decrypted = await window.nostr.nip44.decrypt(currentPubkey, event.content);
const viewedData = JSON.parse(decrypted);

Subscription Optimization — The lastGlobalView Approach

Instead of N separate subscriptions (one per follow with different since values), we use one single subscription:

subscribe({
  kinds: [1],
  authors: Array.from(feedPubkeys),
  since: viewedData.lastGlobalView
}, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });

How lastGlobalView Works

  1. When the user loads the viewed page, we subscribe to all follows posts since lastGlobalView
  2. All posts arrive in one stream — we filter client-side using per-follow timestamps from the follows map
  3. A post is unread if post.created_at > viewedData.follows[post.pubkey]
  4. When the user clicks Mark All As Read for a specific follow, we update that follows timestamp to now
  5. lastGlobalView is recalculated as Math.min(...Object.values(viewedData.follows)) — the oldest per-follow timestamp, ensuring we never miss posts
flowchart LR
    A[Subscribe since lastGlobalView] --> B[All posts arrive]
    B --> C{For each post}
    C --> D[post.created_at > follows timestamp?]
    D -->|Yes| E[Show as UNREAD]
    D -->|No| F[Discard]
    E --> G[User clicks Mark All Read for author X]
    G --> H[follows X = now]
    H --> I[lastGlobalView = min of all follows timestamps]
    I --> J[Publish updated 30078]

UI Layout

+--------------------------------------------------+
|                    VIEWED                          |
+---------------+----------------------------------+
|  FOLLOWS      |         UNREAD POSTS              |
|               |                                    |
|  alice (3)    |  [Post from bob - 1h ago]         |
|  bob (1)      |  [Post from alice - 2h ago]       |
|  carol (7)    |  [Post from carol - 3h ago]       |
|  ----------   |  [Post from alice - 5h ago]       |
|  dave         |  [Post from carol - 6h ago]       |
|  eve          |  ...                              |
|               |                                    |
|               |  [Mark All As Read]               |
+---------------+----------------------------------+
|                    FOOTER                          |
+--------------------------------------------------+

Initial State

Right column shows ALL unread posts from ALL follows, sorted newest first.

After Clicking a Follow Name

Right column filters to only that follows unread posts. Mark All As Read marks only that follows posts as read.

After Clicking Mark All As Read

That follows count goes to 0, they move to the all-caught-up group, right column returns to showing all remaining unread posts from everyone.


Left Column — Follows List

  • Width: ~25% of divBody, min-width 200px
  • Scrollable independently
  • Two groups separated by a subtle divider:
    1. Unread group — follows with unread posts, sorted alphabetically by display name
    2. All caught up group — follows with zero unread, sorted alphabetically, dimmed with var(--muted-color)
  • Each item shows: display name + unread count badge
  • Click behavior: filters right column to that author
  • Active state: currently selected follow gets var(--accent-color) highlight
  • Profiles fetched via fetchProfile() from post-interactions.mjs

Right Column — Posts Feed

  • Width: ~75% of divBody
  • Contains the existing #divFeed element moved here
  • At the bottom: Mark All As Read button
  • No post composition box

Mark All As Read Button

Same styling as the existing #btnSeeMore button:

  • width: 60%; min-width: 300px; max-width: 600px
  • padding: 10px 20px
  • border: 2px solid var(--primary-color)
  • border-radius: 10px
  • font-family: var(--font-family)
  • color: var(--muted-color)
  • background-color: var(--secondary-color)
  • Text: "Mark All As Read"

Filtering: Original Posts Only

A kind 1 event is a comment/reply if it has e tags referencing a parent event. An original post has no e tags, or only e tags that are mention type.

function isOriginalPost(event) {
  const eTags = event.tags?.filter(t => t[0] === 'e') || [];
  if (eTags.length === 0) return true;
  return eTags.every(t => t[3] === 'mention');
}

Default Timestamp for New Follows

For follows that have no entry in viewedData.follows — either first time using the feature, or newly followed someone:

function getViewedTimestamp(pubkey) {
  if (viewedData.follows[pubkey]) {
    return viewedData.follows[pubkey];
  }
  // Default: 24 hours ago
  return Math.floor(Date.now() / 1000) - 86400;
}

After the initial subscription delivers posts, for follows with no existing entry and fewer than 10 unread original posts, we extend the window by doing a secondary fetch without the since filter (limit 10) to ensure the 10-post minimum.


Full Data Flow

flowchart TD
    A[Page Load: viewed=true] --> B[Parse URL params]
    B --> C[initNDKPage + getPubkey]
    C --> D[Fetch kind 3 contact list]
    C --> E[Fetch kind 30078 d=viewed]
    
    D --> F[Extract followedPubkeys]
    E --> G[Decrypt content with NIP-44]
    G --> H[Parse viewedData JSON]
    
    F --> I[Build follows list with profiles]
    H --> I
    
    H --> J[Subscribe kind 1 from all follows since lastGlobalView]
    J --> K[Posts stream in via ndkEvent]
    K --> L{isOriginalPost?}
    L -->|No| M[Discard]
    L -->|Yes| N{post.created_at > follows timestamp?}
    N -->|No| O[Discard - already read]
    N -->|Yes| P[Add to unread posts array]
    
    P --> Q[Render in right column]
    P --> R[Update unread count in left column]
    
    I --> S[Render left column: follows list]
    S --> T{User clicks follow name}
    T --> U[Filter right column to that author only]
    
    U --> V{User clicks Mark All As Read}
    V --> W[Update follows.pubkey = now]
    W --> X[Recalculate lastGlobalView]
    X --> Y[Encrypt JSON with NIP-44]
    Y --> Z[Publish kind 30078]
    Z --> AA[Update UI: zero count re-sort]

Files Modified

File Changes
www/post.html URL param parsing, two-column layout CSS, follows list rendering, viewed event fetch/decrypt/encrypt/publish, mark-as-read logic, client-side filtering

Files NOT Modified

File Reason
www/js/init-ndk.mjs All needed functions already exported: subscribe, publishEvent, fetchEventsFromAllRelays, queryCache, getPubkey
www/js/post-interactions.mjs renderPostItem, fetchProfile, renderAuthorHeader all reusable as-is
www/css/client.css Per CSS placement rules, page-specific styles go in the page style block
www/ndk-worker.js Standard event publish/subscribe handles kind 30078 like any other event

Implementation Steps

Step 1: Parse viewed URL Parameter

Add alongside existing params at the top of the script in post.html:

const viewedParam = urlParams.get('viewed');
const showViewed = viewedParam === 'true';

When showViewed is true: force showPostBox = false, set showFollows = true behavior, ignore npubParam.

Step 2: Add Two-Column CSS

In the page style block, add styles for viewed mode:

  • Override #divBody to flex-direction: row when viewed mode is active
  • #divFollowsList: left column — 25% width, min 200px, scrollable, border-right separator
  • #divFeedColumn: right column — flex 1, contains feed + mark-read button
  • .follow-item: clickable row with name and badge
  • .follow-item.active: accent color highlight
  • .follow-item.has-unread: normal weight text
  • .follow-item.caught-up: dimmed muted color
  • .follow-unread-badge: count badge after name
  • #btnMarkRead: same style as #btnSeeMore

Step 3: Restructure HTML in JavaScript

In the main() function, when showViewed is true:

  1. Create #divFollowsList element
  2. Create #divFeedColumn wrapper element
  3. Move #divFeed into #divFeedColumn
  4. Hide #divPost
  5. Hide #btnSeeMore — not used in viewed mode
  6. Create and add #btnMarkRead to #divFeedColumn
  7. Insert both columns into #divBody

Step 4: Fetch Contact List

Reuse existing follows logic — subscribe to kind 3 for current user, extract followed pubkeys. This code already exists in post.html for the follows=true mode.

Step 5: Fetch and Decrypt Kind 30078 Viewed Event

subscribe(
  { kinds: [30078], authors: [currentPubkey], '#d': ['viewed'] },
  { closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);

In the ndkEvent handler, when a kind 30078 event arrives with d=viewed:

const decrypted = await window.nostr.nip44.decrypt(currentPubkey, evt.content);
viewedData = JSON.parse(decrypted);

If decryption fails or no event exists, initialize with defaults:

viewedData = {
  v: 1,
  lastGlobalView: Math.floor(Date.now() / 1000) - 86400,
  follows: {}
};

Step 6: Subscribe to All Follows Posts

Single subscription using lastGlobalView:

const since = viewedData.lastGlobalView || (Math.floor(Date.now()/1000) - 86400);

subscribe({
  kinds: [1],
  authors: Array.from(feedPubkeys),
  since: since,
  limit: 500
}, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });

Step 7: Client-Side Filtering in ndkEvent Handler

For each kind 1 event that arrives:

  1. Check isOriginalPost(evt) — discard replies
  2. Check isUnread(evt) — compare evt.created_at against getViewedTimestamp(evt.pubkey)
  3. If unread, add to unreadPosts array and update the unread count for that pubkey
  4. Debounced render of the feed

Step 8: Render Follows List

After contact list and viewed data are both loaded:

  1. For each followed pubkey, fetch profile display name via fetchProfile()
  2. Calculate unread count from the unreadPosts array
  3. Sort into two groups: has-unread alphabetically, then all-caught-up alphabetically
  4. Render as clickable list items with count badges
  5. Counts update dynamically as posts stream in

Step 9: Handle Follow Click

let selectedFollowPubkey = null;

function selectFollow(pubkey) {
  selectedFollowPubkey = pubkey;
  // Update active highlight in left column
  // Filter displayed posts to only this author
  renderFilteredFeed();
  btnMarkRead.style.display = 'block';
}

function clearFollowSelection() {
  selectedFollowPubkey = null;
  // Remove highlight
  // Show all unread posts
  renderFilteredFeed();
}

Step 10: Implement Mark All As Read

async function markAllAsRead() {
  const now = Math.floor(Date.now() / 1000);
  
  if (selectedFollowPubkey) {
    viewedData.follows[selectedFollowPubkey] = now;
  } else {
    for (const pk of feedPubkeys) {
      viewedData.follows[pk] = now;
    }
  }
  
  // Recalculate lastGlobalView
  const timestamps = Object.values(viewedData.follows);
  viewedData.lastGlobalView = timestamps.length > 0
    ? Math.min(...timestamps)
    : now;
  
  // Encrypt and publish
  const jsonStr = JSON.stringify(viewedData);
  const encrypted = await window.nostr.nip44.encrypt(currentPubkey, jsonStr);
  
  await publishEvent({
    kind: 30078,
    tags: [['d', 'viewed']],
    content: encrypted,
    created_at: now
  });
  
  // Update UI
  removeReadPostsFromFeed();
  updateFollowsList();
  
  // Clear selection, show remaining unread
  if (selectedFollowPubkey) {
    clearFollowSelection();
  }
}

Step 11: Handle 10-Post Minimum for New Follows

After the initial subscription delivers posts and EOSE fires:

for (const pubkey of feedPubkeys) {
  if (!viewedData.follows[pubkey]) {
    const count = unreadPosts.filter(p => p.pubkey === pubkey).length;
    if (count < 10) {
      // Fetch more posts without since filter
      const moreEvents = await fetchEventsFromAllRelays({
        kinds: [1], authors: [pubkey], limit: 10
      });
      // Add original posts not already in unreadPosts
      // Use the oldest of these as the effective viewed-since
    }
  }
}

Step 12: Update Page Title and Header

if (showViewed) {
  document.title = 'VIEWED';
  headerText.textContent = 'VIEWED';
}

Performance Characteristics

  • 1 subscription for all follows posts instead of N separate subscriptions
  • Client-side filtering — fast, no relay round-trips per follow
  • Lazy profile loading — profiles fetched async, UI updates as they arrive
  • Debounced rendering — reuses existing debouncedRenderFeed() pattern
  • Parameterized replaceable event — kind 30078 with d=viewed means only one event per user on relays, automatically replaced on update
  • NIP-44 encryption — viewed data is private, relays cannot see which follows you have read

Todo List

  • Parse viewed URL parameter and set mode flags
  • Add two-column CSS styles to page style block
  • Restructure divBody into two columns when viewed=true
  • Fetch and decrypt kind 30078 viewed event with NIP-44
  • Subscribe to all follows posts using single lastGlobalView subscription
  • Implement isOriginalPost filter to exclude replies
  • Implement client-side unread filtering using per-follow timestamps
  • Render follows list with unread counts sorted into two groups
  • Handle follow click to filter feed to single author
  • Implement Mark All As Read — encrypt and publish updated 30078
  • Handle 10-post minimum for new follows without viewed history
  • Update page title and header text
  • Test full flow: load, click follow, mark read, re-sort