Changeset 2c22bb5 in Klonkt


Ignore:
Timestamp:
06/26/2026 08:53:42 AM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
46f3dd6
Parents:
49b1b39
Message:

feat(cirkel): rebuild the Cirkel feed on ActivityPub (featured/auto-boosted artists)

Phase 2 of the Cirkels-on-AP rework. /cirkel now shows posts from the accounts a
site auto-boosts ("feature an artist"), sourced from ap_timeline ⋈ ap_following
(auto_boost=1); cards link to the source post. The Solo|Cirkel switcher shows
whenever the site has a cirkel (new hasCirkel local) instead of only on
circle-tenancy. Legacy circle-tenancy remote_posts are merged in until the old
pull-protocol is removed (Phase 4). New: autoBoostCount / getCirkelPosts /
getCirkelMembers.

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • src/middleware/render.js

    r49b1b39 r2c22bb5  
    123123    canManageFedi,
    124124    apEnabled: apEnabled(),
     125    // Cirkel = the artists you feature (auto-boost). Shown when AP is on and you
     126    // auto-boost ≥1 account, or (legacy) on a circle-tenancy site.
     127    hasCirkel: !!(_site && ((apEnabled() && ActivityPubService.autoBoostCount(_site.slug) > 0) || (res.locals.tenancy || 'solo') === 'circle')),
    125128    isViewer: _isViewer,
    126129    canMutate: !_isViewer,
  • src/routes/circle.js

    r49b1b39 r2c22bb5  
    1010import { renderPage } from '../middleware/render.js';
    1111import db from '../config/database.js';
    12 import { getTenancy } from '../services/SettingsService.js';
     12import { getTenancy, apEnabled } from '../services/SettingsService.js';
     13import ActivityPubService from '../services/ActivityPubService.js';
    1314
    1415const router = express.Router();
     16
     17function htmlToText(html) {
     18  return String(html || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
     19}
    1520
    1621function safeUrl(u) {
     
    2631
    2732// ── Overview ─────────────────────────────────────────────────
     33// New model: the Cirkel = posts from the accounts this site auto-boosts
     34// ("feature an artist"), sourced from ActivityPub. Cards link to the source
     35// post (external_url). Legacy circle-tenancy remote_posts are merged in until
     36// the old pull-protocol is removed (Phase 4).
    2837router.get('/cirkel', (req, res, next) => {
    29   if (getTenancy() !== 'circle') return next();
     38  const site = res.locals.site;
     39  if (!site) return next();
     40  const slug = site.slug;
     41  const isCircle = getTenancy() === 'circle';
     42  const abCount = apEnabled() ? ActivityPubService.autoBoostCount(slug) : 0;
     43  if (!abCount && !isCircle) return next(); // no cirkel on this site
    3044
    31   const rows = db.prepare(`
    32     SELECT p.id, p.published, p.title, p.summary, p.media_json, p.tags,
    33            a.name AS actor_name
    34     FROM remote_posts p
    35     JOIN remote_actors a ON a.id = p.actor_id
    36     ORDER BY COALESCE(p.published, p.fetched_at) DESC
    37     LIMIT 100
    38   `).all();
     45  let posts = [];
    3946
    40   const posts = rows.map((r) => {
    41     const image = mediaImage(r.media_json);
    42     return {
    43       id: r.id,
    44       // Local reading page -> the card stays on the own site (post-card links
    45       // locally + htmx, NO external_url).
    46       slug: 'cirkel/' + encodeURIComponent(r.id),
    47       title: r.title || '(zonder titel)',
    48       excerpt: r.summary || '',
    49       cover_image_url: image ? image.url : null,
    50       published_at: r.published,
    51       created_at: r.published,
    52       type: 'post',
    53       tags: r.tags || '',
    54       pinned: 0,
    55       status: 'published',
    56       source_name: r.actor_name || 'Onbekend',
    57     };
    58   });
     47  // Featured (auto-boosted) fediverse posts → link to the original.
     48  if (abCount) {
     49    posts = ActivityPubService.getCirkelPosts(slug, 80).map((r) => {
     50      const text = htmlToText(r.content);
     51      const image = mediaImage(r.media_json);
     52      const name = r.author_name || r.author_handle || 'Onbekend';
     53      return {
     54        id: 'ap-' + r.id,
     55        slug: '',
     56        title: text ? (text.length > 90 ? text.slice(0, 90) + '…' : text) : name,
     57        excerpt: '',
     58        cover_image_url: image ? image.url : null,
     59        published_at: r.published,
     60        created_at: r.published,
     61        type: 'post',
     62        tags: '',
     63        pinned: 0,
     64        status: 'published',
     65        source_name: name,
     66        external_url: safeUrl(r.url),
     67      };
     68    });
     69  }
    5970
    60   // Sites in the circle — active links only (outdated/error ones are excluded, along
    61   // with their posts). Used for the graphic header with avatars.
    62   const sites = db.prepare(`
    63     SELECT a.name, a.url, a.avatar
    64     FROM remote_actors a
    65     JOIN circle_links l ON l.remote_actor_id = a.id
    66     WHERE l.status = 'active'
    67     ORDER BY a.name
    68   `).all()
    69     .map((s) => ({
    70       name: s.name || 'Onbekend',
    71       url: safeUrl(s.url),
    72       avatar: safeUrl(s.avatar),
    73     }));
     71  // Legacy circle-tenancy remote_posts (local reading page, no external_url).
     72  if (isCircle) {
     73    const rows = db.prepare(`
     74      SELECT p.id, p.published, p.title, p.summary, p.media_json, p.tags, a.name AS actor_name
     75      FROM remote_posts p JOIN remote_actors a ON a.id = p.actor_id
     76      ORDER BY COALESCE(p.published, p.fetched_at) DESC LIMIT 100
     77    `).all().map((r) => {
     78      const image = mediaImage(r.media_json);
     79      return {
     80        id: r.id, slug: 'cirkel/' + encodeURIComponent(r.id),
     81        title: r.title || '(zonder titel)', excerpt: r.summary || '',
     82        cover_image_url: image ? image.url : null, published_at: r.published, created_at: r.published,
     83        type: 'post', tags: r.tags || '', pinned: 0, status: 'published', source_name: r.actor_name || 'Onbekend',
     84      };
     85    });
     86    posts = posts.concat(rows);
     87  }
     88
     89  posts.sort((a, b) => String(b.published_at || '').localeCompare(String(a.published_at || '')));
     90
     91  // Members header (avatars): featured artists + legacy circle links.
     92  let sites = abCount
     93    ? ActivityPubService.getCirkelMembers(slug).map((s) => ({ name: s.name || 'Onbekend', url: safeUrl(s.url), avatar: safeUrl(s.icon) }))
     94    : [];
     95  if (isCircle) {
     96    const old = db.prepare(`
     97      SELECT a.name, a.url, a.avatar FROM remote_actors a
     98      JOIN circle_links l ON l.remote_actor_id = a.id WHERE l.status = 'active' ORDER BY a.name
     99    `).all().map((s) => ({ name: s.name || 'Onbekend', url: safeUrl(s.url), avatar: safeUrl(s.avatar) }));
     100    sites = sites.concat(old);
     101  }
    74102
    75103  renderPage(req, res, 'pages/circle-feed', { pageTitle: 'Cirkel', bodyClass: 'on-cirkel', posts, sites });
  • src/services/ActivityPubService.js

    r49b1b39 r2c22bb5  
    10321032export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
    10331033
     1034// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
     1035let _abCount, _cirkelPosts, _cirkelMembers;
     1036export function autoBoostCount(slug) {
     1037  try { if (!_abCount) _abCount = db.prepare('SELECT COUNT(*) AS n FROM ap_following WHERE slug = ? AND auto_boost = 1'); return _abCount.get(slug).n; } catch { return 0; }
     1038}
     1039export function getCirkelPosts(slug, limit) {
     1040  try {
     1041    if (!_cirkelPosts) _cirkelPosts = db.prepare(`
     1042      SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
     1043             t.content, t.url, t.published, t.media_json
     1044      FROM ap_timeline t
     1045      JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri AND f.auto_boost = 1
     1046      WHERE t.slug = ?
     1047      ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
     1048      LIMIT ?`);
     1049    return _cirkelPosts.all(slug, limit || 60);
     1050  } catch { return []; }
     1051}
     1052export function getCirkelMembers(slug) {
     1053  try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
     1054}
     1055
    10341056// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
    10351057export async function followActor(site, handle, autoBoost = false) {
     
    11901212  listOutbox, deliverOutboxDelete,
    11911213  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, getTimeline, sendInteraction,
     1214  autoBoostCount, getCirkelPosts, getCirkelMembers,
    11921215  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    11931216  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
  • src/views/partials/view-switcher.ejs

    r49b1b39 r2c22bb5  
    2323    </div>
    2424    <% } %>
    25     <% if (typeof tenancy !== 'undefined' && tenancy === 'circle') {
     25    <% if (typeof hasCirkel !== 'undefined' && hasCirkel) {
    2626         var _onCirkel = (typeof bodyClass === 'string' && bodyClass.indexOf('on-cirkel') >= 0);
    2727         var _sb = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
Note: See TracChangeset for help on using the changeset viewer.