502 lines
16 KiB
Markdown
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
|