# 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

```json
{
  "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

```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:**
```javascript
const jsonStr = JSON.stringify(viewedData);
const encrypted = await window.nostr.nip44.encrypt(currentPubkey, jsonStr);
```

**Decrypt on load:**
```javascript
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:

```javascript
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

```mermaid
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.

```javascript
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:

```javascript
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

```mermaid
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`:

```javascript
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

```javascript
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`:

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

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

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

### Step 6: Subscribe to All Follows Posts

Single subscription using `lastGlobalView`:

```javascript
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

```javascript
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

```javascript
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:

```javascript
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

```javascript
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
