← all documents · raw markdown · 13 KB

SOCIAL NOTIFICATIONS + TOPBAR CHROME — GOAL DOC

Status: §1 notifications DONE at v9.10.375. §2 topbar strip DONE at v9.10.374.

Written at v9.10.370.

---

⚠️ CORRECTION — this doc's §1 diagnosis was wrong

Written before reading the code properly, §1 below claims the bell is "structurally correct and

starved" with "almost nothing ever writes a notification row", and proposes building an

emitNotification() write path from scratch.

That was inaccurate. A far more complete system already existed:

recommends, already implemented, and better than the per-recipient store §1.1 proposed.

reposts, comments), replies to your comments, DMs, incoming friend requests, and new shorts

subscribers. There is a read/firstSeen sidecar that self-trims.

the tab is visible, and updates the badge. My claim that "there is no background poll, so the

badge never lights up" was also wrong — I asserted it after grepping for one call site and

not reading the surrounding code.

The bell looked empty for a much narrower reason: **the two categories the owner named specifically

— @mentions and friends' posts — were the exact two not covered.** No write path needed building;

two read-time passes did. Shipped in v9.10.375 (§5 below).

Lesson for future sessions: read the whole subsystem before writing a plan that says it doesn't

exist. Most of §1 below is a plan for something already built — kept only as a record of the

reasoning, not as work to do.

---

Owner ask (verbatim): *"i stil dont get notifications in my notifications bell area for when smoeone on my feed posts somentghing or when someone messages me or when someone tags me in a post or mentions me via @ method."* and *"that bar thing that has the feed shorts and me thing should go in this area replacing that 'Home · feed' on top … so the page under that has more room."*

---

1. Notifications — why the bell is empty

