diff --git a/assets/reader.css b/assets/reader.css new file mode 100644 index 0000000..5558c99 --- /dev/null +++ b/assets/reader.css @@ -0,0 +1,884 @@ +/** + * ActivityPub Reader Styles + * Card-based layout inspired by Phanpy/Elk + * Uses Indiekit CSS custom properties + */ + +/* ========================================================================== + Tab Navigation + ========================================================================== */ + +.ap-tabs { + border-bottom: 1px solid var(--color-offset); + display: flex; + gap: var(--space-xs); + margin-bottom: var(--space-m); + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.ap-tab { + border-bottom: 2px solid transparent; + color: var(--color-text-muted); + font-size: var(--font-size-body); + padding: var(--space-s) var(--space-m); + text-decoration: none; + transition: + color 0.2s ease, + border-color 0.2s ease; + white-space: nowrap; +} + +.ap-tab:hover { + color: var(--color-text); +} + +.ap-tab--active { + border-bottom-color: var(--color-primary); + color: var(--color-primary); + font-weight: 600; +} + +/* ========================================================================== + Timeline Layout + ========================================================================== */ + +.ap-timeline { + display: flex; + flex-direction: column; + gap: var(--space-m); +} + +/* ========================================================================== + Item Card + ========================================================================== */ + +.ap-card { + background: var(--color-background); + border: 1px solid var(--color-offset); + border-radius: var(--border-radius); + overflow: hidden; + padding: var(--space-m); + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.ap-card:hover { + border-color: var(--color-offset-active); +} + +/* Boost header */ +.ap-card__boost { + color: var(--color-text-muted); + font-size: var(--font-size-small); + margin-bottom: var(--space-s); + padding-bottom: var(--space-xs); +} + +.ap-card__boost a { + color: var(--color-text-muted); + font-weight: 600; + text-decoration: none; +} + +.ap-card__boost a:hover { + color: var(--color-text); + text-decoration: underline; +} + +/* Reply context */ +.ap-card__reply-to { + color: var(--color-text-muted); + font-size: var(--font-size-small); + margin-bottom: var(--space-s); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-card__reply-to a { + color: var(--color-primary); + text-decoration: none; +} + +.ap-card__reply-to a:hover { + text-decoration: underline; +} + +/* Author header */ +.ap-card__author { + align-items: center; + display: flex; + gap: var(--space-s); + margin-bottom: var(--space-s); +} + +.ap-card__avatar { + border: 1px solid var(--color-offset); + border-radius: 50%; + flex-shrink: 0; + height: 40px; + object-fit: cover; + width: 40px; +} + +.ap-card__author-info { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +.ap-card__author-name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-card__author-name a { + color: inherit; + text-decoration: none; +} + +.ap-card__author-name a:hover { + text-decoration: underline; +} + +.ap-card__author-handle { + color: var(--color-text-muted); + font-size: var(--font-size-small); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-card__timestamp { + color: var(--color-text-muted); + flex-shrink: 0; + font-size: var(--font-size-small); +} + +/* Post title (articles) */ +.ap-card__title { + font-size: var(--font-size-heading-4); + font-weight: 600; + line-height: 1.3; + margin-bottom: var(--space-s); +} + +.ap-card__title a { + color: inherit; + text-decoration: none; +} + +.ap-card__title a:hover { + text-decoration: underline; +} + +/* Content */ +.ap-card__content { + color: var(--color-text); + line-height: 1.6; + margin-bottom: var(--space-s); + overflow-wrap: break-word; + word-break: break-word; +} + +.ap-card__content a { + color: var(--color-primary); +} + +.ap-card__content p { + margin-bottom: var(--space-xs); +} + +.ap-card__content p:last-child { + margin-bottom: 0; +} + +.ap-card__content blockquote { + border-left: 3px solid var(--color-offset); + margin: var(--space-s) 0; + padding-left: var(--space-m); +} + +.ap-card__content pre { + background: var(--color-offset); + border-radius: var(--border-radius); + overflow-x: auto; + padding: var(--space-s); +} + +.ap-card__content code { + background: var(--color-offset); + border-radius: 3px; + font-size: 0.9em; + padding: 1px 4px; +} + +.ap-card__content pre code { + background: none; + padding: 0; +} + +.ap-card__content img { + border-radius: var(--border-radius); + height: auto; + max-width: 100%; +} + +/* Content warning */ +.ap-card__cw { + margin-bottom: var(--space-s); +} + +.ap-card__cw-toggle { + background: var(--color-offset); + border: 1px solid var(--color-offset-active); + border-radius: var(--border-radius); + color: var(--color-text); + cursor: pointer; + display: block; + font-size: var(--font-size-small); + padding: var(--space-s) var(--space-m); + text-align: left; + transition: background 0.2s ease; + width: 100%; +} + +.ap-card__cw-toggle:hover { + background: var(--color-offset-active); +} + +/* Photo gallery */ +.ap-card__gallery { + border-radius: var(--border-radius); + display: grid; + gap: 2px; + margin-bottom: var(--space-s); + overflow: hidden; +} + +.ap-card__gallery-link { + display: block; + position: relative; +} + +.ap-card__gallery img { + background: var(--color-offset); + display: block; + height: 200px; + object-fit: cover; + width: 100%; +} + +.ap-card__gallery-link--more::after { + background: rgba(0, 0, 0, 0.5); + bottom: 0; + content: ""; + left: 0; + position: absolute; + right: 0; + top: 0; +} + +.ap-card__gallery-more { + color: #fff; + font-size: 1.5em; + font-weight: 600; + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + z-index: 1; +} + +/* 1 photo */ +.ap-card__gallery--1 { + grid-template-columns: 1fr; +} + +.ap-card__gallery--1 img { + height: auto; + max-height: 400px; +} + +/* 2 photos - side by side */ +.ap-card__gallery--2 { + grid-template-columns: 1fr 1fr; +} + +/* 3 photos - one large, two small */ +.ap-card__gallery--3 { + grid-template-columns: 2fr 1fr; + grid-template-rows: 1fr 1fr; +} + +.ap-card__gallery--3 img:first-child { + grid-row: 1 / 3; + height: 100%; +} + +/* 4+ photos - 2x2 grid */ +.ap-card__gallery--4 { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; +} + +/* Video embed */ +.ap-card__video { + margin-bottom: var(--space-s); +} + +.ap-card__video video { + border-radius: var(--border-radius); + max-height: 400px; + width: 100%; +} + +/* Audio player */ +.ap-card__audio { + margin-bottom: var(--space-s); +} + +.ap-card__audio audio { + width: 100%; +} + +/* Tags */ +.ap-card__tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + margin-bottom: var(--space-s); +} + +.ap-card__tag { + background: var(--color-offset); + border-radius: var(--border-radius); + color: var(--color-text-muted); + font-size: var(--font-size-small); + padding: 2px var(--space-xs); + text-decoration: none; +} + +.ap-card__tag:hover { + background: var(--color-offset-active); + color: var(--color-text); +} + +/* Interaction buttons */ +.ap-card__actions { + border-top: 1px solid var(--color-offset); + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + padding-top: var(--space-s); +} + +.ap-card__action { + align-items: center; + background: transparent; + border: 1px solid var(--color-offset); + border-radius: var(--border-radius); + color: var(--color-text-muted); + cursor: pointer; + display: inline-flex; + font-size: var(--font-size-small); + gap: var(--space-xs); + padding: var(--space-xs) var(--space-s); + text-decoration: none; + transition: all 0.2s ease; +} + +.ap-card__action:hover { + background: var(--color-offset); + border-color: var(--color-offset-active); + color: var(--color-text); +} + +/* Active interaction states */ +.ap-card__action--like.ap-card__action--active { + background: rgba(225, 29, 72, 0.1); + border-color: #e11d48; + color: #e11d48; +} + +.ap-card__action--boost.ap-card__action--active { + background: rgba(22, 163, 74, 0.1); + border-color: #16a34a; + color: #16a34a; +} + +.ap-card__action:disabled { + cursor: wait; + opacity: 0.6; +} + +/* Error message */ +.ap-card__action-error { + color: #e11d48; + font-size: var(--font-size-small); + width: 100%; +} + +/* ========================================================================== + Pagination + ========================================================================== */ + +.ap-pagination { + border-top: 1px solid var(--color-offset); + display: flex; + gap: var(--space-m); + justify-content: space-between; + margin-top: var(--space-m); + padding-top: var(--space-m); +} + +.ap-pagination a { + color: var(--color-primary); + text-decoration: none; +} + +.ap-pagination a:hover { + text-decoration: underline; +} + +/* ========================================================================== + Compose Form + ========================================================================== */ + +.ap-compose__context { + background: var(--color-offset); + border-left: 3px solid var(--color-primary); + border-radius: var(--border-radius); + margin-bottom: var(--space-m); + padding: var(--space-m); +} + +.ap-compose__context-label { + color: var(--color-text-muted); + font-size: var(--font-size-small); + margin-bottom: var(--space-xs); +} + +.ap-compose__context-author a { + font-weight: 600; + text-decoration: none; +} + +.ap-compose__context-text { + border: 0; + font-size: var(--font-size-small); + line-height: 1.5; + margin: var(--space-xs) 0; + padding: 0; +} + +.ap-compose__context-link { + color: var(--color-text-muted); + font-size: var(--font-size-small); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-compose__form { + display: flex; + flex-direction: column; + gap: var(--space-m); +} + +.ap-compose__mode { + border: 1px solid var(--color-offset); + border-radius: var(--border-radius); + display: flex; + flex-direction: column; + gap: var(--space-s); + padding: var(--space-m); +} + +.ap-compose__mode legend { + font-weight: 600; +} + +.ap-compose__mode-option { + cursor: pointer; + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.ap-compose__mode-hint { + color: var(--color-text-muted); + display: block; + font-size: var(--font-size-small); + margin-left: 1.5em; + width: 100%; +} + +.ap-compose__editor { + position: relative; +} + +.ap-compose__textarea { + border: 1px solid var(--color-offset-active); + border-radius: var(--border-radius); + font-family: inherit; + font-size: var(--font-size-body); + line-height: 1.6; + padding: var(--space-s); + resize: vertical; + width: 100%; +} + +.ap-compose__textarea:focus { + border-color: var(--color-primary); + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +.ap-compose__counter { + font-size: var(--font-size-small); + padding-top: var(--space-xs); + text-align: right; +} + +.ap-compose__counter--warn { + color: #d97706; +} + +.ap-compose__counter--over { + color: #e11d48; + font-weight: 600; +} + +.ap-compose__syndication { + border: 1px solid var(--color-offset); + border-radius: var(--border-radius); + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-m); +} + +.ap-compose__syndication legend { + font-weight: 600; +} + +.ap-compose__syndication-target { + cursor: pointer; + display: flex; + gap: var(--space-xs); +} + +.ap-compose__actions { + align-items: center; + display: flex; + gap: var(--space-m); +} + +.ap-compose__submit { + background: var(--color-primary); + border: 0; + border-radius: var(--border-radius); + color: #fff; + cursor: pointer; + font-size: var(--font-size-body); + font-weight: 600; + padding: var(--space-s) var(--space-l); +} + +.ap-compose__submit:hover { + opacity: 0.9; +} + +.ap-compose__cancel { + color: var(--color-text-muted); + text-decoration: none; +} + +.ap-compose__cancel:hover { + color: var(--color-text); + text-decoration: underline; +} + +/* ========================================================================== + Notifications + ========================================================================== */ + +.ap-notification { + align-items: flex-start; + background: var(--color-background); + border: 1px solid var(--color-offset); + border-radius: var(--border-radius); + display: flex; + gap: var(--space-s); + padding: var(--space-m); +} + +.ap-notification--unread { + border-color: rgba(255, 204, 0, 0.5); + box-shadow: 0 0 8px 0 rgba(255, 204, 0, 0.3); +} + +.ap-notification__icon { + flex-shrink: 0; + font-size: 1.5em; +} + +.ap-notification__body { + flex: 1; + min-width: 0; +} + +.ap-notification__actor { + font-weight: 600; +} + +.ap-notification__action { + color: var(--color-text-muted); +} + +.ap-notification__target { + color: var(--color-text-muted); + display: block; + font-size: var(--font-size-small); + margin-top: var(--space-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-notification__excerpt { + background: var(--color-offset); + border-radius: var(--border-radius); + font-size: var(--font-size-small); + margin-top: var(--space-xs); + padding: var(--space-xs) var(--space-s); +} + +.ap-notification__time { + color: var(--color-text-muted); + flex-shrink: 0; + font-size: var(--font-size-small); +} + +/* ========================================================================== + Remote Profile + ========================================================================== */ + +.ap-profile__header { + border-radius: var(--border-radius); + height: 200px; + margin-bottom: var(--space-m); + overflow: hidden; +} + +.ap-profile__header-img { + height: 100%; + object-fit: cover; + width: 100%; +} + +.ap-profile__info { + margin-bottom: var(--space-l); +} + +.ap-profile__avatar-wrap { + margin-bottom: var(--space-s); +} + +.ap-profile__avatar { + border: 3px solid var(--color-background); + border-radius: 50%; + height: 80px; + object-fit: cover; + width: 80px; +} + +.ap-profile__avatar--placeholder { + align-items: center; + background: var(--color-offset); + color: var(--color-text-muted); + display: flex; + font-size: 2em; + font-weight: 600; + justify-content: center; +} + +.ap-profile__name { + font-size: var(--font-size-heading-3); + margin-bottom: var(--space-xs); +} + +.ap-profile__handle { + color: var(--color-text-muted); + margin-bottom: var(--space-s); +} + +.ap-profile__bio { + line-height: 1.6; + margin-bottom: var(--space-s); +} + +.ap-profile__bio a { + color: var(--color-primary); +} + +.ap-profile__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + margin-top: var(--space-m); +} + +.ap-profile__action { + background: transparent; + border: 1px solid var(--color-offset-active); + border-radius: var(--border-radius); + color: var(--color-text); + cursor: pointer; + font-size: var(--font-size-small); + padding: var(--space-xs) var(--space-m); + text-decoration: none; +} + +.ap-profile__action:hover { + background: var(--color-offset); +} + +.ap-profile__action--follow.ap-profile__action--active { + background: var(--color-primary); + border-color: var(--color-primary); + color: #fff; +} + +.ap-profile__action--danger:hover { + border-color: #e11d48; + color: #e11d48; +} + +.ap-profile__posts { + margin-top: var(--space-l); +} + +.ap-profile__posts h3 { + border-bottom: 1px solid var(--color-offset); + font-size: var(--font-size-heading-4); + margin-bottom: var(--space-m); + padding-bottom: var(--space-s); +} + +/* ========================================================================== + Moderation + ========================================================================== */ + +.ap-moderation__section { + margin-bottom: var(--space-l); +} + +.ap-moderation__section h2 { + font-size: var(--font-size-heading-4); + margin-bottom: var(--space-s); +} + +.ap-moderation__list { + list-style: none; + margin: 0; + padding: 0; +} + +.ap-moderation__entry { + align-items: center; + border-bottom: 1px solid var(--color-offset); + display: flex; + gap: var(--space-s); + justify-content: space-between; + padding: var(--space-s) 0; +} + +.ap-moderation__entry a { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-moderation__remove { + background: transparent; + border: 1px solid var(--color-offset-active); + border-radius: var(--border-radius); + color: var(--color-text-muted); + cursor: pointer; + flex-shrink: 0; + font-size: var(--font-size-small); + padding: var(--space-xs) var(--space-s); +} + +.ap-moderation__remove:hover { + border-color: #e11d48; + color: #e11d48; +} + +.ap-moderation__add-form { + display: flex; + gap: var(--space-s); +} + +.ap-moderation__input { + border: 1px solid var(--color-offset-active); + border-radius: var(--border-radius); + flex: 1; + font-size: var(--font-size-body); + padding: var(--space-xs) var(--space-s); +} + +.ap-moderation__add-btn { + background: var(--color-offset); + border: 1px solid var(--color-offset-active); + border-radius: var(--border-radius); + cursor: pointer; + font-size: var(--font-size-body); + padding: var(--space-xs) var(--space-m); +} + +.ap-moderation__add-btn:hover { + background: var(--color-offset-active); +} + +/* ========================================================================== + Responsive + ========================================================================== */ + +@media (max-width: 640px) { + .ap-tabs { + gap: 0; + } + + .ap-tab { + padding: var(--space-xs) var(--space-s); + } + + .ap-card__gallery--3 { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + } + + .ap-card__gallery--3 img:first-child { + grid-column: 1 / 3; + grid-row: 1; + height: 200px; + } + + .ap-card__actions { + gap: var(--space-xs); + } + + .ap-card__action { + font-size: 0.75rem; + padding: var(--space-xs); + } +} diff --git a/docs/plans/2026-02-21-activitypub-reader.md b/docs/plans/2026-02-21-activitypub-reader.md new file mode 100644 index 0000000..8bde8eb --- /dev/null +++ b/docs/plans/2026-02-21-activitypub-reader.md @@ -0,0 +1,882 @@ +# ActivityPub Reader Implementation Plan + +Created: 2026-02-21 +Status: VERIFIED +Approved: Yes +Iterations: 0 +Worktree: No + +> **Status Lifecycle:** PENDING → COMPLETE → VERIFIED +> **Iterations:** Tracks implement→verify cycles (incremented by verify phase) +> +> - PENDING: Initial state, awaiting implementation +> - COMPLETE: All tasks implemented +> - VERIFIED: All checks passed +> +> **Approval Gate:** Implementation CANNOT proceed until `Approved: Yes` +> **Worktree:** Set at plan creation (from dispatcher). `Yes` uses git worktree isolation; `No` works directly on current branch + +## Summary + +**Goal:** Build a dedicated ActivityPub reader within the `@rmdes/indiekit-endpoint-activitypub` plugin, providing a timeline view of followed accounts' posts, a notifications stream, native AP interactions (like, boost, reply, follow/unfollow), and Micropub-based content creation — then remove the Microsub bridge dependency. + +**Architecture:** The reader adds new MongoDB collections (`ap_timeline`, `ap_notifications`, `ap_muted`, `ap_blocked`) alongside new controllers, views, and a CSS stylesheet. Inbox listeners are refactored to store items natively instead of bridging to Microsub. Alpine.js provides client-side reactivity for interactions. Content creation uses two paths: direct Fedify `ctx.sendActivity()` for quick likes/boosts, and Micropub POST for replies that become blog posts (user chooses per-reply). + +**Tech Stack:** Node.js/Express, MongoDB, Nunjucks templates, Alpine.js, Fedify SDK (`ctx.sendActivity()`, `ctx.lookupObject()`), Indiekit frontend components, CSS custom properties. + +## Scope + +### In Scope + +- Timeline view showing posts from followed accounts with threading, content warnings, boosts, and rich media (images, video, audio, polls) +- Tab-based filtering (All, Notes, Articles, Replies, Boosts, Media) +- Notifications stream (likes, boosts, follows, mentions, replies received) +- Native AP interactions: like, boost, reply (with choice of direct AP or Micropub), follow/unfollow +- Mute/unmute (accounts and keywords), block/unblock +- Profile view for remote actors (view posts, follow/unfollow, mute, block) +- Compose form that submits via Micropub endpoint (for blog-worthy replies) +- Custom CSS stylesheet with card-based layout inspired by Phanpy/Elk +- Content warning spoiler toggle (Alpine.js) +- Image gallery grid for multi-image posts +- Video/audio embed rendering +- Removal of Microsub bridge (`storeTimelineItem`, `getApChannelId`, lazy `microsub_items`/`microsub_channels` accessors) + +### Out of Scope + +- Mastodon REST API compatibility (no mobile client support — would be a separate project) +- Lists (organizing follows into named groups) — deferred to future plan +- Local/Federated timeline distinction (single timeline of followed accounts only) +- Full-text search within timeline items +- Polls (rendering existing polls is in scope; creating polls is not) +- Direct messages / conversations +- Push notifications (browser notifications) +- Infinite scroll (standard pagination is used) +- Video/audio upload in compose form + +## Prerequisites + +- Plugin is at v1.0.29+ with all federation hardening features complete +- Fedify SDK available via `this._federation` on the plugin instance +- MongoDB collections infrastructure in `index.js` +- Indiekit frontend components available (`@indiekit/frontend`) +- Alpine.js: **NOT loaded by Indiekit core**. The reader layout must explicitly load Alpine.js via a ``). The existing AP dashboard views use `x-data` directives — they work because the Cloudron deployment's CSP allows `cdn.jsdelivr.net` (see `nginx.conf`). The reader layout template must include Alpine.js in its `` block. +- `sanitize-html` package (add to `package.json` dependencies — used by Microsub plugin already, needed here for XSS prevention on remote content) + +## Context for Implementer + +> This section is critical for cross-session continuity. Write it for an implementer who has never seen the codebase. + +- **Patterns to follow:** + - Route registration: See `index.js:143-169` — admin routes go in `get routes()` method, registered at `/admin/activitypub/*` + - Controller pattern: Each controller exports async functions taking `(request, response)`. See `lib/controllers/dashboard.js` as example + - View pattern: Views are `activitypub-*.njk` files in `views/`. They extend `document.njk` and use Indiekit frontend component macros (`card`, `button`, `badge`, `pagination`, etc.) + - Collection registration: See `index.js:614-621` — register via `Indiekit.addCollection("name")` calls in `init()`, then store references via `this._collections.name = indiekitCollections.get("name")` + - i18n: All user-visible strings go in `locales/en.json` under the `activitypub` namespace, referenced via `__("activitypub.reader.xxx")` + - Asset serving: Place CSS/JS in `assets/` directory. Indiekit core serves at `/assets/@rmdes-indiekit-endpoint-activitypub/`. Reference from views with `` tag. + +- **Conventions:** + - ESM modules throughout (`import`/`export`) + - ISO 8601 strings for dates in MongoDB (except `published` in timeline items which uses `Date` for sorting queries) + - Nunjucks templates use `{% from "xxx.njk" import component %}` for Indiekit frontend components + - Alpine.js `x-data`, `x-show`, `x-on:click` for client-side interactivity (loaded explicitly in reader layout, NOT by Indiekit core) + - CSRF protection: Indiekit core has no CSRF middleware. POST endpoints that trigger ActivityPub activities must validate a CSRF token. Use a simple pattern: generate a token per-session and embed as a hidden field in forms / include in `fetch()` headers. Validate on the server side before processing. + +- **Key files:** + - `index.js` — Plugin entry point, routes, collections, syndicator, follow/unfollow methods + - `lib/inbox-listeners.js` — All inbox activity handlers (Follow, Like, Announce, Create, Delete, etc.) + - `lib/federation-setup.js` — Fedify federation object configuration (dispatchers, queue, etc.) + - `locales/en.json` — English translations + - `views/activitypub-dashboard.njk` — Dashboard view (reference for card-grid patterns) + - `views/activitypub-following.njk` — Following view (reference for list+pagination) + +- **Gotchas:** + - Fedify returns `Temporal.Instant` for dates, not JS `Date`. Convert with `new Date(Number(obj.published.epochMilliseconds))` + - Fedify object properties are often async getters — `await actorObj.icon` not `actorObj.icon` + - `ctx.sendActivity()` first argument is `{ identifier: handle }` where `handle` comes from plugin options + - The plugin stores `this._federation` and creates context via `this._federation.createContext(new URL(this._publicationUrl), { handle, publicationUrl })` + - Remote actor lookup uses `ctx.lookupObject("@handle@instance")` or `ctx.lookupObject("https://url")` + - The AP plugin's asset directory is `assets/` at the package root, served at `/assets/@rmdes-indiekit-endpoint-activitypub/` + +- **Domain context:** + - ActivityPub activities: `Like` (favorite), `Announce` (boost/repost), `Create` (new post), `Follow`/`Undo(Follow)`, `Accept`, `Reject`, `Delete`, `Update`, `Block`, `Move` + - Content warnings use the `summary` field on AP objects (Mastodon convention) + - Boosts are `Announce` activities wrapping the original post — the reader must render the original post with boost attribution + - Replies use `inReplyTo` linking to the parent post URL + - Sensitive content uses the `sensitive` boolean on AP objects + +## Runtime Environment + +- **Start command:** `cloudron exec --app rmendes.net` or locally `npm start` in the Cloudron container +- **Port:** Indiekit on 8080 (behind nginx on 3000) +- **Health check:** `curl https://rmendes.net/.well-known/webfinger?resource=acct:rick@rmendes.net` +- **Deploy:** Build via `cloudron build --no-cache && cloudron update --app rmendes.net --no-backup` + +## Feature Inventory — Microsub Bridge Being Replaced + +### Files Being Modified (Bridge Removal) + +| Old Code | Functions | Mapped to Task | +|----------|-----------|----------------| +| `lib/inbox-listeners.js` — function `storeTimelineItem()` (~line 468) | Timeline item storage from AP activities | Task 2 (store natively), Task 12 (remove bridge) | +| `lib/inbox-listeners.js` — function `getApChannelId()` (~line 413) | Auto-creates Microsub "Fediverse" channel | Task 12 (remove) | +| `index.js` — lazy accessors in `init()` (~line 638) | `microsub_items`, `microsub_channels` collection refs | Task 12 (remove) | +| `lib/inbox-listeners.js` — Create handler (~line 262, calls `storeTimelineItem` at ~line 310) | Stores incoming posts via bridge | Task 2 (redirect to native storage) | + +### Feature Mapping Verification + +- [x] `storeTimelineItem()` → Task 2 (native `ap_timeline` storage) +- [x] `getApChannelId()` → Task 12 (removed; no longer needed) +- [x] Lazy Microsub collection accessors → Task 12 (removed) +- [x] Inbox Create handler → Task 2 (rewired to native storage) +- [x] Like/Announce inbox storage → Task 3 (notification storage) + +## Progress Tracking + +**MANDATORY: Update this checklist as tasks complete. Change `[ ]` to `[x]`.** + +- [x] Task 1: MongoDB collections and data models +- [x] Task 2: Inbox listener refactor — native timeline storage (includes Delete/Update handling) +- [x] Task 3: Inbox listener refactor — notification storage +- [x] Task 4: Timeline controller and view +- [x] Task 5: Reader CSS stylesheet +- [x] Task 6: Notifications controller and view +- [x] Task 7a: Interaction API — Like and Boost endpoints (with CSRF) +- [x] Task 7b: Interaction UI — Like and Boost buttons (Alpine.js) +- [x] Task 8: Compose form — Micropub reply path +- [x] Task 9: Content warning toggles and rich media rendering +- [x] Task 10: Mute, block, and tab filtering +- [x] Task 11: Remote profile view +- [x] Task 12: Remove Microsub bridge +- [x] Task 13: Timeline retention cleanup + +**Total Tasks:** 14 | **Completed:** 14 | **Remaining:** 0 + +## Implementation Tasks + +### Task 1: MongoDB Collections and Data Models + +**Objective:** Register new MongoDB collections (`ap_timeline`, `ap_notifications`, `ap_muted`, `ap_blocked`, `ap_interactions`) and create indexes for efficient querying. + +**Dependencies:** None + +**Files:** + +- Modify: `index.js` — Register collections via `Indiekit.addCollection()` in `init()`, store references in `this._collections`, create indexes +- Create: `lib/storage/timeline.js` — Timeline CRUD functions +- Create: `lib/storage/notifications.js` — Notification CRUD functions +- Create: `lib/storage/moderation.js` — Mute/block CRUD functions + +**Key Decisions / Notes:** + +- `ap_timeline` schema: + ```js + { + uid: "https://remote.example/posts/123", // canonical AP object URL (dedup key) + type: "note" | "article" | "boost", // boost = Announce wrapper + url: "https://remote.example/posts/123", + name: "Post Title" | null, // Articles only + content: { text: "...", html: "..." }, + summary: "Content warning text" | null, // CW / spoiler + sensitive: false, // Mastodon sensitive flag + published: Date, // Date object for sort queries + author: { name, url, photo, handle }, // handle = "@user@instance" + category: ["tag1", "tag2"], + photo: ["url1", "url2"], + video: ["url1"], + audio: ["url1"], + inReplyTo: "https://parent-post-url" | null, + boostedBy: { name, url, photo, handle } | null, // For Announce activities + boostedAt: Date | null, // When the boost happened + originalUrl: "https://original-post-url" | null, // For boosts: the wrapped object URL + readBy: [], + createdAt: "ISO string" + } + ``` +- `ap_notifications` schema: + ```js + { + uid: "activity-id", // dedup key + type: "like" | "boost" | "follow" | "mention" | "reply", + actorUrl: "https://remote.example/@user", + actorName: "Display Name", + actorPhoto: "https://...", + actorHandle: "@user@instance", + targetUrl: "https://my-post-url" | null, // The post they liked/boosted/replied to + targetName: "My Post Title" | null, + content: { text: "...", html: "..." } | null, // For mentions/replies + published: Date, + read: false, + createdAt: "ISO string" + } + ``` +- `ap_muted`: `{ url: "actor-url", keyword: null, mutedAt: "ISO" }` — url OR keyword, not both +- `ap_blocked`: `{ url: "actor-url", blockedAt: "ISO" }` +- `ap_interactions`: `{ type: "like"|"boost", objectUrl: "https://...", activityId: "urn:uuid:...", createdAt: "ISO" }` — tracks outgoing interactions for undo support and UI state +- Indexes: + - `ap_timeline`: `{ uid: 1 }` unique, `{ published: -1 }` for timeline sort, `{ "author.url": 1 }` for profile view, `{ type: 1, published: -1 }` for tab filtering + - `ap_notifications`: `{ uid: 1 }` unique, `{ published: -1 }` for sort, `{ read: 1 }` for unread count + - `ap_muted`: `{ url: 1 }` unique (sparse), `{ keyword: 1 }` unique (sparse) + - `ap_blocked`: `{ url: 1 }` unique + - `ap_interactions`: `{ objectUrl: 1, type: 1 }` compound unique (one like/boost per object), `{ type: 1 }` for listing +- Storage functions follow the pattern in Microsub's `lib/storage/items.js` — export pure functions that take `(collections, ...)` parameters +- `addTimelineItem(collections, item)` uses atomic upsert: `updateOne({ uid }, { $setOnInsert: item }, { upsert: true })` +- `getTimelineItems(collections, { before, after, limit, type, authorUrl })` returns cursor-paginated results +- `addNotification(collections, notification)` uses atomic upsert +- `getNotifications(collections, { before, limit })` returns paginated, newest-first +- `getUnreadNotificationCount(collections)` returns count of `{ read: false }` + +**Definition of Done:** + +- [ ] All five collections registered via `Indiekit.addCollection()` in `init()` (ap_timeline, ap_notifications, ap_muted, ap_blocked, ap_interactions) +- [ ] Indexes created in `init()` method +- [ ] `addTimelineItem` stores item and deduplicates by uid +- [ ] `getTimelineItems` returns paginated results with before/after cursors +- [ ] `addNotification` stores notification and deduplicates +- [ ] `getNotifications` returns paginated newest-first +- [ ] `getUnreadNotificationCount` returns correct count +- [ ] Mute/block CRUD operations work (add, remove, list, check) +- [ ] All storage functions have unit tests + +**Verify:** + +- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/timeline.js').then(m => console.log(Object.keys(m)))"` — exports exist +- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/notifications.js').then(m => console.log(Object.keys(m)))"` — exports exist +- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/moderation.js').then(m => console.log(Object.keys(m)))"` — exports exist + +--- + +### Task 2: Inbox Listener Refactor — Native Timeline Storage + +**Objective:** Modify the inbox Create handler to store posts in `ap_timeline` instead of bridging to Microsub. Also handle Announce (boost) activities by storing the wrapped object with boost attribution. + +**Dependencies:** Task 1 + +**Files:** + +- Modify: `lib/inbox-listeners.js` — Refactor Create handler (~line 262) and Announce handler (~line 233) to store in `ap_timeline`, plus Delete/Update handlers for timeline cleanup +- Modify: `package.json` — Add `sanitize-html` to dependencies +- Create: `lib/timeline-store.js` — Helper that extracts data from Fedify objects and calls storage functions + +**Key Decisions / Notes:** + +- The existing Create handler at `inbox-listeners.js` (function `registerInboxListeners`, Create section ~line 262) currently calls `storeTimelineItem()`. Replace that call with the new native storage +- **CRITICAL — Announce handler bifurcation required:** The current Announce handler (line ~237) has an early return that ONLY processes boosts of our own content: `if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;`. This filter MUST be modified to create two code paths: + 1. **Boost of our content** (objectId starts with pubUrl) → store as notification (Task 3) + 2. **Boost from a followed account** (announcing actor is in our followers/following) → store in `ap_timeline` with `type: "boost"` + 3. **Both conditions true** (a followed account boosts our post) → store BOTH notification AND timeline item +- For timeline boosts: fetch the wrapped object via `await announce.getObject()` (the current handler only reads `announce.objectId` URL, NOT the full object), extract its data, then store with `type: "boost"` and `boostedBy` populated from the announcing actor +- To check if the announcing actor is followed: query `ap_followers` or `ap_following` collection for the actor URL +- Keep the same Fedify object→data extraction logic from `storeTimelineItem` (content, photos, videos, tags, etc.) but move it to a reusable `extractObjectData(object, actorObj)` function in `lib/timeline-store.js` +- **CRITICAL: HTML sanitization** — Remote content HTML MUST be sanitized before storage using `sanitize-html` (same library used in Microsub's `lib/webmention/verifier.js`). Allow safe tags: `a`, `p`, `br`, `em`, `strong`, `blockquote`, `ul`, `ol`, `li`, `code`, `pre`, `span`, `h1`-`h6`, `img`. Allow `href` on `a`, `src`/`alt` on `img`, `class` on `span` (for Mastodon custom emoji). Strip all other HTML including ` + + {# Reader stylesheet #} + +{% endblock %} diff --git a/views/partials/ap-item-card.njk b/views/partials/ap-item-card.njk new file mode 100644 index 0000000..898fd51 --- /dev/null +++ b/views/partials/ap-item-card.njk @@ -0,0 +1,157 @@ +{# Timeline item card partial - reusable across timeline and profile views #} + +
+ {# Boost header if this is a boosted post #} + {% if item.type == "boost" and item.boostedBy %} +
+ 🔁 {{ item.boostedBy.name }} {{ __("activitypub.reader.boosted") }} +
+ {% endif %} + + {# Reply context if this is a reply #} + {% if item.inReplyTo %} +
+ ↩ {{ __("activitypub.reader.replyingTo") }} {{ item.inReplyTo }} +
+ {% endif %} + + {# Author header #} +
+ {{ item.author.name }} +
+ +
{{ item.author.handle }}
+
+ +
+ + {# Post title (articles only) #} + {% if item.name %} +

+ {{ item.name }} +

+ {% endif %} + + {# Determine if content should be hidden behind CW #} + {% set hasCW = item.summary or item.sensitive %} + {% set cwLabel = item.summary if item.summary else __("activitypub.reader.sensitiveContent") %} + + {% if hasCW %} +
+ +
+ {% if item.content and item.content.html %} +
+ {{ item.content.html | safe }} +
+ {% endif %} + + {# Media hidden behind CW #} + {% include "partials/ap-item-media.njk" %} +
+
+ {% else %} + {# Regular content (no CW) #} + {% if item.content and item.content.html %} +
+ {{ item.content.html | safe }} +
+ {% endif %} + + {# Media visible directly #} + {% include "partials/ap-item-media.njk" %} + {% endif %} + + {# Tags/categories #} + {% if item.category and item.category.length > 0 %} +
+ {% for tag in item.category %} + #{{ tag }} + {% endfor %} +
+ {% endif %} + + {# Interaction buttons — Alpine.js for optimistic updates #} + {# Dynamic data moved to data-* attributes to prevent XSS from inline interpolation #} + {% set itemUrl = item.url or item.originalUrl %} + {% set isLiked = interactionMap[itemUrl].like if interactionMap[itemUrl] else false %} + {% set isBoosted = interactionMap[itemUrl].boost if interactionMap[itemUrl] else false %} + +
diff --git a/views/partials/ap-item-media.njk b/views/partials/ap-item-media.njk new file mode 100644 index 0000000..c4a2585 --- /dev/null +++ b/views/partials/ap-item-media.njk @@ -0,0 +1,37 @@ +{# Media attachments partial — included from ap-item-card.njk #} + +{# Photo gallery #} +{% if item.photo and item.photo.length > 0 %} + {% set displayCount = [item.photo.length, 4] | min %} + {% set extraCount = item.photo.length - 4 %} + +{% endif %} + +{# Video embed #} +{% if item.video and item.video.length > 0 %} +
+ +
+{% endif %} + +{# Audio player #} +{% if item.audio and item.audio.length > 0 %} +
+ +
+{% endif %} diff --git a/views/partials/ap-notification-card.njk b/views/partials/ap-notification-card.njk new file mode 100644 index 0000000..b10c32f --- /dev/null +++ b/views/partials/ap-notification-card.njk @@ -0,0 +1,58 @@ +{# Notification card partial #} + +
+ {# Type icon #} +
+ {% if item.type == "like" %} + ❤ + {% elif item.type == "boost" %} + 🔁 + {% elif item.type == "follow" %} + 👤 + {% elif item.type == "reply" %} + 💬 + {% elif item.type == "mention" %} + @ + {% endif %} +
+ + {# Notification body #} +
+ + {{ item.actorName }} + + + + {% if item.type == "like" %} + {{ __("activitypub.notifications.liked") }} + {% elif item.type == "boost" %} + {{ __("activitypub.notifications.boostedPost") }} + {% elif item.type == "follow" %} + {{ __("activitypub.notifications.followedYou") }} + {% elif item.type == "reply" %} + {{ __("activitypub.notifications.repliedTo") }} + {% elif item.type == "mention" %} + {{ __("activitypub.notifications.mentionedYou") }} + {% endif %} + + + {% if item.targetUrl %} + + {{ item.targetName or item.targetUrl }} + + {% endif %} + + {% if item.content and item.content.text %} +
+ {{ item.content.text | truncate(200) }} +
+ {% endif %} +
+ + {# Timestamp #} + {% if item.published %} + + {% endif %} +