Ignore:
Timestamp:
07/01/2026 10:13:40 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
2abd3d2
Parents:
254939c
Message:

feat(fediverse): notification when mentioned in a non-reply post

An inbound Create whose Mention tag targets one of our actors — but which isn't a
reply to our content — was silently ignored. It's now stored and shown in the
fediverse notifications (@ icon, snippet, link to the original note). Only hrefs on
our own base count (a /ap/users/<slug> path on a remote host is someone else's actor).

  • src/config/database.js — ap_mentions table (per-site, UNIQUE(slug, object_uri)).
  • src/services/ActivityPubService.js — localMentionSlugs(tags, base) detection helper (own-base guard, slug must exist, deduped); the Create branch stores a mention per targeted site; getNotifications includes mentions.
  • src/views/pages/fedi-notifications.ejs — mention item (@ icon, content, view-original link).
  • src/services/i18n.js — notif.mentioned (nl/en/de).
  • test/inbound-mentions.test.js — detection guards + notification surfacing.
  • CHANGELOG(.nl/.de).md — "A mention is now a notification" under Added.

Closes prutfolio-src-xgg.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r254939c rfe97cc3  
    660660// ── HTTP Signatures + delivery ────────────────────────────────────
    661661const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
     662// Which of OUR sites are named in a note's Mention tags? Only hrefs on our own base count
     663// (an /ap/users/<slug> path on a remote host is someone else's actor), and the slug must be
     664// an existing site. Deduped.
     665export function localMentionSlugs(tags, base) {
     666  if (!base) return [];
     667  const out = [], seen = new Set();
     668  for (const t of (Array.isArray(tags) ? tags : (tags ? [tags] : []))) {
     669    if (!t || t.type !== 'Mention' || typeof t.href !== 'string') continue;
     670    if (!t.href.startsWith(base + '/ap/users/')) continue;
     671    const slug = slugFromActorUrl(t.href);
     672    if (!slug || seen.has(slug)) continue; seen.add(slug);
     673    try { if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(slug)) out.push(slug); } catch { /* ignore */ }
     674  }
     675  return out;
     676}
    662677
    663678// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
     
    10731088        }
    10741089        console.log('[AP] timeline +', actorUri, 'x' + subs.length);
     1090      }
     1091    }
     1092    // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
     1093    // above): store a mention notification for each of our actors named in the Mention tags.
     1094    // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
     1095    // someone else's actor, not ours.
     1096    if (actorUri && !isLocalActor && o.id) {
     1097      const slugs = localMentionSlugs(o.tag, base);
     1098      if (slugs.length) {
     1099        const ai = actorInfo(await resolveActor(actorUri), actorUri);
     1100        const html = HtmlSanitizerService.sanitize(o.content || '');
     1101        for (const slug of slugs) {
     1102          try {
     1103            const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
     1104              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null);
     1105            if (r.changes) console.log('[AP] mention', actorUri, '→', slug);
     1106          } catch { /* ignore */ }
     1107        }
    10751108      }
    10761109    }
     
    21612194    }
    21622195  } catch { /* ignore */ }
     2196  try {
     2197    for (const r of db.prepare('SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, created_at FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT 50').all(slug)) {
     2198      out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, created_at: r.created_at });
     2199    }
     2200  } catch { /* ignore */ }
    21632201  out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
    21642202  return out.slice(0, limit || 60);
     
    23352373  listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
    23362374  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll, voteOnRemotePoll,
    2337   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport,
     2375  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    23382376  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    23392377  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
Note: See TracChangeset for help on using the changeset viewer.