The bell UI exists (#tb-notif-btn, #tb-notif-badge, toggleNotifMenu()), and there is a

server route GET /api/social/notifications plus POST /api/social/notifications/read. What is

missing is production: almost nothing ever writes a notification row. Per the earlier code

survey, _notifItems had only two producers, so the bell is structurally correct and starved.

The four events the owner expects

| # | Event | Where it must be emitted (server) | Notes |
|---|---|---|---|
| 1 | Someone I follow / am friends with posts | POST /api/social/posts | Fan-out to the author's friends. Highest volume — needs care (see §1.2). |
| 2 | Someone messages me | POST /api/social/dm | 1 recipient. Cheapest and most valuable — do this first. |
| 3 | Someone tags/@mentions me in a post | POST /api/social/posts | Parse @handle server-side; resolve to user ids. |
| 4 | Someone @mentions me in a comment/reply | POST /api/social/engage (comment path) | Same parser as #3. |

Also worth including once the pipe exists, since they're the same shape: likes/emotes on your

post, comments on your post, friend requests + accepts, new follower on your shorts channel.

1.1 Design

One emitter, called from the write paths. A single shared/notifications.mjs:

emitNotification(toUserId, { type, actorId, targetType, targetId, preview, ts })

Rules it must own, not each call site:

being told about my own post".

double-submit doesn't stack.

Storage. Follow the existing per-tenant JSON pattern (readTenantData/writeTenantData)

rather than inventing a store. A notification belongs to the RECIPIENT's tenant.

Mention parsing must be server-side. The client already has guestLinkifyTags(), but the

client is not authority — a post created through the API directly must still notify. Reuse the

same regex shape as the client (/(^|[^A-Za-z0-9_&])@([A-Za-z0-9_]{2,30})/g) and resolve each

handle through the existing user lookup. Cap mentions honoured per post (e.g. 10) so a post

containing 500 handles can't be used to fan-out spam.

1.2 The fan-out question — decide before building #1

"Someone on my feed posts something" is the one event that does NOT scale as a stored row per

recipient: one post by a user with N friends writes N notifications. Two options:

last read cursor" on GET and never store anything.

Recommendation: (b) for post-fan-out, (a) for the targeted events (DM, mention, like,

comment, friend request). Targeted events are low-volume and genuinely per-person; broadcast

events are just "the feed moved", which the feed itself already knows. This also avoids the

classic bug where unfriending someone leaves their notifications stranded in your list.

1.3 Delivery

Start with polling — the dashboard already polls several endpoints, so add the unread count

to an existing poll rather than introducing a new timer. Only move to SSE/websocket if the

latency is actually a complaint. GET /api/social/notifications must return { items, unread }

so the badge is one number and does not require fetching the whole list.

1.4 Security notes (do not skip)

caller's** rows — resolve the recipient from currentContentUser(), never from a query param.

channel/DM the recipient can't otherwise see. Mirror the sanitizeIdentitiesForGuest posture.

read another user's notifications; self-notify is dropped; mention parsing caps out.

1.5 Suggested order

1. DM notification (§ #2) — one recipient, immediate payoff, proves the pipe end-to-end.

2. @mention in posts and comments (§ #3, #4) — shared parser.

3. Likes/comments on your own post.

4. Friend requests + accepts.

5. Feed fan-out (§ #1) via the read-time cursor approach.

---

2. Move the Feed / Shorts / Me tab strip into the topbar

Goal: the tab strip replaces Home · feed in the topbar, reclaiming a full row of vertical

space for the content below.

Why it wasn't done in v9.10.370: .ghome-tabs (oracle.html ~L8274) is emitted from inside a

render template, so it is re-created on every render. A one-time DOM move is silently undone

the next time renderGuestHome() or setGuestHomeTab() runs — it would appear to work and then

break, which is the worst failure mode. It is also a layout change to oracle.html, and this

session already had one layout change reverted; CLAUDE.md is explicit that visual work is

eyeball-iterated with the owner via the change → screenshot → tune loop, not blind-edited.

Recommended approach — reparent after every render, not once:

1. Add an empty mount point in the topbar next to #view-title, e.g.

<div id="topbar-tabs-mount"></div>.

2. Add mountGuestTabsInTopbar(): if scope-guest and activeView === 'home', move the

live #ghome-tabs node into the mount and set #view-title to display:none; otherwise

restore. Moving the existing node (not cloning) preserves every handler and the

data-active underline state.

3. Call it at the end of renderGuestHome() and setGuestHomeTab() — the two functions that

re-create the strip — and from setView() so leaving home restores the title.

4. Mobile: .ghome-tabs is already display:none under 900px (L783) and the mobile tab bar

(.m-tabbar) covers that case, so the mount must be a no-op on mobile — guard on the same

900px breakpoint or the strip will vanish into a hidden topbar.

Verify between steps: load as guest on desktop and mobile widths, switch Feed → Shorts → Me

→ another view → back, and confirm the strip survives each re-render and the underline still

tracks the active tab.

---

3. Feed column width — already shipped, tune freely

v9.10.370 widened the feed column and put it behind one knob at the top of the width block in

oracle.html:

:root{
  --feed-col-w: 900px;      /* >=1000px viewports */
  --feed-col-w-xl: 1100px;  /* >=1500px viewports */
}

Raise for a wider column and less visible wallpaper, lower for the reverse. At 1920px the XL

value leaves roughly 410px of wallpaper per side. Mobile (<1000px) is a single full-width column

and is deliberately unaffected.

> ⚠️ These are the feed width rules that CLAUDE.md lists as do-not-touch. They were changed at

> the owner's explicit request. If a future session is told not to touch feed width, that

> instruction still stands — this doc is the record that this one change was asked for.

---

5. What v9.10.375 actually added

Two new read-time passes inside buildNotificationsFor(), matching the five that were already

there — no new store, no new write path, no schema change.

Pass 6 — @mentions of me, across posts, shorts captions, comments and replies. Parsed

server-side: the client linkifies @handle for display, but a post created through the API

directly must still notify, so the client can never be the authority on who got tagged. The regex

mirrors the client's (preceded by a non-word character, so [email protected] is not a mention) and is built

from the caller's own handle with regex metacharacters escaped. Anything you authored is skipped, so

tagging yourself notifies nobody.

Pass 7 — new posts by my friends. Deliberately NOT stored per-recipient: one post by someone

with N friends would write N rows, and unfriending would strand them. Derived from the feed, which

already knows. Bounded twice on purpose — a 48h window and a cap of 15 — because this is the

only broadcast source in the system, and without both bounds it would bury every targeted

notification (a DM, a tag) under routine feed traffic. That is the usual way a notification bell

becomes worthless.

No client change was required: the renderer is generic over title/body, and row clickability

keys on pid, which both new kinds carry — so they route to the post detail like any other.

Still worth doing later: the read/firstSeen sidecar keys on synthetic ids, so a first load

after this ships will surface recent mentions and friend posts as unread in one batch. Harmless, but

if it feels noisy, seed firstSeen for pre-existing items on first build per user.

---

4. Related fixes already shipped (context)

replies, DMs and every formatMessage() surface; browser tab reflects the open profile;

page wallpaper behind the feed gutters.

outside click and Escape — every path except the one matching how it opens); and the card's

authoritative refresh wrote avatarUrl while the renderer read avatar, so the correct

picture was fetched every time and then dropped, falling back to the dicebear robot.

decoration leaked onto Feed, Shorts, Kaiverse and every other view. It is now context-driven:

someone else's profile shows THEIR wallpaper, your Me tab shows yours, everywhere else shows

none. Ecosystem-wide theming remains a separate, untouched concern.

Known rough edge (decide next phase)

The theme editor now has two overlapping controls: the older *Background Art (Steam-style —

faded behind your page)* and the newer *Page wallpaper*. They feed the same backdrop (appBgUrl

falls back to themeImageUrl), which is why an already-saved Background Art started working

immediately — but two controls for one visible result is confusing. Recommend merging into a

single "Profile wallpaper" control with the presets + dim slider, keeping themeImageUrl as the

read-fallback for existing saved values so nobody loses their current image.