Changeset b7d4458 in Klonkt


Ignore:
Timestamp:
06/27/2026 08:10:43 AM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
c21197a
Parents:
92f6976
Message:

feat(nsfw): custom content-warning text + blur in the Cirkel

  • Per-post content-warning text (like Mastodon): editor field; used as the on-site veil/banner label and the fediverse Note summary (falls back to 'Gevoelige inhoud').
  • Cirkel feed now blurs remote sensitive posts: ap_timeline gets nsfw+cw (captured from the incoming Note's sensitive/summary), getCirkelPosts returns them, circle.js maps them so post-card/tile show the veil.
Location:
src
Files:
11 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r92f6976 rb7d4458  
    8585  ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0');  // fan-only preview (premium #3)
    8686  ensureColumn('posts', 'nsfw',     'INTEGER DEFAULT 0');  // sensitive content → blur + click-to-reveal; fediverse sensitive
     87  ensureColumn('posts', 'content_warning', 'TEXT');        // custom CW label (empty = default "Gevoelige inhoud")
    8788  ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
    8889
     
    416417  ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
    417418  ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (⭐) → toggle
     419  ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0');  // remote sensitive post → blur in the Cirkel
     420  ensureColumn('ap_timeline', 'cw', 'TEXT');                 // remote content-warning text
    418421}
    419422
  • src/routes/activitypub.js

    r92f6976 rb7d4458  
    6767  if (!site) return res.status(404).end();
    6868  const posts = db.prepare(
    69     `SELECT id, slug, title, content, cover_image_url, nsfw, published_at, created_at
     69    `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
    7070     FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
    7171     ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
     
    9090  // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
    9191  const posts = db.prepare(
    92     `SELECT id, slug, title, content, cover_image_url, nsfw, published_at, created_at
     92    `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
    9393     FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
    9494       AND pinned IS NOT NULL AND pinned > 0
  • src/routes/circle.js

    r92f6976 rb7d4458  
    5353      pinned: 0,
    5454      isBoost: !!r.boosted, // a post YOU boosted → render in the pinned style with a Boost badge
     55      nsfw: r.nsfw ? 1 : 0, // remote sensitive post → blur in the Cirkel (post-card/tile)
     56      content_warning: r.cw || '',
    5557      status: 'published',
    5658      source_name: name,
  • src/routes/posts.js

    r92f6976 rb7d4458  
    177177  const fanOnly = req.body.fan_only ? 1 : 0;
    178178  const nsfw = req.body.nsfw ? 1 : 0;
     179  const cw = (req.body.content_warning || '').trim().slice(0, 200);
    179180
    180181  // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
     
    214215    INSERT INTO posts (
    215216      id, site_id, slug, author_id, title, content, excerpt,
    216       status, cover_image_url, pinned, tags, type, noindex, fan_only, nsfw, publish_at,
     217      status, cover_image_url, pinned, tags, type, noindex, fan_only, nsfw, content_warning, publish_at,
    217218      created_at, updated_at, published_at
    218     ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     219    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    219220  `).run(
    220221    postId, site.id, finalSlug, req.session.user.id,
     
    222223    finalStatus, cover_image_url || null, parsePinnedRank(pinned),
    223224    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    224     finalType, noindex ? 1 : 0, fanOnly, nsfw, publishAt,
     225    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, publishAt,
    225226    now, now, publishedAt
    226227  );
     
    239240        id: postId, slug: finalSlug, title: title || finalSlug,
    240241        content: cleanContent, cover_image_url: cover_image_url || null,
    241         published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw,
     242        published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw,
    242243      }).catch(() => { /* best-effort */ });
    243244    }
     
    298299  const fanOnly = req.body.fan_only ? 1 : 0;
    299300  const nsfw = req.body.nsfw ? 1 : 0;
     301  const cw = (req.body.content_warning || '').trim().slice(0, 200);
    300302  const newSlug = req.body.slug;
    301303  const action = req.body.action || 'save';
     
    336338      title = ?, content = ?, excerpt = ?, status = ?,
    337339      cover_image_url = ?, pinned = ?, tags = ?,
    338       type = ?, noindex = ?, fan_only = ?, nsfw = ?, publish_at = ?,
     340      type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, publish_at = ?,
    339341      slug = ?, published_at = ?, updated_at = ?
    340342    WHERE id = ?
     
    343345    cover_image_url || null, parsePinnedRank(pinned),
    344346    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    345     finalType, noindex ? 1 : 0, fanOnly, nsfw, publishAt,
     347    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, publishAt,
    346348    finalSlug, publishedAt, now, post.id
    347349  );
     
    364366      id: post.id, slug: finalSlug, title: title || finalSlug,
    365367      content: cleanContent, cover_image_url: cover_image_url || null,
    366       published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw,
     368      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw,
    367369    };
    368370    if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
  • src/services/ActivityPubService.js

    r92f6976 rb7d4458  
    250250    sensitive: !!post.nsfw,
    251251  };
    252   if (post.nsfw) note.summary = 'Gevoelige inhoud';
     252  if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
    253253  if (attachment.length) note.attachment = attachment;
    254254  // Playable-audio posts suppress the cover attachment (player card). Still expose
     
    708708        // the timeline).
    709709        for (const s of subs) {
    710           tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media);
     710          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media, o.sensitive ? 1 : 0, o.summary || null);
    711711        }
    712712        console.log('[AP] timeline +', actorUri, 'x' + subs.length);
     
    775775  if (!site) return;
    776776  const recent = db.prepare(
    777     `SELECT id, slug, title, content, cover_image_url, nsfw, published_at, created_at
     777    `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
    778778     FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
    779779     ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
     
    10111011    url: note.url || url,
    10121012    content: HtmlSanitizerService.sanitize(rawHtml),       // full, sanitized
     1013    sensitive: !!note.sensitive,                            // remote CW → blur in the Cirkel
     1014    cw: note.summary || '',
    10131015    images,
    10141016    threadInboxes,                                          // every ancestor author's inbox
     
    10831085function tlStmts() {
    10841086  if (!_insTl) {
    1085     _insTl = db.prepare('INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, author_handle, author_icon, author_url, content, url, published, media_json, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
     1087    _insTl = db.prepare('INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, author_handle, author_icon, author_url, content, url, published, media_json, nsfw, cw, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
    10861088    _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
    10871089    _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
     
    11021104    if (!_cirkelPosts) _cirkelPosts = db.prepare(`
    11031105      SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
    1104              t.content, t.url, t.published, t.media_json, t.boosted
     1106             t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
    11051107      FROM ap_timeline t
    11061108      LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
     
    11421144    tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
    11431145      note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
    1144       new Date().toISOString(), media);
     1146      new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
    11451147  } catch { /* ignore */ }
    11461148  markBoosted(slug, id);
  • src/services/Scheduler.js

    r92f6976 rb7d4458  
    1515  try {
    1616    const due = db.prepare(`
    17       SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.fan_only, p.nsfw,
     17      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.fan_only, p.nsfw, p.content_warning,
    1818             p.published_at, p.publish_at, p.created_at, u.username
    1919      FROM posts p JOIN users u ON u.id = p.author_id
     
    3939            id: p.id, slug: p.slug, title: p.title || p.slug,
    4040            content: p.content, cover_image_url: p.cover_image_url || null,
    41             published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw,
     41            published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw, content_warning: p.content_warning,
    4242          }).catch(() => { /* best-effort */ });
    4343        }
  • src/services/i18n.js

    r92f6976 rb7d4458  
    771771    'pedit.pin_top': 'bovenaan',
    772772    'pedit.pin_nth_suffix': 'e van boven',
    773     'pedit.noindex_label': 'noindex (verberg voor zoekmachines)', 'pedit.nsfw_label': 'NSFW / gevoelige inhoud', 'post.nsfw_warning': 'Gevoelige inhoud', 'post.nsfw_show': 'Tonen',
     773    'pedit.noindex_label': 'noindex (verberg voor zoekmachines)', 'pedit.nsfw_label': 'NSFW / gevoelige inhoud', 'pedit.nsfw_cw_ph': 'Waarschuwingstekst (optioneel, standaard: Gevoelige inhoud)', 'post.nsfw_warning': 'Gevoelige inhoud', 'post.nsfw_show': 'Tonen',
    774774    'pedit.fan_only_label': 'Alleen voor ingelogde fans',
    775775    'pedit.schedule_label': 'Publicatie inplannen',
     
    17821782    'pedit.pin_top': 'at the top',
    17831783    'pedit.pin_nth_suffix': 'th from top',
    1784     'pedit.noindex_label': 'noindex (hide from search engines)', 'pedit.nsfw_label': 'NSFW / sensitive content', 'post.nsfw_warning': 'Sensitive content', 'post.nsfw_show': 'Show',
     1784    'pedit.noindex_label': 'noindex (hide from search engines)', 'pedit.nsfw_label': 'NSFW / sensitive content', 'pedit.nsfw_cw_ph': 'Warning text (optional, default: Sensitive content)', 'post.nsfw_warning': 'Sensitive content', 'post.nsfw_show': 'Show',
    17851785    'pedit.fan_only_label': 'Logged-in fans only',
    17861786    'pedit.schedule_label': 'Schedule publication',
     
    27932793    'pedit.pin_top': 'ganz oben',
    27942794    'pedit.pin_nth_suffix': '. von oben',
    2795     'pedit.noindex_label': 'noindex (vor Suchmaschinen verbergen)', 'pedit.nsfw_label': 'NSFW / sensibler Inhalt', 'post.nsfw_warning': 'Sensibler Inhalt', 'post.nsfw_show': 'Anzeigen',
     2795    'pedit.noindex_label': 'noindex (vor Suchmaschinen verbergen)', 'pedit.nsfw_label': 'NSFW / sensibler Inhalt', 'pedit.nsfw_cw_ph': 'Warntext (optional, Standard: Sensibler Inhalt)', 'post.nsfw_warning': 'Sensibler Inhalt', 'post.nsfw_show': 'Anzeigen',
    27962796    'pedit.fan_only_label': 'Nur für angemeldete Fans',
    27972797    'pedit.schedule_label': 'Veröffentlichung planen',
  • src/views/pages/post-edit.ejs

    r92f6976 rb7d4458  
    242242          <span>🔞 <%= t('pedit.nsfw_label') %></span>
    243243        </label>
     244        <input type="text" name="content_warning" value="<%= post.content_warning || '' %>" maxlength="200"
     245               placeholder="<%= t('pedit.nsfw_cw_ph') %>"
     246               style="width:100%;box-sizing:border-box;margin:6px 0 2px;font-size:13px;padding:7px 9px">
    244247        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    245248          <label class="pe-checkbox">
  • src/views/pages/post.ejs

    r92f6976 rb7d4458  
    1313    <div class="nsfw-banner">
    1414      <span class="nsfw-banner-icon">🔞</span>
    15       <span class="nsfw-banner-text"><%= t('post.nsfw_warning') %></span>
     15      <span class="nsfw-banner-text"><%= post.content_warning || t('post.nsfw_warning') %></span>
    1616      <button type="button" class="nsfw-banner-btn nsfw-reveal"><%= t('post.nsfw_show') %></button>
    1717    </div>
  • src/views/partials/post-card.ejs

    r92f6976 rb7d4458  
    5050       aria-label="<%= post.title || '(zonder titel)' %>" tabindex="-1">
    5151      <img src="<%= post.cover_image_url %>" alt="" loading="lazy" decoding="async">
    52       <% if (post.nsfw) { %><span class="nsfw-veil"><span class="nsfw-veil-label">🔞 <%= t('post.nsfw_warning') %></span><span class="nsfw-veil-btn"><%= t('post.nsfw_show') %></span></span><% } %>
     52      <% if (post.nsfw) { %><span class="nsfw-veil"><span class="nsfw-veil-label">🔞 <%= post.content_warning || t('post.nsfw_warning') %></span><span class="nsfw-veil-btn"><%= t('post.nsfw_show') %></span></span><% } %>
    5353    </a>
    5454  <% } %>
  • src/views/partials/post-tile.ejs

    r92f6976 rb7d4458  
    3737    <% if (_src) { %><span class="grid-tile-gradient-src">van <%= _src %></span><% } %>
    3838  <% } %>
    39   <% if (post.nsfw) { %><div class="nsfw-veil"><span class="nsfw-veil-label">🔞 <%= t('post.nsfw_warning') %></span><span class="nsfw-veil-btn"><%= t('post.nsfw_show') %></span></div><% } %>
     39  <% if (post.nsfw) { %><div class="nsfw-veil"><span class="nsfw-veil-label">🔞 <%= post.content_warning || t('post.nsfw_warning') %></span><span class="nsfw-veil-btn"><%= t('post.nsfw_show') %></span></div><% } %>
    4040</a>
Note: See TracChangeset for help on using the changeset viewer.