Changeset feced2c in Klonkt for src


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@…>

Location:
src
Files:
8 edited

Legend:

Unmodified
Added
Removed
  • 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"
Note: See TracChangeset for help on using the changeset viewer.