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=falseis implied — no post composition boxnpubparameter is ignored — only works for the logged-in userfollows=truebehavior 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
- When the user loads the viewed page, we subscribe to all follows posts since
lastGlobalView - All posts arrive in one stream — we filter client-side using per-follow timestamps from the
followsmap - A post is unread if
post.created_at > viewedData.follows[post.pubkey] - When the user clicks Mark All As Read for a specific follow, we update that follows timestamp to now
lastGlobalViewis recalculated asMath.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:
- Unread group — follows with unread posts, sorted alphabetically by display name
- 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()frompost-interactions.mjs
Right Column — Posts Feed
- Width: ~75% of divBody
- Contains the existing
#divFeedelement 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: 600pxpadding: 10px 20pxborder: 2px solid var(--primary-color)border-radius: 10pxfont-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
#divBodytoflex-direction: rowwhen 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:
- Create
#divFollowsListelement - Create
#divFeedColumnwrapper element - Move
#divFeedinto#divFeedColumn - Hide
#divPost - Hide
#btnSeeMore— not used in viewed mode - Create and add
#btnMarkReadto#divFeedColumn - 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:
- Check
isOriginalPost(evt)— discard replies - Check
isUnread(evt)— compareevt.created_atagainstgetViewedTimestamp(evt.pubkey) - If unread, add to
unreadPostsarray and update the unread count for that pubkey - Debounced render of the feed
Step 8: Render Follows List
After contact list and viewed data are both loaded:
- For each followed pubkey, fetch profile display name via
fetchProfile() - Calculate unread count from the
unreadPostsarray - Sort into two groups: has-unread alphabetically, then all-caught-up alphabetically
- Render as clickable list items with count badges
- 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=viewedmeans 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