Changeset d9ad6c5 in Klonkt


Ignore:
Timestamp:
07/28/2026 06:17:36 PM (6 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
a7bcf66
Parents:
2708282
Message:

Een hulpvraag hoort in Berichten, niet in de Krant

Een 🛟 van een ward kwam bij de guardian op twee plekken binnen: als mention in
Berichten en de Guardian PWA, maar ook als gewone post in de Krant. Op
sound-fabrics.com stonden vijf van de zes hulpvragen in allebei.

De oorzaak zat in de inbox: de tijdlijn-insert vroeg alleen "is dit een
top-level post van iemand die ik volg" en keek niet naar wie de post geadresseerd
was. Dat is nu belongsInTimeline: een directe note is aan iemand persoonlijk
gericht, dus een bericht en geen post. Dat dekt meteen de wave en de gewone DM,
die om dezelfde reden in de Krant terechtkwamen. De self-heal ruimt de al
opgeslagen exemplaren op, beperkt tot de twee soorten die achteraf nog te
herkennen zijn; een publieke mention van iemand die je volgt is wel een post en
blijft staan.

Tweede helft: de weergave gelijkgetrokken. De Krant rendert een post met
emoji's, een quote- of linkkaart en de media; Berichten liet daar niks van zien
(zelfs de shortcodes bleven staan, want ap_mentions had geen emoji_json) en de
Guardian PWA plakte de kale content in een div. Die opmaak zat bovendien in de
<style> van de Krant zelf, dus een post buiten de Krant kwam sowieso ongestyled
binnen.

Nu is er één partial, note-body, met de opmaak in shared-styles ernaast. Alle
drie de oppervlakken gebruiken hem: de PWA bouwt zijn kaarten in de browser en
krijgt de body server-side gerenderd mee. ap_mentions en ap_interactions kregen
de kolommen die daarvoor nodig zijn, gevuld bij binnenkomst, met de quote- of
linkkaart out of band zoals de tijdlijn dat al deed.

En passant: de embed-gate stond alleen op de C2S-read, dus een ward zag in de
web-Krant nog steeds linkvoorbeelden die de guardians hadden uitgezet. Die gate
zit nu ook op /news en /messages.

Changed files:
src/services/ActivityPubService.js

  • belongsInTimeline: een directe note is geen tijdlijn-post
  • self-heal v21 verwijdert al opgeslagen hulpvragen en waves uit ap_timeline
  • resolveCard: quote of linkvoorbeeld, één kaart, out of band opgelost
  • mentions en replies slaan emoji's, media en die kaart op
  • getNotifications geeft die kolommen door aan Berichten

src/config/database.js

  • kolommen op ap_mentions en ap_interactions voor emoji's, media, quote, embed

src/middleware/render.js

  • renderNoteBody: dezelfde partial als string, voor niet-EJS oppervlakken

src/routes/posts.js

  • gateEmbeds op /news en /messages (FEP-633c gated feature)

src/routes/guardian.js

  • hulpvragen krijgen body_html en name_html mee

src/assets/js/guardian.js

  • kaart rendert die body in plaats van de kale content

src/assets/css/guardian.css

  • opmaak voor de gedeelde post-body in de kleuren van de PWA

src/views/partials/tl-item.ejs

  • body vervangen door de gedeelde partial

src/views/partials/msg-item.ejs

  • idem, plus een 🛟-markering bij een hulpvraag

src/views/partials/shared-styles.ejs

  • .tl-content, .tl-quote* en .tl-media* hierheen verhuisd

src/views/pages/news.ejs

  • die regels weggehaald, alleen Krant-eigen opmaak blijft

src/services/i18n.js

  • msg.help_request in nl, en, de

New file:
src/views/partials/note-body.ejs

  • de body van een post: content, quote/linkkaart, media

test/help-request-timeline.test.js

  • een 🛟 blijft uit de Krant en in Berichten

test/note-body-shared.test.js

  • één renderer, en beide views gaan er doorheen

remarks: geverifieerd op een wegwerp-database in de browser: de hulpvraag
verdwijnt bij het opstarten uit de Krant en staat mét quote en capture in
Berichten en de PWA. Nog niet uitgerold.

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

Files:
3 added
12 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/guardian.css

    r2708282 rd9ad6c5  
    8888.g-card.feed .feed-body { margin-top: 6px; line-height: 1.4; }
    8989.g-card.feed .feed-body img { max-width: 100%; border-radius: 8px; }
     90
     91/* ── The body of a post ────────────────────────────────────────────────────
     92   The 🛟 card shows a real post, so it is rendered by the same partial de
     93   Krant and Berichten use (partials/note-body). These are that partial's
     94   classes, in the PWA's own colours: the dashboard is standalone and does not
     95   load the site's stylesheet. */
     96.g-note .tl-content { line-height: 1.5; overflow-wrap: anywhere; }
     97.g-note .tl-content p { margin: .35rem 0; }
     98.g-note .tl-content p:first-child { margin-top: 0; }
     99.g-note .tl-content p:last-child { margin-bottom: 0; }
     100.g-note .tl-content a { color: var(--accent); }
     101.g-note img.emoji { height: 1.25em; width: auto; margin: 0 .05em; vertical-align: -.2em;
     102  display: inline-block; border-radius: 0; background: none; }
     103
     104/* Quote card and link preview: one card, two origins. */
     105.g-note .tl-quote { margin: 8px 0 0; padding: 8px 10px; border-radius: 10px;
     106  border: 1px solid var(--line); background: var(--card-2, rgba(128,128,128,.07)); }
     107.g-note .tl-quote-head { display: flex; align-items: center; gap: 6px; margin: 0 0 4px; min-width: 0; }
     108.g-note .tl-quote-avatar { flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
     109.g-note .tl-quote-name { font-weight: 600; font-size: .82rem; white-space: nowrap; }
     110.g-note .tl-quote-handle { color: var(--sub); font-size: .76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
     111.g-note .tl-quote-body { color: var(--sub); font-size: .88rem; line-height: 1.45; max-height: 14em; overflow: hidden; }
     112.g-note .tl-quote-media { display: block; margin: 6px 0 0; }
     113
     114/* The capture a 🛟 usually carries. */
     115.g-note .tl-media { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 0; }
     116.g-note .tl-media-img { display: block; border-radius: 10px; overflow: hidden; }
     117.g-note .tl-media-img img { width: 100%; height: auto; display: block; background: #fff; }
     118.g-note .tl-media-video, .g-note .tl-media-audio { width: 100%; margin: 8px 0 0; border-radius: 10px; display: block; }
     119.g-note .tl-media-video { max-height: 320px; background: #000; }
  • src/assets/js/guardian.js

    r2708282 rd9ad6c5  
    4343      var card = el('div', 'g-card help');
    4444      var row = el('div', 'row');
    45       row.appendChild(el('span', 'who grow', h.actor_name || handleOf(h.actor_uri, h.actor_handle)));
     45      var who = el('span', 'who grow');
     46      // name_html carries the custom emojis (FEP-9098) of the display name, the
     47      // same way de Krant renders a byline. Falls back to the plain name.
     48      if (h.name_html) who.innerHTML = h.name_html;
     49      else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
     50      row.appendChild(who);
    4651      row.appendChild(el('span', 'when', when(h.published || h.created_at)));
    4752      card.appendChild(row);
    48       var body = el('div', 'body');
    49       body.innerHTML = h.content || '';          // sanitized server-side on ingest
     53      var body = el('div', 'body g-note');
     54      // body_html is the shared note-body partial, rendered server-side: the
     55      // content with its emojis, the quote / link-preview card and the media.
     56      // Falls back to the bare content for rows stored before that existed.
     57      body.innerHTML = h.body_html || h.content || '';   // sanitized server-side on ingest
    5058      card.appendChild(body);
    5159      if (h.note_url) {
  • src/config/database.js

    r2708282 rd9ad6c5  
    622622  ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
    623623  ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
     624  // Berichten and de Krant render a post the same way, so a mention or a reply
     625  // needs the same trimmings a timeline row already has: custom emojis, the
     626  // media the note carried, and the quote / link-preview card.
     627  ensureColumn('ap_mentions', 'emoji_json', 'TEXT');        // FEP-9098, in the content
     628  ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT');  // FEP-9098, in the display name
     629  ensureColumn('ap_mentions', 'media_json', 'TEXT');
     630  ensureColumn('ap_mentions', 'quote_json', 'TEXT');        // FEP-044f quoted post
     631  ensureColumn('ap_mentions', 'embed_json', 'TEXT');        // external link preview
     632  ensureColumn('ap_interactions', 'media_json', 'TEXT');
     633  ensureColumn('ap_interactions', 'quote_json', 'TEXT');
     634  ensureColumn('ap_interactions', 'embed_json', 'TEXT');
    624635  ensureColumn('ap_followers', 'name', 'TEXT');    // cached display name (shaer-aa3)
    625636  ensureColumn('ap_followers', 'handle', 'TEXT');  // @user@host
  • src/middleware/render.js

    r2708282 rd9ad6c5  
    3737const __dirname = path.dirname(fileURLToPath(import.meta.url));
    3838const VIEWS_DIR = path.join(__dirname, '..', 'views');
     39
     40/**
     41 * Render a post body to an HTML string with the SAME partial de Krant uses.
     42 *
     43 * For surfaces that are not an EJS page: the Guardian PWA builds its cards in
     44 * the browser, so it gets the finished HTML in its state blob instead of the
     45 * raw columns. One renderer, so a post cannot drift into looking different
     46 * depending on where you run into it.
     47 */
     48export function renderNoteBody(nb, lang) {
     49  if (!nb || !nb.content) return '';
     50  const _l = lang || 'nl';
     51  // ejs.renderFile hands back a Promise even with async:false, and the callers
     52  // here are plain synchronous route code. Compile the file ourselves instead;
     53  // `filename` is what lets the partial's own relative includes resolve.
     54  const file = path.join(VIEWS_DIR, 'partials', 'note-body.ejs');
     55  try {
     56    return ejs.render(fs.readFileSync(file, 'utf8'), {
     57      nb,
     58      t: (key, vars) => i18nT(_l, key, vars),
     59      emojiHtml,
     60      emojiName,
     61      noteQuote: parseQuote,
     62      thumb: (url, w) => (typeof url === 'string' && /^https?:\/\//i.test(url) ? imgProxyUrl(url, w || 480) : url),
     63      avatar: (url, w) => (typeof url === 'string' && /^https?:\/\//i.test(url) ? imgProxyUrl(url, w || 128) : url),
     64    }, { filename: file, async: false });
     65  } catch (e) {
     66    console.warn('[render] note body failed:', e.message);
     67    return '';
     68  }
     69}
    3970
    4071// App version (from package.json) + short commit hash (from .klonkt-version, written by
  • src/routes/guardian.js

    r2708282 rd9ad6c5  
    1919import * as Guardianship from '../services/guardianship/index.js';
    2020import { t as i18nT, resolveLang } from '../services/i18n.js';
    21 import { injectCspNonce } from '../middleware/render.js';
     21import { injectCspNonce, renderNoteBody } from '../middleware/render.js';
     22import { emojiName } from '../services/NoteRender.js';
    2223
    2324const router = express.Router();
     
    5152  const me = AP.actorId(base, site.slug);
    5253  const help = db.prepare(
    53     `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at
     54    `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at,
     55            emoji_json, actor_emoji_json, media_json, quote_json, embed_json
    5456     FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
    55   ).all(site.slug);
     57  ).all(site.slug).map((h) => ({
     58    ...h,
     59    // The dashboard is built in the browser, so it gets the body finished: the
     60    // same partial de Krant and Berichten use. A 🛟 often carries a screenshot
     61    // and a link to the post it is about; both belong in the card.
     62    body_html: renderNoteBody(h, L),
     63    name_html: emojiName(h.actor_name || '', h.actor_emoji_json),
     64  }));
    5665  return {
    5766    site: site.slug,
  • src/routes/posts.js

    r2708282 rd9ad6c5  
    920920  const append = req.query.append === '1';
    921921  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
    922   const page = site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : [];
     922  const page = gateEmbeds(site, site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []);
    923923  const hasMore = page.length > FEED_PAGE;
    924924  const items = page.slice(0, FEED_PAGE);
     
    10841084}
    10851085
     1086/**
     1087 * FEP-633c §5.3-style gated feature: may this account see previews of links
     1088 * that point OUTSIDE the fediverse? For a ward that is the guardians' call.
     1089 *
     1090 * Applied at SERVE time on every surface, the way the app's inbox read already
     1091 * does it (routes/activitypub.js): a card the client merely hides has still
     1092 * been delivered.
     1093 */
     1094function gateEmbeds(site, rows) {
     1095  if (!site || !rows.length) return rows;
     1096  let isWard = false;
     1097  try { isWard = Guardianship.listGuardians(site.slug).length > 0; } catch { /* no relations yet */ }
     1098  if (Guardianship.externalEmbedsAllowed(site.external_embeds, isWard)) return rows;
     1099  return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r));
     1100}
     1101
    10861102router.get('/news', requireSiteManager, (req, res) => {
    10871103  const site = res.locals.site;
     
    10901106  const cspOrigins = new Set();
    10911107  // Fetch one extra to know whether a "Load more" button belongs on this page.
    1092   const rows = site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : [];
     1108  const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []);
    10931109  const hasMore = rows.length > FEED_PAGE;
    10941110  const timeline = rows.slice(0, FEED_PAGE).map((p) => {
  • src/services/ActivityPubService.js

    r2708282 rd9ad6c5  
    832832}
    833833
     834/**
     835 * Does this note belong in the home timeline (de Krant)?
     836 *
     837 * Only if it is a POST. A direct note is addressed to named people, so it is a
     838 * message: a plain DM, a ward's 🛟 help request (FEP-633c 5.2.1) or a
     839 * guardian's wave. Those are stored as mentions instead and surface in
     840 * Berichten and the Guardian PWA. A reply belongs to its thread, not the feed.
     841 */
     842export function belongsInTimeline(o) {
     843  if (!o || !o.id || o.inReplyTo) return false;
     844  return noteVisibility(o) !== 'direct';
     845}
     846
    834847function iStmts() {
    835848  if (!_insI) {
     
    15241537      iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis));
    15251538      console.log('[AP] reply', actorUri, '→', tgt.post_id);
     1539      // A reply is a post too: Berichten renders it the way de Krant renders a
     1540      // timeline row, so it needs the same media and the same quote/preview card.
     1541      {
     1542        const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
     1543        const key = ['reply', tgt.post_id, actorUri, o.id || ''];
     1544        const mj = mediaFromNote(o);
     1545        if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
     1546        resolveCard(o).then((c) => {
     1547          if (!c) return;
     1548          const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
     1549          try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
     1550        }).catch(() => { /* best-effort */ });
     1551      }
    15261552      {
    15271553        // Private (followers/direct) replies push as a DM ping WITHOUT content
     
    15411567    }
    15421568    // Home timeline (client): a top-level post from an account we follow.
    1543     if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
     1569    if (actorUri && !isLocalActor && belongsInTimeline(o)) {
    15441570      let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
    15451571      if (subs.length) {
     
    16101636        for (const slug of slugs) {
    16111637          try {
    1612             const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
    1613               .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0);
     1638            const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
     1639                                  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
     1640              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
     1641                extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
    16141642            if (r.changes) {
     1643              // The quote / link-preview card resolves out of band (a remote
     1644              // fetch), exactly as it does for a timeline post, so the inbox
     1645              // answer is never blocked on it.
     1646              resolveCard(o).then((c) => {
     1647                if (!c) return;
     1648                const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
     1649                try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
     1650              }).catch(() => { /* best-effort */ });
    16151651              console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
    16161652              const vis = noteVisibility(o);
     
    28492885// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
    28502886// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
    2851 const SELFHEAL_VERSION = 20; // v20: oEmbed provider registry, so platforms that hide their tags from a server still resolve
     2887const SELFHEAL_VERSION = 21; // v21: drop direct notes (🛟 help requests, waves) that were cached as timeline posts
    28522888async function fetchNoteAP(url) {
    28532889  try {
     
    29913027  return JSON.stringify(snapshot);
    29923028}
     3029
     3030/**
     3031 * The card under a post: a fediverse quote (FEP-044f) when the note has one,
     3032 * otherwise an external link preview. Both render as the SAME card, so only one
     3033 * of the two is ever stored. Returns {column, json} or null.
     3034 *
     3035 * Both halves reach out over the network, which is why every caller runs this
     3036 * out of band: an inbox answer must never wait on a third party.
     3037 */
     3038async function resolveCard(o) {
     3039  if (quoteHrefOf(o)) {
     3040    const qj = await resolveQuote(o);
     3041    return qj ? { column: 'quote_json', json: qj } : null;
     3042  }
     3043  const ej = await resolveExternalEmbed(o && o.content);
     3044  return ej ? { column: 'embed_json', json: ej } : null;
     3045}
     3046
    29933047// A generic SSRF-safe AP GET (collections / pages).
    29943048async function apGetJson(url) {
     
    31583212    try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_version'); cur = r ? (parseInt(r.value, 10) || 0) : 0; } catch { return; }
    31593213    if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
     3214    // v21: direct notes used to land in the timeline as if they were posts, so a
     3215    // ward's 🛟 help request showed up in the guardian's Krant. The insert now
     3216    // refuses them; drop the ones already cached. Scoped to the two kinds we can
     3217    // still recognise afterwards (help request, wave) — a plain public mention
     3218    // from someone you follow IS a timeline post and must stay.
     3219    try {
     3220      const r = db.prepare(`DELETE FROM ap_timeline WHERE EXISTS (
     3221        SELECT 1 FROM ap_mentions m
     3222         WHERE m.object_uri = ap_timeline.id AND m.slug = ap_timeline.slug
     3223           AND (m.help_request = 1 OR m.wave = 1))`).run();
     3224      if (r.changes) console.log(`[AP] self-heal v21: ${r.changes} direct note(s) removed from the timeline`);
     3225    } catch { /* table may predate the columns */ }
    31603226    let rows = [];
    31613227    try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json, embed_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
     
    34563522    const rows = db.prepare(`
    34573523      SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.visibility,
    3458              i.emoji_json, i.actor_emoji_json,
     3524             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
    34593525             p.slug AS post_slug, p.title AS post_title
    34603526      FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
     
    34663532      content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
    34673533      emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (messages render)
     3534      media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json,   // rendered like a Krant post
    34683535      // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages
    34693536      visibility: r.visibility || 'public',
     
    34883555  } catch { /* ignore */ }
    34893556  try {
    3490     for (const r of db.prepare('SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, created_at FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
    3491       out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at });
     3557    for (const r of db.prepare(`SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, help_request, created_at,
     3558                                       emoji_json, actor_emoji_json, media_json, quote_json, embed_json
     3559                                FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?`).all(slug, L)) {
     3560      out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, help_request: r.help_request ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at,
     3561        // Same trimmings a Krant row has, so Berichten renders the post identically.
     3562        emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json });
    34923563    }
    34933564  } catch { /* ignore */ }
     
    37163787  getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
    37173788  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
    3718   noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget,
     3789  noteVisibility, belongsInTimeline, isRejectedObject, rejectInteraction, interactionReportTarget,
    37193790  getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched,
    37203791};
  • src/services/i18n.js

    r2708282 rd9ad6c5  
    6767    'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer',
    6868    'push.t': 'Notificaties', 'push.intro': 'Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten, ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo min mogelijk inhoud mee.', 'push.unavailable': 'Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).', 'push.unsupported': 'Deze browser ondersteunt geen push-notificaties.', 'push.ios_hint': 'Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat: deel-knop, dan "Zet op beginscherm", en open de site daarna vanaf daar.', 'push.this_device': 'Dit apparaat:', 'push.checking': 'controleren…', 'push.state_on': 'meldingen staan aan', 'push.state_off': 'meldingen staan uit', 'push.state_denied': 'geblokkeerd in de browserinstellingen', 'push.state_unknown': 'status onbekend', 'push.state_unsupported': 'niet ondersteund', 'push.enable': 'Zet aan op dit apparaat', 'push.disable': 'Zet uit', 'push.test': 'Stuur testmelding', 'push.what': 'Waarvoor wil je een melding?', 'push.a_follow': 'Nieuwe volger', 'push.a_reply': 'Reactie of vermelding', 'push.a_like': 'Waardering (ster)', 'push.a_boost': 'Boost', 'push.a_dm': 'Privébericht', 'push.saved': 'Opgeslagen.', 'push.devices': 'Gekoppelde apparaten', 'push.device': 'Apparaat', 'push.since': 'sinds', 'push.remove': 'Verwijder', 'push.enable_failed': 'aanzetten mislukt', 'push.on_short': 'Word supporter',
    69     'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
     69    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
    7070    'apaid.t': 'Betaalde posts', 'apaid.intro': 'Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey, zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters, alleen het versleutelde token van jouw campagne.', 'apaid.saved': 'Opgeslagen.', 'apaid.nokey': 'Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.', 'apaid.status': 'Status:', 'apaid.connected': 'verbonden', 'apaid.campaign': 'campagne', 'apaid.configured': 'ingesteld, nog niet verbonden (vul een token in)', 'apaid.notyet': 'nog niet ingesteld', 'apaid.redirect_h': 'Zet deze redirect-URI in je Patreon-client', 'apaid.redirect_p': 'Bij je Patreon API-client, onder Redirect URIs, moet exact deze regel staan. Klopt hij niet, dan geeft Patreon een foutmelding in plaats van je supporters terug te sturen.', 'apaid.copy': 'Kopieer', 'apaid.copied': 'Gekopieerd', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leeg laten = huidige waarde behouden.', 'apaid.campaign_id': 'Campagne-id', 'apaid.public_page': 'Openbare Patreon-pagina', 'apaid.public_help': 'De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.', 'apaid.min_eur': 'Standaard-steunbedrag voor een betaalde post (euro)', 'apaid.save': 'Opslaan', 'apaid.disconnect': 'Koppeling verwijderen', 'apaid.disconnect_confirm': 'Patreon-koppeling verwijderen?', 'apaid.unchanged': 'blijft ongewijzigd',
    7171    'pgate.h': 'Voor supporters', 'pgate.sub': 'Deze post is voor supporters van deze site. Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.sub_cents': 'Deze post is voor supporters van deze site (vanaf €{eur} per maand op Patreon). Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.join': 'Word supporter op Patreon', 'pgate.unlock_have': 'Al supporter? Ontgrendelen', 'pgate.unlock': 'Ontgrendelen met Patreon', 'pgate.join_short': 'Word supporter', 'pgate.confirm': 'Bevestig met je passkey…', 'pgate.failed': 'Ontgrendelen mislukt. Probeer opnieuw.', 'pgate.error': 'Er ging iets mis. Probeer opnieuw.',
     
    10081008    'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin',
    10091009    'push.t': 'Notifications', 'push.intro': 'Get a notification on this device for new followers, replies and messages, even when the site is closed. Encrypted all the way to your browser; we send as little content as possible.', 'push.unavailable': 'Push is unavailable on this server (the key could not be created or the dependency is missing).', 'push.unsupported': 'This browser does not support push notifications.', 'push.ios_hint': 'On iPhone/iPad this only works when the site is on your home screen: share button, then "Add to Home Screen", and open it from there.', 'push.this_device': 'This device:', 'push.checking': 'checking…', 'push.state_on': 'notifications are on', 'push.state_off': 'notifications are off', 'push.state_denied': 'blocked in the browser settings', 'push.state_unknown': 'status unknown', 'push.state_unsupported': 'not supported', 'push.enable': 'Turn on for this device', 'push.disable': 'Turn off', 'push.test': 'Send a test notification', 'push.what': 'What do you want to be notified about?', 'push.a_follow': 'New follower', 'push.a_reply': 'Reply or mention', 'push.a_like': 'Like (star)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private message', 'push.saved': 'Saved.', 'push.devices': 'Linked devices', 'push.device': 'Device', 'push.since': 'since', 'push.remove': 'Remove', 'push.enable_failed': 'turning on failed',
    1010     'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
     1010    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
    10111011    'apaid.t': 'Paid posts', 'apaid.intro': 'Connect your own Patreon campaign. Supporters unlock paid posts with a passkey, no account and no cookie. We store no supporter names or email addresses, only the encrypted token of your campaign.', 'apaid.saved': 'Saved.', 'apaid.nokey': 'Note: the encryption key could not be created or read (write permissions on the storage directory?). Without a key, secrets cannot be stored safely.', 'apaid.status': 'Status:', 'apaid.connected': 'connected', 'apaid.campaign': 'campaign', 'apaid.configured': 'configured, not connected yet (enter a token)', 'apaid.notyet': 'not configured yet', 'apaid.redirect_h': 'Put this redirect URI in your Patreon client', 'apaid.redirect_p': 'In your Patreon API client, under Redirect URIs, exactly this line must be present. If it does not match, Patreon shows an error instead of sending your supporters back.', 'apaid.copy': 'Copy', 'apaid.copied': 'Copied', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leave empty = keep the current value.', 'apaid.campaign_id': 'Campaign id', 'apaid.public_page': 'Public Patreon page', 'apaid.public_help': 'The link where visitors can become a supporter. Shown as "Become a supporter" when someone does not pledge yet.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'You get the access + refresh token on your Patreon API client page. We encrypt them and refresh automatically.', 'apaid.min_eur': 'Default support amount for a paid post (euro)', 'apaid.save': 'Save', 'apaid.disconnect': 'Remove connection', 'apaid.disconnect_confirm': 'Remove the Patreon connection?', 'apaid.unchanged': 'stays unchanged',
    10121012    'pgate.h': 'For supporters', 'pgate.sub': 'This post is for supporters of this site. Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.sub_cents': 'This post is for supporters of this site (from €{eur} per month on Patreon). Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.join': 'Become a supporter on Patreon', 'pgate.unlock_have': 'Already a supporter? Unlock', 'pgate.unlock': 'Unlock with Patreon', 'pgate.join_short': 'Become a supporter', 'pgate.confirm': 'Confirm with your passkey…', 'pgate.failed': 'Unlocking failed. Try again.', 'pgate.error': 'Something went wrong. Try again.',
     
    19431943    'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung',
    19441944    'push.t': 'Benachrichtigungen', 'push.intro': 'Erhalte auf diesem Gerät eine Meldung bei neuen Followern, Antworten und Nachrichten, auch wenn die Seite geschlossen ist. Verschlüsselt bis in deinen Browser; wir senden so wenig Inhalt wie möglich mit.', 'push.unavailable': 'Push ist auf diesem Server nicht verfügbar (Schlüssel konnte nicht erstellt werden oder die Abhängigkeit fehlt).', 'push.unsupported': 'Dieser Browser unterstützt keine Push-Benachrichtigungen.', 'push.ios_hint': 'Auf iPhone/iPad funktioniert das nur, wenn die Seite auf deinem Home-Bildschirm liegt: Teilen-Knopf, dann "Zum Home-Bildschirm", und öffne sie danach von dort.', 'push.this_device': 'Dieses Gerät:', 'push.checking': 'prüfen…', 'push.state_on': 'Benachrichtigungen sind an', 'push.state_off': 'Benachrichtigungen sind aus', 'push.state_denied': 'in den Browser-Einstellungen blockiert', 'push.state_unknown': 'Status unbekannt', 'push.state_unsupported': 'nicht unterstützt', 'push.enable': 'Auf diesem Gerät einschalten', 'push.disable': 'Ausschalten', 'push.test': 'Testmeldung senden', 'push.what': 'Wofür möchtest du eine Meldung?', 'push.a_follow': 'Neuer Follower', 'push.a_reply': 'Antwort oder Erwähnung', 'push.a_like': 'Like (Stern)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private Nachricht', 'push.saved': 'Gespeichert.', 'push.devices': 'Verbundene Geräte', 'push.device': 'Gerät', 'push.since': 'seit', 'push.remove': 'Entfernen', 'push.enable_failed': 'Einschalten fehlgeschlagen',
    1945     'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
     1945    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
    19461946    'apaid.t': 'Bezahlte Beiträge', 'apaid.intro': 'Verbinde deine eigene Patreon-Kampagne. Unterstützer entsperren bezahlte Beiträge mit einem Passkey, ohne Konto und ohne Cookie. Wir speichern keine Namen oder E-Mail-Adressen von Unterstützern, nur das verschlüsselte Token deiner Kampagne.', 'apaid.saved': 'Gespeichert.', 'apaid.nokey': 'Achtung: der Verschlüsselungsschlüssel konnte nicht erstellt oder gelesen werden (Schreibrechte auf dem Speicherordner?). Ohne Schlüssel können Secrets nicht sicher gespeichert werden.', 'apaid.status': 'Status:', 'apaid.connected': 'verbunden', 'apaid.campaign': 'Kampagne', 'apaid.configured': 'eingerichtet, noch nicht verbunden (Token eintragen)', 'apaid.notyet': 'noch nicht eingerichtet', 'apaid.redirect_h': 'Trage diese Redirect-URI in deinen Patreon-Client ein', 'apaid.redirect_p': 'In deinem Patreon-API-Client muss unter Redirect URIs genau diese Zeile stehen. Stimmt sie nicht, zeigt Patreon eine Fehlermeldung statt deine Unterstützer zurückzuschicken.', 'apaid.copy': 'Kopieren', 'apaid.copied': 'Kopiert', 'apaid.client_id': 'Patreon Client-ID', 'apaid.client_secret': 'Patreon Client-Secret', 'apaid.keep': 'Leer lassen = aktuellen Wert behalten.', 'apaid.campaign_id': 'Kampagnen-ID', 'apaid.public_page': 'Öffentliche Patreon-Seite', 'apaid.public_help': 'Der Link, unter dem Besucher Unterstützer werden können. Wird als "Unterstützer werden" gezeigt, wenn jemand noch nicht spendet.', 'apaid.access': 'Creator Access-Token', 'apaid.refresh': 'Creator Refresh-Token', 'apaid.token_help': 'Access- und Refresh-Token bekommst du auf deiner Patreon-API-Client-Seite. Wir verschlüsseln sie und erneuern automatisch.', 'apaid.min_eur': 'Standard-Unterstützungsbetrag für einen bezahlten Beitrag (Euro)', 'apaid.save': 'Speichern', 'apaid.disconnect': 'Verbindung entfernen', 'apaid.disconnect_confirm': 'Patreon-Verbindung entfernen?', 'apaid.unchanged': 'bleibt unverändert',
    19471947    'pgate.h': 'Für Unterstützer', 'pgate.sub': 'Dieser Beitrag ist für Unterstützer dieser Seite. Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.sub_cents': 'Dieser Beitrag ist für Unterstützer dieser Seite (ab €{eur} pro Monat auf Patreon). Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.join': 'Unterstützer werden auf Patreon', 'pgate.unlock_have': 'Schon Unterstützer? Entsperren', 'pgate.unlock': 'Mit Patreon entsperren', 'pgate.join_short': 'Unterstützer werden', 'pgate.confirm': 'Bestätige mit deinem Passkey…', 'pgate.failed': 'Entsperren fehlgeschlagen. Versuch es erneut.', 'pgate.error': 'Etwas ist schiefgegangen. Versuch es erneut.',
  • src/views/pages/news.ejs

    r2708282 rd9ad6c5  
    124124  .tl-time { color: var(--ink-soft, #999); font-size: .78rem; flex: 0 0 auto; align-self: flex-start; white-space: nowrap; }
    125125
    126   .tl-content { line-height: 1.55; overflow-wrap: anywhere; }
    127   .tl-content p { margin: .4rem 0; } .tl-content p:first-child { margin-top: 0; } .tl-content p:last-child { margin-bottom: 0; }
    128   .tl-content a { color: var(--accent, #06c); }
    129   .tl-content img { max-width: 100%; height: auto; border-radius: 10px; background: #fff; }
     126  /* .tl-content, .tl-quote* and .tl-media* now live in partials/shared-styles,
     127     next to partials/note-body: the markup is shared with Berichten and the
     128     Guardian PWA, so the styling has to be too. What stays here is Krant-only. */
    130129  /* Long posts collapse to a max height with a fade + "read more" (added by JS only when it overflows). */
    131130  .tl-content.tl-clamp { max-height: 20em; overflow: hidden;
     
    137136  .tl-readmore:hover { text-decoration: underline; }
    138137
    139   /* FEP-044f embedded quote card (mirror of the Shaer QuoteCard). */
    140   .tl-quote { margin: .75rem 0 0; padding: .6rem .7rem; border-radius: 12px;
    141     border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
    142     background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); }
    143   .tl-quote-head { display: flex; align-items: center; gap: .4rem; margin: 0 0 .35rem; min-width: 0; }
    144   .tl-quote-avatar { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; object-fit: cover; background: #fff; }
    145   .tl-quote-name { font-weight: 700; font-size: .85rem; color: var(--ink, inherit); white-space: nowrap; }
    146   .tl-quote-handle { color: var(--ink-soft, #888); font-size: .78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
    147   .tl-quote-body { line-height: 1.5; font-size: .92rem; color: var(--ink-soft, #555);
    148     overflow-wrap: anywhere; max-height: 16em; overflow: hidden; }
    149   .tl-quote-body p { margin: .3rem 0; } .tl-quote-body p:first-child { margin-top: 0; } .tl-quote-body p:last-child { margin-bottom: 0; }
    150   .tl-quote-body a { color: var(--accent, #06c); }
    151   .tl-quote-media { display: block; margin: .45rem 0 0; }
    152   .tl-quote-media img { max-width: 100%; height: auto; border-radius: 8px; }
    153 
    154   /* Always show the FULL image at its natural ratio — never cropped, no letterbox. */
    155   .tl-media { display: flex; flex-direction: column; gap: .4rem; margin: .75rem 0 0; }
    156   .tl-media-img { display: block; border-radius: 12px; overflow: hidden;
    157     background-image: linear-gradient(135deg,
    158       color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2, var(--paper))) 0%,
    159       color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2, var(--paper))) 100%); }
    160   .tl-media-img img { width: 100%; height: auto; display: block; background: #fff; transition: opacity .4s ease; }
    161   .tl-media-img img.is-loading { opacity: 0; }
    162   /* Reserve placeholder space while the (natural-aspect) feed image loads. */
    163   .tl-media-img:has(img.is-loading) { min-height: 220px; }
    164 
    165   .tl-media-video, .tl-media-audio { width: 100%; margin: .75rem 0 0; border-radius: 12px; display: block; }
    166   .tl-media-video { max-height: 480px; background: #000; }
    167138  .tl-poll { margin: .75rem 0 0; display: flex; flex-direction: column; gap: 8px; }
    168139  .tl-poll-form { display: flex; flex-direction: column; gap: 8px; }
  • src/views/partials/msg-item.ejs

    r2708282 rd9ad6c5  
    5555              <% } else if (_t === 'boost') { %><%= t('notif.boosted') %>
    5656              <% } else if (_t === 'report') { %><%= t('notif.reported') %>
     57              <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %>
    5758              <% } else if (_t === 'mention' && n.wave) { %>👋 <%= t('msg.waved_at_you') %>
    5859              <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %>
     
    6768
    6869            <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div>
    69             <% } else if ((_t === 'reply' || _t === 'mention' || _t === 'sent') && n.content) { %><div class="msg-content"><%- emojiHtml(n.content, n.emoji_json) %></div>
     70            <% } else if ((_t === 'reply' || _t === 'mention' || _t === 'sent') && n.content) { %><div class="msg-content msg-note"><%- include('../partials/note-body', { nb: n }) %></div>
    7071              <% if (_t === 'mention' && n.wave && n.actorUri && canMutate) { %>
    7172                <form class="msg-quickreply" method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
  • src/views/partials/shared-styles.ejs

    r2708282 rd9ad6c5  
    202202.tl-author img.emoji, .tl-boost-by img.emoji, .tl-quote-name img.emoji, .msg-who img.emoji, .comment-author img.emoji {
    203203  max-width: none; height: 1.3em; border-radius: 0; background: none; box-shadow: none; vertical-align: -0.2em; }
     204
     205/* ── The body of a post ────────────────────────────────────────────────────
     206   Shared with partials/note-body.ejs, so a post looks the same in de Krant, in
     207   Berichten and in the Guardian PWA. These used to live in the Krant's own
     208   <style> block, which is why a post outside de Krant came out unstyled.
     209   Krant-only behaviour (the clamp + "read more", the poll, the action bar)
     210   stays in pages/news.ejs. */
     211.tl-content { line-height: 1.55; overflow-wrap: anywhere; }
     212.tl-content p { margin: .4rem 0; } .tl-content p:first-child { margin-top: 0; } .tl-content p:last-child { margin-bottom: 0; }
     213.tl-content a { color: var(--accent, #06c); }
     214.tl-content img { max-width: 100%; height: auto; border-radius: 10px; background: #fff; }
     215
     216/* FEP-044f embedded quote card (mirror of the Shaer QuoteCard). Also carries
     217   an external link preview: same card, different origin. */
     218.tl-quote { margin: .75rem 0 0; padding: .6rem .7rem; border-radius: 12px;
     219  border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
     220  background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); }
     221.tl-quote-head { display: flex; align-items: center; gap: .4rem; margin: 0 0 .35rem; min-width: 0; }
     222.tl-quote-avatar { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; object-fit: cover; background: #fff; }
     223.tl-quote-name { font-weight: 700; font-size: .85rem; color: var(--ink, inherit); white-space: nowrap; }
     224.tl-quote-handle { color: var(--ink-soft, #888); font-size: .78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
     225.tl-quote-body { line-height: 1.5; font-size: .92rem; color: var(--ink-soft, #555);
     226  overflow-wrap: anywhere; max-height: 16em; overflow: hidden; }
     227.tl-quote-body p { margin: .3rem 0; } .tl-quote-body p:first-child { margin-top: 0; } .tl-quote-body p:last-child { margin-bottom: 0; }
     228.tl-quote-body a { color: var(--accent, #06c); }
     229.tl-quote-media { display: block; margin: .45rem 0 0; }
     230.tl-quote-media img { max-width: 100%; height: auto; border-radius: 8px; }
     231
     232/* Always show the FULL image at its natural ratio — never cropped, no letterbox. */
     233.tl-media { display: flex; flex-direction: column; gap: .4rem; margin: .75rem 0 0; }
     234.tl-media-img { display: block; border-radius: 12px; overflow: hidden;
     235  background-image: linear-gradient(135deg,
     236    color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2, var(--paper))) 0%,
     237    color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2, var(--paper))) 100%); }
     238.tl-media-img img { width: 100%; height: auto; display: block; background: #fff; transition: opacity .4s ease; }
     239.tl-media-img img.is-loading { opacity: 0; }
     240/* Reserve placeholder space while the (natural-aspect) feed image loads. */
     241.tl-media-img:has(img.is-loading) { min-height: 220px; }
     242.tl-media-video, .tl-media-audio { width: 100%; margin: .75rem 0 0; border-radius: 12px; display: block; }
     243.tl-media-video { max-height: 480px; background: #000; }
     244
     245/* In Berichten a post sits inside a notification row, so it starts tighter and
     246   its media stays modest: the row is a pointer to the post, not the post page. */
     247.msg-note .tl-content { line-height: 1.5; }
     248.msg-note .tl-quote, .msg-note .tl-media, .msg-note .tl-media-video, .msg-note .tl-media-audio { margin-top: .5rem; }
     249.msg-note .tl-media-img:has(img.is-loading) { min-height: 120px; }
     250.msg-note .tl-media-video { max-height: 320px; }
    204251</style>
  • src/views/partials/tl-item.ejs

    r2708282 rd9ad6c5  
    1010          </div>
    1111
    12           <%
    13             var media = []; try { media = JSON.parse(p.media_json || '[]'); } catch (e) { media = []; }
    14             var imgs = media.filter(function(m){ return m && m.url && (!m.type || /^image\//.test(m.type)); });
    15             var vids = media.filter(function(m){ return m && m.url && m.type && /^video\//.test(m.type); });
    16             var auds = media.filter(function(m){ return m && m.url && m.type && /^audio\//.test(m.type); });
    17             var _nsfwVisual = !!p.nsfw && (imgs.length || vids.length || auds.length);
    18             var _nsfwText = !!p.nsfw && !(imgs.length || vids.length || auds.length);
    19           %>
    20           <% if (_nsfwText) { %>
    21             <div class="tl-content nsfw-media"><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></span><%- emojiHtml(p.content, p.emoji_json) %></div>
    22           <% } else { %>
    23             <div class="tl-content"><%- emojiHtml(p.content, p.emoji_json) %></div>
    24           <% } %>
    25           <%
    26             // One card for both: a fediverse quote and an external embed look
    27             // identical; only where they came from differs. An embed carries a
    28             // title and a thumbnail, never an iframe.
    29             var _quote = noteQuote(p.quote_json);
    30             if (!_quote) {
    31               var _emb = noteQuote(p.embed_json);
    32               // The title comes from a third-party oEmbed provider, so it is
    33               // escaped here: the quote card renders `content` as HTML (fine for
    34               // AP content, which we sanitise on the way in, but not for this).
    35               if (_emb) _quote = { url: _emb.url, author: _emb.author || (_emb.provider ? { name: _emb.provider } : null),
    36                                    content: _emb.title ? ('<p>' + emojiName(_emb.title, null) + '</p>') : '', media: _emb.media || [] };
    37             }
    38           %>
    39           <% if (_quote) { %><%- include('../partials/quote-card', { q: _quote }) %><% } %>
    40           <% if (_nsfwVisual) { %><div class="nsfw-media"><% } %>
    41           <% if (imgs.length) { %>
    42             <div class="tl-media">
    43               <% imgs.forEach(function(m){ %>
    44                 <a class="tl-media-img" href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= thumb(m.url, 1280) %>" alt="" loading="lazy" decoding="async"></a>
    45               <% }); %>
    46             </div>
    47           <% } %>
    48           <% vids.forEach(function(m){ %><video class="tl-media-video" src="<%= m.url %>" poster="<%= thumb(m.url, 1280) %>" controls preload="metadata" playsinline></video><% }); %>
    49           <% /* A Klonkt audio post carries an embed player (embedHtml/embedUrl) that already
    50                 covers these tracks, so don't ALSO render the raw Audio attachments as bare
    51                 players here. Posts without a Klonkt embed (e.g. a plain remote audio) keep them. */ %>
    52           <% if (!p.embedHtml && !p.embedUrl) { auds.forEach(function(m){ %><audio class="tl-media-audio" src="<%= m.url %>" controls preload="none"></audio><% }); } %>
    53           <% if (_nsfwVisual) { %><div class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></div></div><% } %>
     12          <% /* Content, quote/preview card and media: shared with Berichten and the
     13                Guardian PWA so a post looks the same wherever it turns up.
     14                suppressAudio: a Klonkt audio post carries an embed player below
     15                that already covers these tracks, so don't ALSO render the raw
     16                Audio attachments as bare players. */ %>
     17          <%- include('../partials/note-body', { nb: Object.assign({}, p, { suppressAudio: !!(p.embedHtml || p.embedUrl) }) }) %>
    5418
    5519          <% if (p.embedHtml) { %><div class="tl-embed"><%- p.embedHtml %></div><% } %>
Note: See TracChangeset for help on using the changeset viewer.