source: Klonkt/src/routes/circle.js@ 520e477

main
Last change on this file since 520e477 was 520e477, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: Load more on Solo + Cirkel, page 72 (klonkt-demo-r9u, slice 3/4)

The home feed (Solo) and the Cirkel feed now page in 72s instead of a
hard 30/80 cap. Both render two containers (list + grid, CSS-toggled),
so the append fragment (partials/home-append) sends the post-cards as
the primary beforeend swap into #post-list and OOB-appends the same
posts as tiles into #grid-tiles. htmx 1.9.12 unwraps the OOB wrapper's
children on a positional swap, so the tiles land as direct grid items
(the display:contents wrapper is a belt-and-braces fallback). The
button lives once below both views and OOB-replaces itself with the
next offset, dropping on the last page. Pinned posts stay on page 1
only. getCirkelPosts gains an offset arg; FEED_PAGE hoisted to the top
of posts.js.

Browser-verified both feeds: 72 -> 144 -> 150 in list AND grid, then
the button disappears.

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

  • Property mode set to 100644
File size: 4.2 KB
Line 
1/**
2 * Circle feed — the artists this site features (auto-boosts), sourced from
3 * ActivityPub. Cards link to the source post. Available whenever the site
4 * auto-boosts at least one account; otherwise next() -> postsRoutes.
5 * GET /cirkel
6 */
7
8import express from 'express';
9import { renderPage } from '../middleware/render.js';
10import { apEnabled } from '../services/SettingsService.js';
11import ActivityPubService from '../services/ActivityPubService.js';
12
13const router = express.Router();
14
15function safeUrl(u) {
16 return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null;
17}
18function safeJson(s) {
19 try { return s ? JSON.parse(s) : []; } catch { return []; }
20}
21// The cover image + (separately) a cover video from a remote note's media. NEVER use a video/audio
22// item as the cover image — that produced a broken <img> for an animated cover that federated as an
23// MP4 (the video becomes a <video> instead).
24function coverMedia(media_json) {
25 const media = safeJson(media_json).map((m) => ({ ...m, url: safeUrl(m.url) })).filter((m) => m.url);
26 const video = media.find((m) => /video/i.test(m.type || '')) || null;
27 const image = media.find((m) => /image/i.test(m.type || ''))
28 || (media[0] && !/(video|audio)/i.test(media[0].type || '') ? media[0] : null);
29 return { image, video };
30}
31function htmlToText(html) {
32 return String(html || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
33}
34// Tidy a plain-text snippet for use as a card title: drop a leading "RE: <url>" (the
35// quote/reply prefix Misskey/Akkoma and some reply federation prepend) and any other
36// leading bare URL, so the title shows the actual prose, not link noise.
37function tidySnippet(text) {
38 return String(text || '')
39 .replace(/^RE:\s*https?:\/\/\S+\s*/i, '')
40 .replace(/^https?:\/\/\S+\s*/i, '')
41 .trim();
42}
43
44const CIRKEL_PAGE = 72; // matches FEED_PAGE in posts.js: divisible by 2/3/4
45
46router.get('/cirkel', (req, res, next) => {
47 const site = res.locals.site;
48 if (!site || !apEnabled() || (ActivityPubService.autoBoostCount(site.slug) === 0 && ActivityPubService.boostedCount(site.slug) === 0)) return next();
49
50 const append = req.query.append === '1';
51 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
52 const rows = ActivityPubService.getCirkelPosts(site.slug, CIRKEL_PAGE + 1, offset);
53 const hasMore = rows.length > CIRKEL_PAGE;
54 const posts = rows.slice(0, CIRKEL_PAGE).map((r) => {
55 const text = tidySnippet(htmlToText(r.content));
56 // Show ONLY the title (the bold first line a Klonkt note carries), not the whole
57 // body. Title-less notes (e.g. plain Mastodon) fall back to a short text snippet.
58 const titleM = (r.content || '').match(/^\s*<p>\s*<strong>([\s\S]*?)<\/strong>/i);
59 const realTitle = titleM ? htmlToText(titleM[1]).trim() : '';
60 const cover = coverMedia(r.media_json);
61 const name = r.author_name || r.author_handle || 'Onbekend';
62 return {
63 id: 'ap-' + r.id,
64 slug: '',
65 title: realTitle
66 ? (realTitle.length > 90 ? realTitle.slice(0, 90) + '…' : realTitle)
67 : (text ? (text.length > 90 ? text.slice(0, 90) + '…' : text) : name),
68 excerpt: '',
69 cover_image_url: cover.image ? cover.image.url : null,
70 cover_video_url: cover.video ? cover.video.url : null,
71 published_at: r.published,
72 created_at: r.published,
73 type: 'post',
74 tags: '',
75 pinned: 0,
76 isBoost: !!r.boosted, // a post YOU boosted → render in the pinned style with a Boost badge
77 nsfw: r.nsfw ? 1 : 0, // remote sensitive post → blur in the Cirkel (post-card/tile)
78 content_warning: r.cw || '',
79 status: 'published',
80 source_name: name,
81 external_url: safeUrl(r.url),
82 };
83 });
84
85 const moreBase = res.locals.siteUrlBase || '';
86 if (append) {
87 return renderPage(req, res, 'partials/home-append', { posts, hasMore, nextOffset: offset + CIRKEL_PAGE, moreBase, morePath: '/cirkel' });
88 }
89
90 const sites = ActivityPubService.getCirkelMembers(site.slug)
91 .map((s) => ({ name: s.name || 'Onbekend', url: safeUrl(s.url), avatar: safeUrl(s.icon) }));
92
93 renderPage(req, res, 'pages/circle-feed', {
94 pageTitle: 'Cirkel', bodyClass: 'on-cirkel', posts, sites,
95 hasMore, nextOffset: offset + CIRKEL_PAGE, moreBase,
96 });
97});
98
99export default router;
Note: See TracBrowser for help on using the repository browser.