Changeset feced2c in Klonkt


Ignore:
Timestamp:
07/19/2026 05:25:26 PM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
5190152
Parents:
33e1dbd
git-author:
Robin <roboburr@…> (07/19/2026 05:24:58 PM)
git-committer:
Robin <roboburr@…> (07/19/2026 05:25:26 PM)
Message:

Feature: media in replies — rich replies phase 2 (klonkt-demo-c7f)

Drop, paste or pick images/audio/video in the reply editor; they upload, show
as removable chips, travel as AS2 attachments on the federated Note, and render
in the thread.

  • POST /posts/upload-reply-media (requireSiteManager): image/audio/video by extension AND mimetype, stored as-is under /media/reply-media/ (no transcode; a reply attachment is not a track), 32MB cap, returns {url, mediaType, name}.
  • Editor: paperclip button + hidden file input (the mobile path), paste-files and drag/drop handlers, busy/error chips, image thumbnails, max 4, hidden attachments JSON field. Media-only submit allowed (text no longer required when something is attached).
  • deliverReply({attachments}): re-validates server-side — own /media/ paths only (the upload route is the sole producer, remote URLs rejected), image|audio|video mimetypes, capped at 4; stored as JSON on ap_outbox (additive column). Dedup guard now includes attachments so two media-only replies to the same parent are distinct from each other but double-submits still dedup.
  • buildNote reply branch: attachment array with Image/Audio/Video types and absolute URLs. getInteractions passes media through; fedi-node renders it (img/audio/video) for visitors too, loading the stylesheet when the owner-only editor is not on the page.

3 new tests (foreign-URL and type rejection, typed absolute Note attachments,
media-only allowed, only-invalid rejected); 91 green. Browser-verified end to
end: real upload via the endpoint, paste-event -> chip with thumbnail ->
submit -> ap_outbox row with content+language+attachments -> media rendered in
the thread -> /ap/notes/<id> serves the typed absolute attachment.

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

