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

502 lines
16 KiB
Markdown

# 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