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

File:
1 edited

Legend:

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