Files:
12 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.de.md

    r33e1dbd rfeced2c  
    77
    88### Hinzugefügt
     9- **Medien in Antworten (reichhaltige Antworten, Phase 2).** Ziehe, füge ein
     10  oder wähle Bilder, Audio und Video direkt im Antwort-Editor (die Büroklammer
     11  funktioniert auf Telefonen). Dateien laden auf deine eigene Seite hoch,
     12  erscheinen beim Schreiben als entfernbare Chips, reisen als echte Anhänge der
     13  föderierten Note mit und erscheinen in deinem Thread. Eine Antwort nur mit
     14  Medien (ohne Text) geht auch.
    915- **Reichhaltige Antworten (Phase 1).** Das Antworten auf Fediverse-Kommentare
    1016  (inline im Thread und auf der Interaktionsseite) nutzt jetzt einen geteilten
  • CHANGELOG.md

    r33e1dbd rfeced2c  
    77
    88### Added
     9- **Media in replies (rich replies, phase 2).** Drop, paste or pick images,
     10  audio and video straight into the reply editor (the paperclip works on
     11  phones). Files upload to your own site, show as removable chips while you
     12  write, travel as real attachments on the federated note, and render in your
     13  thread. A media-only reply (no text) works too.
    914- **Rich replies (phase 1).** Replying to fediverse comments (inline in the
    1015  thread and on the interact page) now uses a shared rich editor: bold, italic,
  • CHANGELOG.nl.md

    r33e1dbd rfeced2c  
    77
    88### Toegevoegd
     9- **Media in reacties (rijke reacties, fase 2).** Sleep, plak of kies
     10  afbeeldingen, audio en video direct in de reactie-editor (de paperclip werkt
     11  op telefoons). Bestanden uploaden naar je eigen site, verschijnen als
     12  verwijderbare chips tijdens het schrijven, reizen als echte attachments mee op
     13  de gefedereerde note, en renderen in je thread. Een reactie met alleen media
     14  (zonder tekst) kan ook.
    915- **Rijke reacties (fase 1).** Reageren op fediverse-reacties (inline in de
    1016  thread en op de interactiepagina) gebruikt nu een gedeelde rijke editor: vet,
  • src/assets/css/reply-editor.css

    r33e1dbd rfeced2c  
    3030}
    3131
     32/* Media attachments (chips under the editor). */
     33.re-attachments { display: flex; flex-wrap: wrap; gap: .4rem; }
     34.re-att {
     35  display: inline-flex; align-items: center; gap: .35rem;
     36  padding: .25rem .4rem; border-radius: 8px; font-size: .8rem;
     37  background: color-mix(in srgb, var(--ink, #000) 7%, transparent);
     38  max-width: 100%; overflow: hidden;
     39}
     40.re-att img { width: 44px; height: 44px; object-fit: cover; border-radius: 6px; display: block; }
     41.re-att-del { border: none; background: none; color: inherit; font-size: 1rem; line-height: 1; cursor: pointer; padding: 0 .15rem; }
     42.re-att-err { background: color-mix(in srgb, #c00 18%, transparent); }
     43.re-editor.re-drop { border-color: var(--accent, #06c); border-style: dashed; }
     44
     45/* Media on a sent reply in the thread (fedi-node). */
     46.re-reply-media { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .4rem; }
     47.re-reply-media img { max-width: min(260px, 100%); max-height: 260px; border-radius: 10px; display: block; }
     48.re-reply-media audio { max-width: 100%; }
     49.re-reply-media video { max-width: min(320px, 100%); border-radius: 10px; }
     50
    3251/* Full-screen compose (mobile). JS toggles .re-full on the form. */
    3352.re-head { display: flex; align-items: center; gap: .6rem; }
  • src/assets/js/reply-editor.js

    r33e1dbd rfeced2c  
    3232    if (lang) lang.hidden = false;
    3333
     34    // ── Media attachments: picker (📎), drag/drop and paste ──────────────
     35    var attWrap = form.querySelector('.re-attachments');
     36    var attField = form.querySelector('input[name="attachments"]');
     37    var fileInput = form.querySelector('.re-file');
     38    var attachments = [];
     39
     40    function syncAtt() {
     41      attField.value = attachments.length ? JSON.stringify(attachments) : '';
     42      attWrap.hidden = attachments.length === 0;
     43    }
     44    function addChip(a) {
     45      var chip = document.createElement('span');
     46      chip.className = 're-att';
     47      if (a.mediaType.indexOf('image/') === 0) {
     48        var img = document.createElement('img');
     49        img.src = a.url; img.alt = a.name || '';
     50        chip.appendChild(img);
     51      } else {
     52        chip.appendChild(document.createTextNode((a.mediaType.indexOf('audio/') === 0 ? '🎵 ' : '🎬 ') + (a.name || a.mediaType)));
     53      }
     54      var del = document.createElement('button');
     55      del.type = 'button'; del.className = 're-att-del'; del.textContent = '×';
     56      del.addEventListener('click', function () {
     57        attachments = attachments.filter(function (x) { return x !== a; });
     58        chip.remove(); syncAtt();
     59      });
     60      chip.appendChild(del);
     61      attWrap.appendChild(chip);
     62    }
     63    function uploadFiles(files) {
     64      Array.prototype.forEach.call(files, function (file) {
     65        if (!/^(image|audio|video)\//.test(file.type) || attachments.length >= 4) return;
     66        var chip = document.createElement('span');
     67        chip.className = 're-att re-att-busy';
     68        chip.textContent = '⏳ ' + file.name;
     69        attWrap.hidden = false;
     70        attWrap.appendChild(chip);
     71        var fd = new FormData();
     72        fd.append('media', file);
     73        fetch(form.getAttribute('data-upload'), { method: 'POST', body: fd })
     74          .then(function (r) { return r.json().then(function (j) { return r.ok ? j : Promise.reject(j); }); })
     75          .then(function (j) {
     76            chip.remove();
     77            var a = { url: j.url, mediaType: j.mediaType, name: j.name || file.name };
     78            attachments.push(a); addChip(a); syncAtt();
     79          })
     80          .catch(function (err) {
     81            chip.className = 're-att re-att-err';
     82            chip.textContent = (form.getAttribute('data-upload-err') || 'Upload failed') + (err && err.error ? ': ' + err.error : '');
     83            setTimeout(function () { chip.remove(); syncAtt(); }, 5000);
     84          });
     85      });
     86    }
     87
    3488    // Toolbar commands (execCommand is deprecated-but-universal; same approach
    3589    // as the post editor).
     
    3892      if (!btn) return;
    3993      e.preventDefault();
     94      var cmd = btn.getAttribute('data-cmd');
     95      if (cmd === 'attach') { fileInput.click(); return; }   // no editor focus: keeps the picker usable on mobile
    4096      ed.focus();
    41       var cmd = btn.getAttribute('data-cmd');
    4297      if (cmd === 'bold') document.execCommand('bold');
    4398      else if (cmd === 'italic') document.execCommand('italic');
     
    49104      }
    50105    });
     106    fileInput.addEventListener('change', function () {
     107      uploadFiles(fileInput.files);
     108      fileInput.value = '';
     109    });
    51110
    52     // Paste as plain text (rich paste becomes messy HTML; formatting is what
    53     // the toolbar is for). Media paste/drop lands in the media phase.
     111    // Paste: files become attachments; text pastes as plain text (rich paste
     112    // becomes messy HTML; formatting is what the toolbar is for).
    54113    ed.addEventListener('paste', function (e) {
    55       var txt = (e.clipboardData || window.clipboardData).getData('text/plain');
     114      var cd = e.clipboardData || window.clipboardData;
     115      if (cd.files && cd.files.length) {
     116        e.preventDefault();
     117        uploadFiles(cd.files);
     118        return;
     119      }
     120      var txt = cd.getData('text/plain');
    56121      if (!txt) return;
    57122      e.preventDefault();
    58123      document.execCommand('insertText', false, txt);
     124    });
     125
     126    // Drag/drop media onto the editor.
     127    ed.addEventListener('dragover', function (e) {
     128      if (e.dataTransfer && Array.prototype.some.call(e.dataTransfer.types || [], function (t) { return t === 'Files'; })) {
     129        e.preventDefault();
     130        ed.classList.add('re-drop');
     131      }
     132    });
     133    ed.addEventListener('dragleave', function () { ed.classList.remove('re-drop'); });
     134    ed.addEventListener('drop', function (e) {
     135      ed.classList.remove('re-drop');
     136      if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
     137        e.preventDefault();
     138        uploadFiles(e.dataTransfer.files);
     139      }
    59140    });
    60141
     
    72153    if (cancel) cancel.addEventListener('click', function () { setFull(false); });
    73154
    74     // Serialize on submit; block truly empty replies.
     155    // Serialize on submit; block truly empty replies (media-only is fine).
    75156    form.addEventListener('submit', function (e) {
    76157      var html = ed.innerHTML.trim();
    77158      var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
    78       if (!plain) { e.preventDefault(); ed.focus(); return; }
    79       hidden.value = html;
     159      if (!plain && !attachments.length) { e.preventDefault(); ed.focus(); return; }
     160      hidden.value = plain ? html : '';
    80161      ta.value = plain;
    81162      document.documentElement.classList.remove('re-lock');
  • src/config/database.js

    r33e1dbd rfeced2c  
    438438  // Rich replies: the reply's language (BCP47 code) → contentMap on the outgoing Note.
    439439  ensureColumn('ap_outbox', 'language', 'TEXT');
     440  // Rich replies: JSON array [{url, mediaType, name}] → `attachment` on the Note.
     441  ensureColumn('ap_outbox', 'attachments', 'TEXT');
    440442}
    441443
  • src/routes/posts.js

    r33e1dbd rfeced2c  
    3232const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
    3333
     34// Rich replies: media dropped/pasted into the reply editor. Images, audio and
     35// video, stored as-is (no transcode; a reply attachment is not a track).
     36const REPLY_MEDIA_DIR = path.resolve(
     37  process.env.REPLY_MEDIA_PATH ||
     38  path.join(__dirname, '..', '..', 'storage', 'media', 'reply-media')
     39);
     40fs.mkdirSync(REPLY_MEDIA_DIR, { recursive: true });
     41const ALLOWED_REPLY_MEDIA_EXT = new Set([
     42  '.jpg', '.jpeg', '.png', '.webp', '.gif',
     43  '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav',
     44  '.mp4', '.webm', '.mov',
     45]);
     46const MAX_REPLY_MEDIA_BYTES = 32 * 1024 * 1024;
     47const replyMediaUpload = multer({
     48  storage: multer.diskStorage({
     49    destination: (req, file, cb) => cb(null, REPLY_MEDIA_DIR),
     50    filename: (req, file, cb) => cb(null, `${uuid()}${path.extname(file.originalname).toLowerCase()}`),
     51  }),
     52  limits: { fileSize: MAX_REPLY_MEDIA_BYTES },
     53  fileFilter: (req, file, cb) => {
     54    const ext = path.extname(file.originalname).toLowerCase();
     55    if (!ALLOWED_REPLY_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
     56    cb(null, true);
     57  },
     58});
     59
    3460const imageStorage = multer.diskStorage({
    3561  destination: (req, file, cb) => cb(null, POST_IMAGES_DIR),
     
    90116    } catch { /* keep the still image */ }
    91117    res.json({ url, video, size: req.file.size, mime: req.file.mimetype });
     118  });
     119});
     120
     121// Rich replies: media for a reply (image/audio/video). Returns { url, mediaType, name }
     122// exactly as the editor's attachments JSON wants it; deliverReply re-validates.
     123router.post('/posts/upload-reply-media', requireSiteManager, (req, res) => {
     124  replyMediaUpload.single('media')(req, res, (err) => {
     125    if (err) return res.status(400).json({ error: err.message });
     126    if (!req.file) return res.status(400).json({ error: 'No file' });
     127    const mime = String(req.file.mimetype || '');
     128    if (!/^(image|audio|video)\//.test(mime)) {
     129      try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
     130      return res.status(400).json({ error: 'Media must be an image, audio or video file' });
     131    }
     132    res.json({
     133      url: '/media/reply-media/' + req.file.filename,
     134      mediaType: mime,
     135      name: String(req.file.originalname || '').slice(0, 120),
     136    });
    92137  });
    93138});
     
    714759  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
    715760  const language = (req.body.language || '').toString();
    716   if (site && uri && (text.trim() || html.trim())) {
     761  let attachments = [];
     762  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
     763  if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
    717764    // Resolve + deliver in the background so Send responds instantly.
    718765    ActivityPubService.resolveRemoteNote(uri)
    719       .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language }))
     766      .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments }))
    720767      .catch((e) => console.warn('[AP] remote reply failed:', e.message));
    721768  }
     
    12601307  const text = (req.body.text || '').toString();
    12611308  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
    1262   if (parent && parent.post_id === post.id && (text.trim() || html.trim())) {
     1309  let attachments = [];
     1310  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
     1311  if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
    12631312    try {
    12641313      await ActivityPubService.deliverReply(site, {
    1265         postId: post.id, postSlug: post.slug, parent, text, html,
     1314        postId: post.id, postSlug: post.slug, parent, text, html, attachments,
    12661315        language: (req.body.language || '').toString(),
    12671316      });
  • src/services/ActivityPubService.js

    r33e1dbd rfeced2c  
    227227  if (opts.isReply) {
    228228    const meR = actorId(base, site.slug);
     229    // Rich replies: attachments column (JSON [{url, mediaType, name}]) → AS2
     230    // attachment array with absolute URLs and the matching object type.
     231    let replyAtt;
     232    try {
     233      const list = post.attachments ? JSON.parse(post.attachments) : [];
     234      if (Array.isArray(list) && list.length) {
     235        replyAtt = list.map((a) => ({
     236          type: a.mediaType.startsWith('image/') ? 'Image' : a.mediaType.startsWith('audio/') ? 'Audio' : 'Video',
     237          mediaType: a.mediaType,
     238          url: /^https?:/i.test(a.url) ? a.url : `${base}${a.url}`,
     239          name: a.name || undefined,
     240        }));
     241      }
     242    } catch { /* malformed attachments never block the Note */ }
    229243    return {
    230244      id: noteId(base, post.id),
     
    235249      // Reply language (rich replies): the AS2 language map next to `content`.
    236250      contentMap: post.language ? { [post.language]: post.content } : undefined,
     251      attachment: replyAtt,
    237252      url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined,
    238253      published: toISO(post.created_at),
     
    712727    _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at, acted_boost, acted_like, visibility FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
    713728    _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
    714     _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, created_at) VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
     729    _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
    715730    _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
    716731    _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
     
    817832      noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
    818833      mine: true, outboxId: o.id, content: stripLeadingMentions(o.content), created_at: o.created_at,
     834      media: (() => { try { return o.attachments ? JSON.parse(o.attachments) : []; } catch { return []; } })(),
    819835      actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
    820836      children: [],
     
    18561872// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
    18571873// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
    1858 export async function deliverReply(site, { postId, postSlug, parent, text, html, language }) {
     1874export async function deliverReply(site, { postId, postSlug, parent, text, html, language, attachments }) {
    18591875  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    18601876  // Rich replies: `html` is the reply editor's HTML (sanitized here); `text` is
     
    18621878  const richClean = html ? HtmlSanitizerService.sanitize(String(html)) : '';
    18631879  const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
    1864   if (!base || !site || !site.slug || !parent || (!String(text || '').trim() && !rich)) return null;
     1880  // Attachments: only OUR OWN uploads (/media/... paths, no remote URLs — the
     1881  // upload route is the sole producer), image/audio/video only, max 4.
     1882  const media = (Array.isArray(attachments) ? attachments : [])
     1883    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
     1884      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
     1885    .slice(0, 4)
     1886    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
     1887  // A media-only reply (no text) is a valid reply.
     1888  if (!base || !site || !site.slug || !parent || (!String(text || '').trim() && !rich && !media.length)) return null;
    18651889  const me = actorId(base, site.slug);
    18661890  const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
     
    18901914  const replyLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
    18911915  // Dedup: skip if the exact same reply was already sent (double-submit guard).
    1892   const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
    1893     .get(site.slug, parent.object_uri || '', content);
     1916  // Attachments count toward "the same": two media-only replies share content.
     1917  const mediaJson = media.length ? JSON.stringify(media) : null;
     1918  const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? AND IFNULL(attachments, \'\') = IFNULL(?, \'\') LIMIT 1')
     1919    .get(site.slug, parent.object_uri || '', content, mediaJson);
    18941920  if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
    18951921  const id = crypto.randomUUID();
    1896   iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content, replyLang);
     1922  iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content, replyLang, mediaJson);
    18971923  const row = iStmts().getO.get(id);
    18981924  const note = buildReplyNote(base, site, row);
  • src/services/i18n.js

    r33e1dbd rfeced2c  
    113113    'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse',
    114114    'fedi.reply': 'Reageer', 'fedi.reply_ph': 'Je antwoord aan de fediverse…', 'fedi.send': 'Versturen', 'fedi.you': 'Jij',
    115     'fedi.remote_title': 'Reageer via de fediverse', 'fedi.follow_heading': 'Volgen via de fediverse', 'fedi.profile_follow': 'Volg via de fediverse', 'profile.since': 'Op Klonkt sinds', 'profile.free': 'Gratis', 'fedi.follow_intro': 'Je staat op het punt te volgen:', 'fedi.follow_btn': 'Volgen', 'fedi.cancel': 'Annuleren', 'fedi.followed_title': 'Volgverzoek verstuurd ✅', 'fedi.followed_done': 'Je volgverzoek is onderweg. Zodra de andere kant het accepteert, verschijnen hun berichten in je tijdlijn.', 'fedi.view_profile': 'Bekijk profiel →', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres:', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Like intrekken', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'jouw server', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is verstuurd. Hij verschijnt zo bij de originele post op de fediverse, niet op deze pagina. Bekijk hem daar:', 'fedi.reply_where': 'Je reactie verschijnt bij de originele post op de fediverse, niet op deze pagina. Via de link hierboven zie je hem daar.', 'fedi.remote_back': '← Terug naar je site', 'fedi.like_btn': 'Like deze post', 'fedi.or_reply': 'of reageer:', 'fedi.liked_title': 'Geliket', 'fedi.liked_done': 'Je like is onderweg naar de fediverse.', 'fedi.boost_btn': 'Boost deze post', 'fedi.boosted_title': 'Geboost', 'fedi.boosted_done': 'Je boost is onderweg naar de fediverse.', 'fedi.remote_interact': 'Interageer via de fediverse', 'fedi.report_open': 'Deze post rapporteren', 'fedi.report_where': 'De melding gaat naar de instance en hun moderator(s).', 'fedi.report_ph': 'Wat is er mis? (optioneel)', 'fedi.report_send': 'Rapporteren', 'fedi.reported_title': 'Gerapporteerd', 'fedi.reported_done': 'Je melding is naar de server van de gebruiker gestuurd. Hun moderators bekijken het.', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.mod_remove_confirm': 'Deze reactie uit je thread verwijderen? Hij komt niet terug, ook niet via thread-aanvulling.', 'fedi.mod_report_confirm': 'Deze reactie rapporteren bij de server van de auteur?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 'fedi.goto_post': 'Naar de post', 'fedi.edit': 'Bewerken', 'fedi.save_edit': 'Opslaan', 'fedi.bm_label': 'Interageer via mijn site', 'fedi.bm_help': 'Sleep deze knop naar je bladwijzerbalk. Klik ’m daarna op elke fediverse-post (Mastodon, een andere Klonkt…) om er via jouw site op te reageren, te liken of te boosten.', 'tl.title': 'Krant', 'tl.lead': 'Volg accounts in de fediverse en zie hun berichten hier.', 'tl.follow_btn': 'Volgen', 'tl.following': 'Je volgt', 'tl.unfollow': 'Ontvolgen', 'tl.autoboost': 'Uitgelicht', 'tl.autoboost_follow': 'uitlichten in cirkel', 'tl.autoboost_hint': 'Hun nieuwe posts verschijnen doorlopend in jouw Cirkel (lokaal, geen fediverse-boost).', 'tl.pending': 'in afwachting', 'tl.unboost': 'Boost intrekken', 'tl.feed': 'Berichten', 'tl.tab_feed': 'Krant', 'tl.tab_following': 'Volgend', 'tl.tab_replies': 'Reacties', 'tl.tab_followers': 'Volgers', 'tl.followers': 'Volgers', 'tl.followers_lead': 'Wie jou volgt in de fediverse, met de laatste geslaagde bezorging. Rood = nog nooit bezorgd of laatste poging mislukt — kandidaat om op te ruimen na een check.', 'tl.empty_followers': 'Nog geen volgers.', 'tl.last_delivery': 'Laatste bezorging', 'tl.never_delivered': 'Nog nooit bezorgd', 'tl.delivery_failed': 'laatste poging mislukt', 'tl.remove_follower': 'Verwijderen', 'tl.remove_confirm': 'Deze volger verwijderen? Een actief account moet je dan opnieuw volgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'jij volgt', 'tl.dir_follower': 'volgt jou', 'tl.dir_mutual': 'wederzijds', 'tl.connect_empty': 'Nog geen connecties. Volg iemand hierboven om te beginnen.', 'tl.unreachable': 'Niet bereikbaar', 'tl.unreachable_lead': 'Deze volgers konden we niet bereiken (nooit bezorgd of laatste poging mislukt). Ruim ze op na een handmatige check.', 'msg.tab': 'Berichten', 'msg.title': 'Berichten', 'msg.filter_all': 'Alles', 'msg.filter_conv': 'Gesprekken', 'msg.filter_act': 'Activiteit', 'msg.filter_sent': 'Verzonden', 'msg.you': 'Jij', 'msg.sent_reply': 'reageerde via de fediverse', 'msg.and_more': 'en {n} anderen', 'msg.liked_many': 'liketen je post', 'msg.boosted_many': 'boostten je post', 'msg.private': 'privé', 'msg.private_hint': 'Alleen aan jou gericht; staat niet op de publieke postpagina.', 'msg.new': 'Nieuw sinds je laatste bezoek', 'msg.empty': 'Nog geen berichten. Reacties, vermeldingen en activiteit verschijnen hier.', 'oauth.title': 'App toegang geven', 'oauth.wants_access': 'wil verbinding maken met je Klonkt-account.', 'oauth.post_as': 'Plaatsen als', 'oauth.scope_read': 'Je berichten, reacties en meldingen lezen', 'oauth.scope_write': 'Namens jou posten, reageren, liken en volgen', 'oauth.allow': 'Toestaan', 'oauth.deny': 'Weigeren', 'oauth.foot': 'Je kunt de toegang later intrekken. Geef alleen apps toegang die je vertrouwt.', 're.title': 'Reactie schrijven', 're.bold': 'Vet', 're.italic': 'Cursief', 're.link': 'Link invoegen', 're.list': 'Opsomming', 're.quote': 'Citaat', 're.lang': 'Taal van je reactie', 'tl.empty_following': 'Je volgt nog niemand.', 'tl.empty': 'Nog niks — volg iemand om hun berichten hier te zien.', 'tl.view_original': 'Bekijk origineel →', 'tl.open_player': 'Open de speler', 'tl.paste_ph': 'Plak een fediverse-post-URL', 'tl.paste_go': 'Openen', 'tl.boosted': 'boostte dit', 'tl.read_more': 'Meer lezen', 'tl.show_less': 'Minder', 'poll.vote': 'Stem', 'poll.votes': 'stemmen', 'poll.closed': 'gesloten', 'poll.open': 'open', 'poll.aria': 'Peiling', 'poll.voter_one': 'stemmer', 'poll.voter_many': 'stemmers', 'poll.closes': 'sluit op', 'poll.multiple': 'meerkeuze', 'poll.fedi_only': 'Stemmen kan vanuit de fediverse — volg deze site en stem in je eigen app.', 'poll.voted_title': 'Stem verstuurd', 'poll.voted_done': 'Je stem is verstuurd naar de poll. De uitslag werkt bij zodra de maker die doorstuurt.',
     115    'fedi.remote_title': 'Reageer via de fediverse', 'fedi.follow_heading': 'Volgen via de fediverse', 'fedi.profile_follow': 'Volg via de fediverse', 'profile.since': 'Op Klonkt sinds', 'profile.free': 'Gratis', 'fedi.follow_intro': 'Je staat op het punt te volgen:', 'fedi.follow_btn': 'Volgen', 'fedi.cancel': 'Annuleren', 'fedi.followed_title': 'Volgverzoek verstuurd ✅', 'fedi.followed_done': 'Je volgverzoek is onderweg. Zodra de andere kant het accepteert, verschijnen hun berichten in je tijdlijn.', 'fedi.view_profile': 'Bekijk profiel →', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres:', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Like intrekken', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'jouw server', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is verstuurd. Hij verschijnt zo bij de originele post op de fediverse, niet op deze pagina. Bekijk hem daar:', 'fedi.reply_where': 'Je reactie verschijnt bij de originele post op de fediverse, niet op deze pagina. Via de link hierboven zie je hem daar.', 'fedi.remote_back': '← Terug naar je site', 'fedi.like_btn': 'Like deze post', 'fedi.or_reply': 'of reageer:', 'fedi.liked_title': 'Geliket', 'fedi.liked_done': 'Je like is onderweg naar de fediverse.', 'fedi.boost_btn': 'Boost deze post', 'fedi.boosted_title': 'Geboost', 'fedi.boosted_done': 'Je boost is onderweg naar de fediverse.', 'fedi.remote_interact': 'Interageer via de fediverse', 'fedi.report_open': 'Deze post rapporteren', 'fedi.report_where': 'De melding gaat naar de instance en hun moderator(s).', 'fedi.report_ph': 'Wat is er mis? (optioneel)', 'fedi.report_send': 'Rapporteren', 'fedi.reported_title': 'Gerapporteerd', 'fedi.reported_done': 'Je melding is naar de server van de gebruiker gestuurd. Hun moderators bekijken het.', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.mod_remove_confirm': 'Deze reactie uit je thread verwijderen? Hij komt niet terug, ook niet via thread-aanvulling.', 'fedi.mod_report_confirm': 'Deze reactie rapporteren bij de server van de auteur?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 'fedi.goto_post': 'Naar de post', 'fedi.edit': 'Bewerken', 'fedi.save_edit': 'Opslaan', 'fedi.bm_label': 'Interageer via mijn site', 'fedi.bm_help': 'Sleep deze knop naar je bladwijzerbalk. Klik ’m daarna op elke fediverse-post (Mastodon, een andere Klonkt…) om er via jouw site op te reageren, te liken of te boosten.', 'tl.title': 'Krant', 'tl.lead': 'Volg accounts in de fediverse en zie hun berichten hier.', 'tl.follow_btn': 'Volgen', 'tl.following': 'Je volgt', 'tl.unfollow': 'Ontvolgen', 'tl.autoboost': 'Uitgelicht', 'tl.autoboost_follow': 'uitlichten in cirkel', 'tl.autoboost_hint': 'Hun nieuwe posts verschijnen doorlopend in jouw Cirkel (lokaal, geen fediverse-boost).', 'tl.pending': 'in afwachting', 'tl.unboost': 'Boost intrekken', 'tl.feed': 'Berichten', 'tl.tab_feed': 'Krant', 'tl.tab_following': 'Volgend', 'tl.tab_replies': 'Reacties', 'tl.tab_followers': 'Volgers', 'tl.followers': 'Volgers', 'tl.followers_lead': 'Wie jou volgt in de fediverse, met de laatste geslaagde bezorging. Rood = nog nooit bezorgd of laatste poging mislukt — kandidaat om op te ruimen na een check.', 'tl.empty_followers': 'Nog geen volgers.', 'tl.last_delivery': 'Laatste bezorging', 'tl.never_delivered': 'Nog nooit bezorgd', 'tl.delivery_failed': 'laatste poging mislukt', 'tl.remove_follower': 'Verwijderen', 'tl.remove_confirm': 'Deze volger verwijderen? Een actief account moet je dan opnieuw volgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'jij volgt', 'tl.dir_follower': 'volgt jou', 'tl.dir_mutual': 'wederzijds', 'tl.connect_empty': 'Nog geen connecties. Volg iemand hierboven om te beginnen.', 'tl.unreachable': 'Niet bereikbaar', 'tl.unreachable_lead': 'Deze volgers konden we niet bereiken (nooit bezorgd of laatste poging mislukt). Ruim ze op na een handmatige check.', 'msg.tab': 'Berichten', 'msg.title': 'Berichten', 'msg.filter_all': 'Alles', 'msg.filter_conv': 'Gesprekken', 'msg.filter_act': 'Activiteit', 'msg.filter_sent': 'Verzonden', 'msg.you': 'Jij', 'msg.sent_reply': 'reageerde via de fediverse', 'msg.and_more': 'en {n} anderen', 'msg.liked_many': 'liketen je post', 'msg.boosted_many': 'boostten je post', 'msg.private': 'privé', 'msg.private_hint': 'Alleen aan jou gericht; staat niet op de publieke postpagina.', 'msg.new': 'Nieuw sinds je laatste bezoek', 'msg.empty': 'Nog geen berichten. Reacties, vermeldingen en activiteit verschijnen hier.', 'oauth.title': 'App toegang geven', 'oauth.wants_access': 'wil verbinding maken met je Klonkt-account.', 'oauth.post_as': 'Plaatsen als', 'oauth.scope_read': 'Je berichten, reacties en meldingen lezen', 'oauth.scope_write': 'Namens jou posten, reageren, liken en volgen', 'oauth.allow': 'Toestaan', 'oauth.deny': 'Weigeren', 'oauth.foot': 'Je kunt de toegang later intrekken. Geef alleen apps toegang die je vertrouwt.', 're.title': 'Reactie schrijven', 're.bold': 'Vet', 're.italic': 'Cursief', 're.link': 'Link invoegen', 're.list': 'Opsomming', 're.quote': 'Citaat', 're.lang': 'Taal van je reactie', 're.attach': 'Media toevoegen (afbeelding, audio, video)', 're.attach_err': 'Upload mislukt', 'tl.empty_following': 'Je volgt nog niemand.', 'tl.empty': 'Nog niks — volg iemand om hun berichten hier te zien.', 'tl.view_original': 'Bekijk origineel →', 'tl.open_player': 'Open de speler', 'tl.paste_ph': 'Plak een fediverse-post-URL', 'tl.paste_go': 'Openen', 'tl.boosted': 'boostte dit', 'tl.read_more': 'Meer lezen', 'tl.show_less': 'Minder', 'poll.vote': 'Stem', 'poll.votes': 'stemmen', 'poll.closed': 'gesloten', 'poll.open': 'open', 'poll.aria': 'Peiling', 'poll.voter_one': 'stemmer', 'poll.voter_many': 'stemmers', 'poll.closes': 'sluit op', 'poll.multiple': 'meerkeuze', 'poll.fedi_only': 'Stemmen kan vanuit de fediverse — volg deze site en stem in je eigen app.', 'poll.voted_title': 'Stem verstuurd', 'poll.voted_done': 'Je stem is verstuurd naar de poll. De uitslag werkt bij zodra de maker die doorstuurt.',
    116116    'comments.to_start': 'om de conversatie te starten.',
    117117    'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren',
     
    10421042    'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse',
    10431043    'fedi.reply': 'Reply', 'fedi.reply_ph': 'Your reply to the fediverse…', 'fedi.send': 'Send', 'fedi.you': 'You',
    1044     'fedi.remote_title': 'Reply via the fediverse', 'fedi.follow_heading': 'Follow via the fediverse', 'fedi.profile_follow': 'Follow via the fediverse', 'profile.since': 'On Klonkt since', 'profile.free': 'Free', 'fedi.follow_intro': 'You are about to follow:', 'fedi.follow_btn': 'Follow', 'fedi.cancel': 'Cancel', 'fedi.followed_title': 'Follow request sent ✅', 'fedi.followed_done': 'Your follow request is on its way. Once accepted, their posts show up in your timeline.', 'fedi.view_profile': 'View profile →', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address:', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Unlike', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'your server', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply has been sent. It will show up on the original post on the fediverse, not on this page. See it there:', 'fedi.reply_where': 'Your reply appears on the original post on the fediverse, not on this page. Use the link above to see it there.', 'fedi.remote_back': '← Back to your site', 'fedi.like_btn': 'Like this post', 'fedi.or_reply': 'or reply:', 'fedi.liked_title': 'Liked', 'fedi.liked_done': 'Your like is on its way to the fediverse.', 'fedi.boost_btn': 'Boost this post', 'fedi.boosted_title': 'Boosted', 'fedi.boosted_done': 'Your boost is on its way to the fediverse.', 'fedi.remote_interact': 'Interact via the fediverse', 'fedi.report_open': 'Report this post', 'fedi.report_where': 'The report goes to the instance and their moderator(s).', 'fedi.report_ph': 'What is wrong? (optional)', 'fedi.report_send': 'Report', 'fedi.reported_title': 'Reported', 'fedi.reported_done': 'Your report has been sent to their server. Their moderators will review it.', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.mod_remove_confirm': 'Remove this reply from your thread? It will not come back, not even via thread-filling.', 'fedi.mod_report_confirm': 'Report this reply to its author’s server?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 'fedi.goto_post': 'Go to post', 'fedi.edit': 'Edit', 'fedi.save_edit': 'Save', 'fedi.bm_label': 'Interact via my site', 'fedi.bm_help': 'Drag this button to your bookmarks bar. Then click it on any fediverse post (Mastodon, another Klonkt…) to reply, like or boost it via your own site.', 'tl.title': 'News', 'tl.lead': 'Follow accounts on the fediverse and see their posts here.', 'tl.follow_btn': 'Follow', 'tl.following': 'Following', 'tl.unfollow': 'Unfollow', 'tl.autoboost': 'Featured', 'tl.autoboost_follow': 'feature in circle', 'tl.autoboost_hint': 'Their new posts keep showing in your Circle (local, no fediverse boost).', 'tl.pending': 'pending', 'tl.unboost': 'Unboost', 'tl.feed': 'Posts', 'tl.tab_feed': 'News', 'tl.tab_following': 'Following', 'tl.tab_replies': 'Replies', 'tl.tab_followers': 'Followers', 'tl.followers': 'Followers', 'tl.followers_lead': 'Who follows you on the fediverse, with the last successful delivery. Red = never delivered or last attempt failed — a candidate to clean up after a check.', 'tl.empty_followers': 'No followers yet.', 'tl.last_delivery': 'Last delivery', 'tl.never_delivered': 'Never delivered', 'tl.delivery_failed': 'last attempt failed', 'tl.remove_follower': 'Remove', 'tl.remove_confirm': 'Remove this follower? An active account would have to follow you again.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'you follow', 'tl.dir_follower': 'follows you', 'tl.dir_mutual': 'mutual', 'tl.connect_empty': 'No connections yet. Follow someone above to get started.', 'tl.unreachable': 'Unreachable', 'tl.unreachable_lead': 'We could not reach these followers (never delivered or last attempt failed). Clean them up after a manual check.', 'msg.tab': 'Messages', 'msg.title': 'Messages', 'msg.filter_all': 'All', 'msg.filter_conv': 'Conversations', 'msg.filter_act': 'Activity', 'msg.filter_sent': 'Sent', 'msg.you': 'You', 'msg.sent_reply': 'replied via the fediverse', 'msg.and_more': 'and {n} others', 'msg.liked_many': 'liked your post', 'msg.boosted_many': 'boosted your post', 'msg.private': 'private', 'msg.private_hint': 'Addressed to you only; not shown on the public post page.', 'msg.new': 'New since your last visit', 'msg.empty': 'No messages yet. Replies, mentions and activity show up here.', 'oauth.title': 'Authorize app', 'oauth.wants_access': 'wants to connect to your Klonkt account.', 'oauth.post_as': 'Post as', 'oauth.scope_read': 'Read your posts, replies and notifications', 'oauth.scope_write': 'Post, reply, like and follow on your behalf', 'oauth.allow': 'Allow', 'oauth.deny': 'Deny', 'oauth.foot': 'You can revoke access later. Only authorize apps you trust.', 're.title': 'Write a reply', 're.bold': 'Bold', 're.italic': 'Italic', 're.link': 'Insert link', 're.list': 'Bullet list', 're.quote': 'Quote', 're.lang': 'Language of your reply', 'tl.empty_following': 'You do not follow anyone yet.', 'tl.empty': 'Nothing yet — follow someone to see their posts here.', 'tl.view_original': 'View original →', 'tl.open_player': 'Open the player', 'tl.paste_ph': 'Paste a fediverse post URL', 'tl.paste_go': 'Open', 'tl.boosted': 'boosted', 'tl.read_more': 'Read more', 'tl.show_less': 'Show less', 'poll.vote': 'Vote', 'poll.votes': 'votes', 'poll.closed': 'closed', 'poll.open': 'open', 'poll.aria': 'Poll', 'poll.voter_one': 'voter', 'poll.voter_many': 'voters', 'poll.closes': 'closes', 'poll.multiple': 'multiple choice', 'poll.fedi_only': 'Voting happens on the fediverse — follow this site and vote from your own app.', 'poll.voted_title': 'Vote sent', 'poll.voted_done': 'Your vote is on its way to the poll. The results refresh once the author sends the update.',
     1044    'fedi.remote_title': 'Reply via the fediverse', 'fedi.follow_heading': 'Follow via the fediverse', 'fedi.profile_follow': 'Follow via the fediverse', 'profile.since': 'On Klonkt since', 'profile.free': 'Free', 'fedi.follow_intro': 'You are about to follow:', 'fedi.follow_btn': 'Follow', 'fedi.cancel': 'Cancel', 'fedi.followed_title': 'Follow request sent ✅', 'fedi.followed_done': 'Your follow request is on its way. Once accepted, their posts show up in your timeline.', 'fedi.view_profile': 'View profile →', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address:', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Unlike', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'your server', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply has been sent. It will show up on the original post on the fediverse, not on this page. See it there:', 'fedi.reply_where': 'Your reply appears on the original post on the fediverse, not on this page. Use the link above to see it there.', 'fedi.remote_back': '← Back to your site', 'fedi.like_btn': 'Like this post', 'fedi.or_reply': 'or reply:', 'fedi.liked_title': 'Liked', 'fedi.liked_done': 'Your like is on its way to the fediverse.', 'fedi.boost_btn': 'Boost this post', 'fedi.boosted_title': 'Boosted', 'fedi.boosted_done': 'Your boost is on its way to the fediverse.', 'fedi.remote_interact': 'Interact via the fediverse', 'fedi.report_open': 'Report this post', 'fedi.report_where': 'The report goes to the instance and their moderator(s).', 'fedi.report_ph': 'What is wrong? (optional)', 'fedi.report_send': 'Report', 'fedi.reported_title': 'Reported', 'fedi.reported_done': 'Your report has been sent to their server. Their moderators will review it.', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.mod_remove_confirm': 'Remove this reply from your thread? It will not come back, not even via thread-filling.', 'fedi.mod_report_confirm': 'Report this reply to its author’s server?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 'fedi.goto_post': 'Go to post', 'fedi.edit': 'Edit', 'fedi.save_edit': 'Save', 'fedi.bm_label': 'Interact via my site', 'fedi.bm_help': 'Drag this button to your bookmarks bar. Then click it on any fediverse post (Mastodon, another Klonkt…) to reply, like or boost it via your own site.', 'tl.title': 'News', 'tl.lead': 'Follow accounts on the fediverse and see their posts here.', 'tl.follow_btn': 'Follow', 'tl.following': 'Following', 'tl.unfollow': 'Unfollow', 'tl.autoboost': 'Featured', 'tl.autoboost_follow': 'feature in circle', 'tl.autoboost_hint': 'Their new posts keep showing in your Circle (local, no fediverse boost).', 'tl.pending': 'pending', 'tl.unboost': 'Unboost', 'tl.feed': 'Posts', 'tl.tab_feed': 'News', 'tl.tab_following': 'Following', 'tl.tab_replies': 'Replies', 'tl.tab_followers': 'Followers', 'tl.followers': 'Followers', 'tl.followers_lead': 'Who follows you on the fediverse, with the last successful delivery. Red = never delivered or last attempt failed — a candidate to clean up after a check.', 'tl.empty_followers': 'No followers yet.', 'tl.last_delivery': 'Last delivery', 'tl.never_delivered': 'Never delivered', 'tl.delivery_failed': 'last attempt failed', 'tl.remove_follower': 'Remove', 'tl.remove_confirm': 'Remove this follower? An active account would have to follow you again.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'you follow', 'tl.dir_follower': 'follows you', 'tl.dir_mutual': 'mutual', 'tl.connect_empty': 'No connections yet. Follow someone above to get started.', 'tl.unreachable': 'Unreachable', 'tl.unreachable_lead': 'We could not reach these followers (never delivered or last attempt failed). Clean them up after a manual check.', 'msg.tab': 'Messages', 'msg.title': 'Messages', 'msg.filter_all': 'All', 'msg.filter_conv': 'Conversations', 'msg.filter_act': 'Activity', 'msg.filter_sent': 'Sent', 'msg.you': 'You', 'msg.sent_reply': 'replied via the fediverse', 'msg.and_more': 'and {n} others', 'msg.liked_many': 'liked your post', 'msg.boosted_many': 'boosted your post', 'msg.private': 'private', 'msg.private_hint': 'Addressed to you only; not shown on the public post page.', 'msg.new': 'New since your last visit', 'msg.empty': 'No messages yet. Replies, mentions and activity show up here.', 'oauth.title': 'Authorize app', 'oauth.wants_access': 'wants to connect to your Klonkt account.', 'oauth.post_as': 'Post as', 'oauth.scope_read': 'Read your posts, replies and notifications', 'oauth.scope_write': 'Post, reply, like and follow on your behalf', 'oauth.allow': 'Allow', 'oauth.deny': 'Deny', 'oauth.foot': 'You can revoke access later. Only authorize apps you trust.', 're.title': 'Write a reply', 're.bold': 'Bold', 're.italic': 'Italic', 're.link': 'Insert link', 're.list': 'Bullet list', 're.quote': 'Quote', 're.lang': 'Language of your reply', 're.attach': 'Add media (image, audio, video)', 're.attach_err': 'Upload failed', 'tl.empty_following': 'You do not follow anyone yet.', 'tl.empty': 'Nothing yet — follow someone to see their posts here.', 'tl.view_original': 'View original →', 'tl.open_player': 'Open the player', 'tl.paste_ph': 'Paste a fediverse post URL', 'tl.paste_go': 'Open', 'tl.boosted': 'boosted', 'tl.read_more': 'Read more', 'tl.show_less': 'Show less', 'poll.vote': 'Vote', 'poll.votes': 'votes', 'poll.closed': 'closed', 'poll.open': 'open', 'poll.aria': 'Poll', 'poll.voter_one': 'voter', 'poll.voter_many': 'voters', 'poll.closes': 'closes', 'poll.multiple': 'multiple choice', 'poll.fedi_only': 'Voting happens on the fediverse — follow this site and vote from your own app.', 'poll.voted_title': 'Vote sent', 'poll.voted_done': 'Your vote is on its way to the poll. The results refresh once the author sends the update.',
    10451045    'comments.to_start': 'to start the conversation.',
    10461046    'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel',
     
    19691969    'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse',
    19701970    'fedi.reply': 'Antworten', 'fedi.reply_ph': 'Deine Antwort an das Fediverse…', 'fedi.send': 'Senden', 'fedi.you': 'Du',
    1971     'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.follow_heading': 'Über das Fediverse folgen', 'fedi.profile_follow': 'Über das Fediverse folgen', 'profile.since': 'Auf Klonkt seit', 'profile.free': 'Kostenlos', 'fedi.follow_intro': 'Du folgst gleich:', 'fedi.follow_btn': 'Folgen', 'fedi.cancel': 'Abbrechen', 'fedi.followed_title': 'Folge-Anfrage gesendet ✅', 'fedi.followed_done': 'Deine Folge-Anfrage ist unterwegs. Sobald sie akzeptiert wird, erscheinen ihre Beiträge in deiner Timeline.', 'fedi.view_profile': 'Profil ansehen →', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse:', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.like_short': 'Liken', 'fedi.unlike_short': 'Like zurücknehmen', 'fedi.boost_short': 'Boosten', 'fedi.remote_ph': 'dein Server', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort wurde gesendet. Sie erscheint gleich beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Sieh sie dir dort an:', 'fedi.reply_where': 'Deine Antwort erscheint beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Über den Link oben siehst du sie dort.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.like_btn': 'Diesen Beitrag liken', 'fedi.or_reply': 'oder antworten:', 'fedi.liked_title': 'Geliked', 'fedi.liked_done': 'Dein Like ist unterwegs ins Fediverse.', 'fedi.boost_btn': 'Diesen Beitrag boosten', 'fedi.boosted_title': 'Geboostet', 'fedi.boosted_done': 'Dein Boost ist unterwegs ins Fediverse.', 'fedi.remote_interact': 'Übers Fediverse interagieren', 'fedi.report_open': 'Diesen Beitrag melden', 'fedi.report_where': 'Die Meldung geht an die Instanz und deren Moderator(en).', 'fedi.report_ph': 'Was ist das Problem? (optional)', 'fedi.report_send': 'Melden', 'fedi.reported_title': 'Gemeldet', 'fedi.reported_done': 'Deine Meldung wurde an den Server der Person gesendet. Deren Moderatoren prüfen sie.', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.mod_remove_confirm': 'Diese Antwort aus deinem Thread entfernen? Sie kommt nicht zurück, auch nicht über Thread-Auffüllung.', 'fedi.mod_report_confirm': 'Diese Antwort beim Server des Autors melden?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 'fedi.goto_post': 'Zur Post', 'fedi.edit': 'Bearbeiten', 'fedi.save_edit': 'Speichern', 'fedi.bm_label': 'Über meine Seite interagieren', 'fedi.bm_help': 'Zieh diesen Button in deine Lesezeichenleiste. Klick ihn dann auf einem beliebigen Fediverse-Beitrag (Mastodon, ein anderes Klonkt…), um über deine eigene Seite zu antworten, zu liken oder zu boosten.', 'tl.title': 'Zeitung', 'tl.lead': 'Folge Konten im Fediverse und sieh ihre Beiträge hier.', 'tl.follow_btn': 'Folgen', 'tl.following': 'Du folgst', 'tl.unfollow': 'Entfolgen', 'tl.autoboost': 'Hervorgehoben', 'tl.autoboost_follow': 'im Zirkel hervorheben', 'tl.autoboost_hint': 'Ihre neuen Beiträge erscheinen laufend in deinem Zirkel (lokal, kein Fediverse-Boost).', 'tl.pending': 'ausstehend', 'tl.unboost': 'Boost zurücknehmen', 'tl.feed': 'Beiträge', 'tl.tab_feed': 'Zeitung', 'tl.tab_following': 'Folge ich', 'tl.tab_replies': 'Antworten', 'tl.tab_followers': 'Follower', 'tl.followers': 'Follower', 'tl.followers_lead': 'Wer dir im Fediverse folgt, mit der letzten erfolgreichen Zustellung. Rot = nie zugestellt oder letzter Versuch fehlgeschlagen — nach einer Prüfung ein Kandidat zum Aufräumen.', 'tl.empty_followers': 'Noch keine Follower.', 'tl.last_delivery': 'Letzte Zustellung', 'tl.never_delivered': 'Nie zugestellt', 'tl.delivery_failed': 'letzter Versuch fehlgeschlagen', 'tl.remove_follower': 'Entfernen', 'tl.remove_confirm': 'Diesen Follower entfernen? Ein aktives Konto müsste dir erneut folgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'du folgst', 'tl.dir_follower': 'folgt dir', 'tl.dir_mutual': 'gegenseitig', 'tl.connect_empty': 'Noch keine Verbindungen. Folge oben jemandem, um zu starten.', 'tl.unreachable': 'Nicht erreichbar', 'tl.unreachable_lead': 'Diese Follower konnten wir nicht erreichen (nie zugestellt oder letzter Versuch fehlgeschlagen). Räume sie nach einer Prüfung auf.', 'msg.tab': 'Nachrichten', 'msg.title': 'Nachrichten', 'msg.filter_all': 'Alle', 'msg.filter_conv': 'Gespräche', 'msg.filter_act': 'Aktivität', 'msg.filter_sent': 'Gesendet', 'msg.you': 'Du', 'msg.sent_reply': 'hat über das Fediverse geantwortet', 'msg.and_more': 'und {n} andere', 'msg.liked_many': 'gefällt dein Beitrag', 'msg.boosted_many': 'teilten deinen Beitrag', 'msg.private': 'privat', 'msg.private_hint': 'Nur an dich gerichtet; erscheint nicht auf der öffentlichen Beitragsseite.', 'msg.new': 'Neu seit deinem letzten Besuch', 'msg.empty': 'Noch keine Nachrichten. Antworten, Erwähnungen und Aktivität erscheinen hier.', 'oauth.title': 'App autorisieren', 'oauth.wants_access': 'möchte sich mit deinem Klonkt-Konto verbinden.', 'oauth.post_as': 'Posten als', 'oauth.scope_read': 'Deine Beiträge, Antworten und Meldungen lesen', 'oauth.scope_write': 'In deinem Namen posten, antworten, liken und folgen', 'oauth.allow': 'Erlauben', 'oauth.deny': 'Ablehnen', 'oauth.foot': 'Du kannst den Zugriff später widerrufen. Autorisiere nur Apps, denen du vertraust.', 're.title': 'Antwort schreiben', 're.bold': 'Fett', 're.italic': 'Kursiv', 're.link': 'Link einfügen', 're.list': 'Aufzählung', 're.quote': 'Zitat', 're.lang': 'Sprache deiner Antwort', 'tl.empty_following': 'Du folgst noch niemandem.', 'tl.empty': 'Noch nichts — folge jemandem, um Beiträge hier zu sehen.', 'tl.view_original': 'Original ansehen →', 'tl.open_player': 'Player öffnen', 'tl.paste_ph': 'URL eines Fediverse-Beitrags einfügen', 'tl.paste_go': 'Öffnen', 'tl.boosted': 'hat geteilt', 'tl.read_more': 'Mehr lesen', 'tl.show_less': 'Weniger', 'poll.vote': 'Abstimmen', 'poll.votes': 'Stimmen', 'poll.closed': 'geschlossen', 'poll.open': 'offen', 'poll.aria': 'Umfrage', 'poll.voter_one': 'Teilnehmer', 'poll.voter_many': 'Teilnehmer', 'poll.closes': 'endet am', 'poll.multiple': 'Mehrfachauswahl', 'poll.fedi_only': 'Abstimmen geht über das Fediverse — folge dieser Seite und stimme in deiner eigenen App ab.', 'poll.voted_title': 'Stimme gesendet', 'poll.voted_done': 'Deine Stimme ist unterwegs zur Umfrage. Die Ergebnisse aktualisieren sich, sobald die Autorin oder der Autor das Update sendet.',
     1971    'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.follow_heading': 'Über das Fediverse folgen', 'fedi.profile_follow': 'Über das Fediverse folgen', 'profile.since': 'Auf Klonkt seit', 'profile.free': 'Kostenlos', 'fedi.follow_intro': 'Du folgst gleich:', 'fedi.follow_btn': 'Folgen', 'fedi.cancel': 'Abbrechen', 'fedi.followed_title': 'Folge-Anfrage gesendet ✅', 'fedi.followed_done': 'Deine Folge-Anfrage ist unterwegs. Sobald sie akzeptiert wird, erscheinen ihre Beiträge in deiner Timeline.', 'fedi.view_profile': 'Profil ansehen →', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse:', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.like_short': 'Liken', 'fedi.unlike_short': 'Like zurücknehmen', 'fedi.boost_short': 'Boosten', 'fedi.remote_ph': 'dein Server', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort wurde gesendet. Sie erscheint gleich beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Sieh sie dir dort an:', 'fedi.reply_where': 'Deine Antwort erscheint beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Über den Link oben siehst du sie dort.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.like_btn': 'Diesen Beitrag liken', 'fedi.or_reply': 'oder antworten:', 'fedi.liked_title': 'Geliked', 'fedi.liked_done': 'Dein Like ist unterwegs ins Fediverse.', 'fedi.boost_btn': 'Diesen Beitrag boosten', 'fedi.boosted_title': 'Geboostet', 'fedi.boosted_done': 'Dein Boost ist unterwegs ins Fediverse.', 'fedi.remote_interact': 'Übers Fediverse interagieren', 'fedi.report_open': 'Diesen Beitrag melden', 'fedi.report_where': 'Die Meldung geht an die Instanz und deren Moderator(en).', 'fedi.report_ph': 'Was ist das Problem? (optional)', 'fedi.report_send': 'Melden', 'fedi.reported_title': 'Gemeldet', 'fedi.reported_done': 'Deine Meldung wurde an den Server der Person gesendet. Deren Moderatoren prüfen sie.', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.mod_remove_confirm': 'Diese Antwort aus deinem Thread entfernen? Sie kommt nicht zurück, auch nicht über Thread-Auffüllung.', 'fedi.mod_report_confirm': 'Diese Antwort beim Server des Autors melden?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 'fedi.goto_post': 'Zur Post', 'fedi.edit': 'Bearbeiten', 'fedi.save_edit': 'Speichern', 'fedi.bm_label': 'Über meine Seite interagieren', 'fedi.bm_help': 'Zieh diesen Button in deine Lesezeichenleiste. Klick ihn dann auf einem beliebigen Fediverse-Beitrag (Mastodon, ein anderes Klonkt…), um über deine eigene Seite zu antworten, zu liken oder zu boosten.', 'tl.title': 'Zeitung', 'tl.lead': 'Folge Konten im Fediverse und sieh ihre Beiträge hier.', 'tl.follow_btn': 'Folgen', 'tl.following': 'Du folgst', 'tl.unfollow': 'Entfolgen', 'tl.autoboost': 'Hervorgehoben', 'tl.autoboost_follow': 'im Zirkel hervorheben', 'tl.autoboost_hint': 'Ihre neuen Beiträge erscheinen laufend in deinem Zirkel (lokal, kein Fediverse-Boost).', 'tl.pending': 'ausstehend', 'tl.unboost': 'Boost zurücknehmen', 'tl.feed': 'Beiträge', 'tl.tab_feed': 'Zeitung', 'tl.tab_following': 'Folge ich', 'tl.tab_replies': 'Antworten', 'tl.tab_followers': 'Follower', 'tl.followers': 'Follower', 'tl.followers_lead': 'Wer dir im Fediverse folgt, mit der letzten erfolgreichen Zustellung. Rot = nie zugestellt oder letzter Versuch fehlgeschlagen — nach einer Prüfung ein Kandidat zum Aufräumen.', 'tl.empty_followers': 'Noch keine Follower.', 'tl.last_delivery': 'Letzte Zustellung', 'tl.never_delivered': 'Nie zugestellt', 'tl.delivery_failed': 'letzter Versuch fehlgeschlagen', 'tl.remove_follower': 'Entfernen', 'tl.remove_confirm': 'Diesen Follower entfernen? Ein aktives Konto müsste dir erneut folgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'du folgst', 'tl.dir_follower': 'folgt dir', 'tl.dir_mutual': 'gegenseitig', 'tl.connect_empty': 'Noch keine Verbindungen. Folge oben jemandem, um zu starten.', 'tl.unreachable': 'Nicht erreichbar', 'tl.unreachable_lead': 'Diese Follower konnten wir nicht erreichen (nie zugestellt oder letzter Versuch fehlgeschlagen). Räume sie nach einer Prüfung auf.', 'msg.tab': 'Nachrichten', 'msg.title': 'Nachrichten', 'msg.filter_all': 'Alle', 'msg.filter_conv': 'Gespräche', 'msg.filter_act': 'Aktivität', 'msg.filter_sent': 'Gesendet', 'msg.you': 'Du', 'msg.sent_reply': 'hat über das Fediverse geantwortet', 'msg.and_more': 'und {n} andere', 'msg.liked_many': 'gefällt dein Beitrag', 'msg.boosted_many': 'teilten deinen Beitrag', 'msg.private': 'privat', 'msg.private_hint': 'Nur an dich gerichtet; erscheint nicht auf der öffentlichen Beitragsseite.', 'msg.new': 'Neu seit deinem letzten Besuch', 'msg.empty': 'Noch keine Nachrichten. Antworten, Erwähnungen und Aktivität erscheinen hier.', 'oauth.title': 'App autorisieren', 'oauth.wants_access': 'möchte sich mit deinem Klonkt-Konto verbinden.', 'oauth.post_as': 'Posten als', 'oauth.scope_read': 'Deine Beiträge, Antworten und Meldungen lesen', 'oauth.scope_write': 'In deinem Namen posten, antworten, liken und folgen', 'oauth.allow': 'Erlauben', 'oauth.deny': 'Ablehnen', 'oauth.foot': 'Du kannst den Zugriff später widerrufen. Autorisiere nur Apps, denen du vertraust.', 're.title': 'Antwort schreiben', 're.bold': 'Fett', 're.italic': 'Kursiv', 're.link': 'Link einfügen', 're.list': 'Aufzählung', 're.quote': 'Zitat', 're.lang': 'Sprache deiner Antwort', 're.attach': 'Medien hinzufügen (Bild, Audio, Video)', 're.attach_err': 'Upload fehlgeschlagen', 'tl.empty_following': 'Du folgst noch niemandem.', 'tl.empty': 'Noch nichts — folge jemandem, um Beiträge hier zu sehen.', 'tl.view_original': 'Original ansehen →', 'tl.open_player': 'Player öffnen', 'tl.paste_ph': 'URL eines Fediverse-Beitrags einfügen', 'tl.paste_go': 'Öffnen', 'tl.boosted': 'hat geteilt', 'tl.read_more': 'Mehr lesen', 'tl.show_less': 'Weniger', 'poll.vote': 'Abstimmen', 'poll.votes': 'Stimmen', 'poll.closed': 'geschlossen', 'poll.open': 'offen', 'poll.aria': 'Umfrage', 'poll.voter_one': 'Teilnehmer', 'poll.voter_many': 'Teilnehmer', 'poll.closes': 'endet am', 'poll.multiple': 'Mehrfachauswahl', 'poll.fedi_only': 'Abstimmen geht über das Fediverse — folge dieser Seite und stimme in deiner eigenen App ab.', 'poll.voted_title': 'Stimme gesendet', 'poll.voted_done': 'Deine Stimme ist unterwegs zur Umfrage. Die Ergebnisse aktualisieren sich, sobald die Autorin oder der Autor das Update sendet.',
    19721972    'comments.to_start': 'um das Gespräch zu starten.',
    19731973    'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen',
  • src/views/partials/fedi-node.ejs

    r33e1dbd rfeced2c  
    1212  </div>
    1313  <div class="comment-content"><%- n.content %></div>
     14  <%# Rich replies: media on our own sent replies (attachments on the Note).
     15      Visitors see these too, and the reply editor (owner-only) may not render,
     16      so make sure the stylesheet is on the page either way. %>
     17  <% if (n.media && n.media.length) { %>
     18    <% if (!locals.__reAssets) { locals.__reAssets = 1; %><link rel="stylesheet" href="/assets/css/reply-editor.css"><% } %>
     19    <div class="re-reply-media">
     20      <% n.media.forEach(function (m) { %>
     21        <% if ((m.mediaType || '').indexOf('image/') === 0) { %>
     22          <a href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= m.url %>" alt="<%= m.name || '' %>" loading="lazy"></a>
     23        <% } else if ((m.mediaType || '').indexOf('audio/') === 0) { %>
     24          <audio controls preload="none" src="<%= m.url %>"></audio>
     25        <% } else { %>
     26          <video controls preload="metadata" src="<%= m.url %>"></video>
     27        <% } %>
     28      <% }); %>
     29    </div>
     30  <% } %>
    1431  <div class="comment-actions">
    1532    <% if (n.mine && typeof canManageSite !== 'undefined' && canManageSite && n.outboxId) { %>
  • src/views/partials/reply-editor.ejs

    r33e1dbd rfeced2c  
    1818%>
    1919<% var _reLang = (typeof defaultLang !== 'undefined' && defaultLang) || (typeof lang !== 'undefined' ? lang : 'en'); %>
    20 <form method="post" action="<%= action %>" class="re-form" data-re>
     20<form method="post" action="<%= action %>" class="re-form" data-re
     21      data-upload="/posts/upload-reply-media" data-upload-err="<%= t('re.attach_err') %>">
    2122  <% (typeof hiddenFields !== 'undefined' ? hiddenFields : []).forEach(function (h) { %>
    2223    <input type="hidden" name="<%= h.name %>" value="<%= h.value %>">
     
    3536    <button type="button" data-cmd="list" title="<%= t('re.list') %>" aria-label="<%= t('re.list') %>">•≡</button>
    3637    <button type="button" data-cmd="quote" title="<%= t('re.quote') %>" aria-label="<%= t('re.quote') %>">❝</button>
     38    <button type="button" data-cmd="attach" title="<%= t('re.attach') %>" aria-label="<%= t('re.attach') %>">📎</button>
     39    <input type="file" class="re-file" accept="image/*,audio/*,video/*" multiple hidden>
    3740  </div>
     41  <input type="hidden" name="attachments" value="">
     42  <div class="re-attachments" hidden></div>
    3843  <textarea name="text" rows="<%= typeof rows !== 'undefined' ? rows : 3 %>" required placeholder="<%= placeholder %>"></textarea>
    3944  <div class="re-editor" contenteditable="true" role="textbox" aria-multiline="true"
  • test/rich-reply.test.js

    r33e1dbd rfeced2c  
    8080  assert.equal(row.language, null);
    8181});
     82
     83test('attachments: own /media/ urls stored, note carries typed absolute attachments', async () => {
     84  const r = await AP.deliverReply(site, {
     85    postId: 'p1', postSlug: 'hallo', parent, text: 'met media', html: '',
     86    attachments: [
     87      { url: '/media/reply-media/a.webp', mediaType: 'image/webp', name: 'foto' },
     88      { url: '/media/reply-media/b.mp3', mediaType: 'audio/mpeg', name: 'liedje' },
     89      { url: 'https://evil.example/x.png', mediaType: 'image/png', name: 'remote' },  // rejected: not ours
     90      { url: '/media/reply-media/c.pdf', mediaType: 'application/pdf', name: 'doc' }, // rejected: type
     91    ],
     92  });
     93  const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
     94  const stored = JSON.parse(row.attachments);
     95  assert.equal(stored.length, 2);
     96  assert.deepEqual(stored.map((a) => a.url), ['/media/reply-media/a.webp', '/media/reply-media/b.mp3']);
     97
     98  const note = AP.buildReplyNote('https://klonkt.test', site, row);
     99  assert.equal(note.attachment.length, 2);
     100  assert.deepEqual(note.attachment.map((a) => a.type), ['Image', 'Audio']);
     101  assert.equal(note.attachment[0].url, 'https://klonkt.test/media/reply-media/a.webp');
     102});
     103
     104test('a media-only reply (no text) is delivered', async () => {
     105  const r = await AP.deliverReply(site, {
     106    postId: 'p1', postSlug: 'hallo', parent, text: '', html: '',
     107    attachments: [{ url: '/media/reply-media/solo.webp', mediaType: 'image/webp', name: '' }],
     108  });
     109  assert.ok(r && r.id);
     110  const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
     111  assert.match(row.content, /^<p><a /); // just the mention paragraph
     112  assert.equal(JSON.parse(row.attachments).length, 1);
     113});
     114
     115test('only foreign/invalid attachments and no text -> rejected', async () => {
     116  const r = await AP.deliverReply(site, {
     117    postId: 'p1', postSlug: 'hallo', parent, text: '', html: '',
     118    attachments: [{ url: 'https://evil.example/x.png', mediaType: 'image/png' }],
     119  });
     120  assert.equal(r, null);
     121});
Note: See TracChangeset for help on using the changeset viewer.