source: Klonkt/src/routes/circle.js@ 462a931

main
Last change on this file since 462a931 was 462a931, checked in by roboburr <roboburr@…>, 2 months ago

feat(circle): filter the Circle feed by member (clickable chips + Following "Show" link)

The member chips on the Circle page are now clickable filters: clicking one shows only that
account's posts, with an "Everyone" chip to clear and an active highlight. The Following page
gets a "Show" link per featured account that lands on the filtered Circle view. So Featured =
"include in my Circle", and Show lets you view just that one person here.

  • src/services/ActivityPubService.js — getCirkelPosts(slug, limit, actorUri) filter; members expose actor_uri
  • src/routes/circle.js — honour ?actor=<uri> (validated against the member list) + active flags
  • src/views/pages/circle-feed.ejs — chips + modal as filter links + Everyone + active state
  • src/views/pages/following.ejs — Show link for featured accounts
  • src/services/i18n.js — cfeed.all, tl.show (nl/en/de)

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

  • Property mode set to 100644
File size: 3.4 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}
21function mediaImage(media_json) {
22 const media = safeJson(media_json).map((m) => ({ ...m, url: safeUrl(m.url) })).filter((m) => m.url);
23 return media.find((m) => /image/i.test(m.type || '')) || media[0] || null;
24}
25function htmlToText(html) {
26 return String(html || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
27}
28
29router.get('/cirkel', (req, res, next) => {
30 const site = res.locals.site;
31 if (!site || !apEnabled() || (ActivityPubService.autoBoostCount(site.slug) === 0 && ActivityPubService.boostedCount(site.slug) === 0)) return next();
32
33 const members = ActivityPubService.getCirkelMembers(site.slug);
34 // Optional ?actor=<uri> → show only that member's posts. Only honour a uri that is
35 // actually a featured member (so it can't be used to probe arbitrary timeline rows).
36 const reqActor = safeUrl(req.query.actor);
37 const activeMember = reqActor ? members.find((m) => m.actor_uri === reqActor) : null;
38 const activeActor = activeMember ? activeMember.actor_uri : null;
39
40 const posts = ActivityPubService.getCirkelPosts(site.slug, 80, activeActor).map((r) => {
41 const text = htmlToText(r.content);
42 // Show ONLY the title (the bold first line a Klonkt note carries), not the whole
43 // body. Title-less notes (e.g. plain Mastodon) fall back to a short text snippet.
44 const titleM = (r.content || '').match(/^\s*<p>\s*<strong>([\s\S]*?)<\/strong>/i);
45 const realTitle = titleM ? htmlToText(titleM[1]).trim() : '';
46 const image = mediaImage(r.media_json);
47 const name = r.author_name || r.author_handle || 'Onbekend';
48 return {
49 id: 'ap-' + r.id,
50 slug: '',
51 title: realTitle
52 ? (realTitle.length > 90 ? realTitle.slice(0, 90) + '…' : realTitle)
53 : (text ? (text.length > 90 ? text.slice(0, 90) + '…' : text) : name),
54 excerpt: '',
55 cover_image_url: image ? image.url : null,
56 published_at: r.published,
57 created_at: r.published,
58 type: 'post',
59 tags: '',
60 pinned: 0,
61 isBoost: !!r.boosted, // a post YOU boosted → render in the pinned style with a Boost badge
62 nsfw: r.nsfw ? 1 : 0, // remote sensitive post → blur in the Cirkel (post-card/tile)
63 content_warning: r.cw || '',
64 status: 'published',
65 source_name: name,
66 external_url: safeUrl(r.url),
67 };
68 });
69
70 const sites = members.map((s) => ({
71 name: s.name || 'Onbekend', url: safeUrl(s.url), avatar: safeUrl(s.icon),
72 actor_uri: s.actor_uri, active: !!(activeActor && s.actor_uri === activeActor),
73 }));
74
75 renderPage(req, res, 'pages/circle-feed', {
76 pageTitle: 'Cirkel', bodyClass: 'on-cirkel', posts, sites,
77 activeActor, activeName: activeMember ? (activeMember.name || '') : '',
78 });
79});
80
81export default router;
Note: See TracBrowser for help on using the repository browser.