Changeset fe97cc3 in Klonkt for src/services


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

Location:
src/services
Files:
2 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,
  • src/services/i18n.js

    r254939c rfe97cc3  
    2828    'nav.language': 'Taal',
    2929    'nav.notifications': 'Meldingen',
    30     'notif.title': 'Meldingen', 'notif.empty': 'Nog geen meldingen.', 'notif.someone': 'Iemand', 'notif.followed': 'volgt je nu', 'notif.liked': 'likete je post', 'notif.boosted': 'boostte je post', 'notif.replied': 'reageerde op', 'notif.reported': 'rapporteerde je bij hun server', 'blk.title': 'Blokkeren', 'blk.lead': 'Blokkeer een account of een heel domein — hun reacties, likes en posts verdwijnen en nieuwe worden geweigerd.', 'blk.block_btn': 'Blokkeren', 'blk.empty': 'Niks geblokkeerd.', 'blk.unblock': 'Deblokkeren', 'tl.block': 'Blokkeer',
     30    'notif.title': 'Meldingen', 'notif.empty': 'Nog geen meldingen.', 'notif.someone': 'Iemand', 'notif.followed': 'volgt je nu', 'notif.liked': 'likete je post', 'notif.boosted': 'boostte je post', 'notif.replied': 'reageerde op', 'notif.reported': 'rapporteerde je bij hun server', 'notif.mentioned': 'noemde je in een post', 'blk.title': 'Blokkeren', 'blk.lead': 'Blokkeer een account of een heel domein — hun reacties, likes en posts verdwijnen en nieuwe worden geweigerd.', 'blk.block_btn': 'Blokkeren', 'blk.empty': 'Niks geblokkeerd.', 'blk.unblock': 'Deblokkeren', 'tl.block': 'Blokkeer',
    3131    'notif.reply': '{actor} reageerde op je reactie', 'notif.comment': '{actor} reageerde op je post', 'notif.like': '{actor} vindt je post leuk',
    3232    'switch.agenda': 'Agenda',
     
    959959    'nav.language': 'Language',
    960960    'nav.notifications': 'Notifications',
    961     'notif.title': 'Notifications', 'notif.empty': 'No notifications yet.', 'notif.someone': 'Someone', 'notif.followed': 'followed you', 'notif.liked': 'liked your post', 'notif.boosted': 'boosted your post', 'notif.replied': 'replied to', 'notif.reported': 'reported you to their server', 'blk.title': 'Blocking', 'blk.lead': 'Block an account or a whole domain — their replies, likes and posts disappear and new ones are refused.', 'blk.block_btn': 'Block', 'blk.empty': 'Nothing blocked.', 'blk.unblock': 'Unblock', 'tl.block': 'Block',
     961    'notif.title': 'Notifications', 'notif.empty': 'No notifications yet.', 'notif.someone': 'Someone', 'notif.followed': 'followed you', 'notif.liked': 'liked your post', 'notif.boosted': 'boosted your post', 'notif.replied': 'replied to', 'notif.reported': 'reported you to their server', 'notif.mentioned': 'mentioned you in a post', 'blk.title': 'Blocking', 'blk.lead': 'Block an account or a whole domain — their replies, likes and posts disappear and new ones are refused.', 'blk.block_btn': 'Block', 'blk.empty': 'Nothing blocked.', 'blk.unblock': 'Unblock', 'tl.block': 'Block',
    962962    'notif.reply': '{actor} replied to your comment', 'notif.comment': '{actor} commented on your post', 'notif.like': '{actor} liked your post',
    963963    'switch.agenda': 'Agenda',
     
    18811881    'nav.language': 'Sprache',
    18821882    'nav.notifications': 'Benachrichtigungen',
    1883     'notif.title': 'Benachrichtigungen', 'notif.empty': 'Noch keine Benachrichtigungen.', 'notif.someone': 'Jemand', 'notif.followed': 'folgt dir jetzt', 'notif.liked': 'gefällt dein Beitrag', 'notif.boosted': 'teilte deinen Beitrag', 'notif.replied': 'antwortete auf', 'notif.reported': 'hat dich bei ihrem Server gemeldet', 'blk.title': 'Blockieren', 'blk.lead': 'Blockiere ein Konto oder eine ganze Domain — ihre Antworten, Likes und Beiträge verschwinden und neue werden abgelehnt.', 'blk.block_btn': 'Blockieren', 'blk.empty': 'Nichts blockiert.', 'blk.unblock': 'Entsperren', 'tl.block': 'Blockieren',
     1883    'notif.title': 'Benachrichtigungen', 'notif.empty': 'Noch keine Benachrichtigungen.', 'notif.someone': 'Jemand', 'notif.followed': 'folgt dir jetzt', 'notif.liked': 'gefällt dein Beitrag', 'notif.boosted': 'teilte deinen Beitrag', 'notif.replied': 'antwortete auf', 'notif.reported': 'hat dich bei ihrem Server gemeldet', 'notif.mentioned': 'hat dich in einem Beitrag erwähnt', 'blk.title': 'Blockieren', 'blk.lead': 'Blockiere ein Konto oder eine ganze Domain — ihre Antworten, Likes und Beiträge verschwinden und neue werden abgelehnt.', 'blk.block_btn': 'Blockieren', 'blk.empty': 'Nichts blockiert.', 'blk.unblock': 'Entsperren', 'tl.block': 'Blockieren',
    18841884    'notif.reply': '{actor} hat auf deinen Kommentar geantwortet', 'notif.comment': '{actor} hat deinen Beitrag kommentiert', 'notif.like': '{actor} gefällt dein Beitrag',
    18851885    'switch.agenda': 'Termine',
Note: See TracChangeset for help on using the changeset viewer.