Changeset fe97cc3 in Klonkt


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

Files:
1 added
7 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.de.md

    r254939c rfe97cc3  
    77
    88### Hinzugefügt
     9- **Eine Erwähnung ist jetzt eine Benachrichtigung.** Wenn dich jemand im Fediverse in einem
     10  Beitrag erwähnt — auch in einem, der keine Antwort an dich ist — erscheint das in deinen
     11  Fediverse-Benachrichtigungen mit einem Link zum Original.
    912- **Cover-Art bei offen geteiltem Audio.** Ein offen im Fediverse geteilter Track trägt jetzt sein
    1013  Cover (oder das Beitragscover) mit, sodass Audio-Player, die Artwork unterstützen, es statt einer
  • CHANGELOG.md

    r254939c rfe97cc3  
    77
    88### Added
     9- **A mention is now a notification.** When someone on the fediverse mentions you in a post —
     10  even one that isn't a reply to you — it shows up in your fediverse notifications with a link
     11  to the original.
    912- **Cover art on openly shared audio.** A track shared openly on the fediverse now carries its
    1013  cover art (or the post cover), so audio players that support artwork show it instead of a blank tile.
  • CHANGELOG.nl.md

    r254939c rfe97cc3  
    77
    88### Toegevoegd
     9- **Een vermelding is nu een melding.** Als iemand op de fediverse je noemt in een post — ook
     10  eentje die geen reactie op jou is — verschijnt dat in je fediverse-meldingen met een link naar
     11  het origineel.
    912- **Cover-art op openbaar gedeelde audio.** Een track die je openbaar op de fediverse deelt draagt
    1013  nu z'n cover-art mee (of de post-cover), zodat audiospelers die artwork ondersteunen die tonen in
  • src/config/database.js

    r254939c rfe97cc3  
    347347    );
    348348    CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
     349    CREATE TABLE IF NOT EXISTS ap_mentions (
     350      id INTEGER PRIMARY KEY AUTOINCREMENT,
     351      slug TEXT NOT NULL,           -- our mentioned site/actor
     352      object_uri TEXT NOT NULL,     -- the remote note that mentions us
     353      note_url TEXT,                -- its human URL (open/interact)
     354      actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
     355      content TEXT,                 -- sanitized HTML snippet of the mentioning note
     356      published TEXT,
     357      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     358      UNIQUE(slug, object_uri)
     359    );
     360    CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
    349361    CREATE TABLE IF NOT EXISTS ap_reports (
    350362      id INTEGER PRIMARY KEY AUTOINCREMENT,
  • 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',
  • src/views/pages/fedi-notifications.ejs

    r254939c rfe97cc3  
    77    <ul class="nt-list">
    88      <% items.forEach(function(n){
    9            var _ic = n.type === 'follow' ? 'follow' : (n.type === 'like' ? 'like' : (n.type === 'announce' ? 'boost' : (n.type === 'report' ? 'report' : 'reply')));
     9           var _ic = n.type === 'follow' ? 'follow' : (n.type === 'like' ? 'like' : (n.type === 'announce' ? 'boost' : (n.type === 'report' ? 'report' : (n.type === 'mention' ? 'mention' : 'reply'))));
    1010      %>
    1111        <li class="nt-item nt-<%= _ic %>">
     
    1515            <% } else if (_ic === 'follow') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
    1616            <% } else if (_ic === 'report') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
     17            <% } else if (_ic === 'mention') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/></svg>
    1718            <% } else { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><% } %>
    1819          </span>
     
    2829              <% } else if (n.type === 'announce') { %><%= t('notif.boosted') %>
    2930              <% } else if (n.type === 'report') { %><%= t('notif.reported') %>
     31              <% } else if (n.type === 'mention') { %><%= t('notif.mentioned') %>
    3032              <% } else { %><%= t('notif.replied') %><% } %>
    3133              <% if (n.post_slug) { %><a class="nt-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
     34              <% if (n.type === 'mention' && n.note_url) { %><a class="nt-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
    3235            </div>
    3336            <% if (n.type === 'report' && n.content) { %><div class="nt-content"><%= n.content %></div>
    34             <% } else if (n.type === 'reply' && n.content) { %><div class="nt-content"><%- n.content %></div><% } %>
     37            <% } else if ((n.type === 'reply' || n.type === 'mention') && n.content) { %><div class="nt-content"><%- n.content %></div><% } %>
    3538          </div>
    3639        </li>
     
    5962  .nt-follow .nt-icon, .nt-reply .nt-icon { background: color-mix(in srgb, var(--accent, #888) 16%, transparent); color: var(--accent, #06c); }
    6063  .nt-report .nt-icon { background: color-mix(in srgb, #c0392b 16%, transparent); color: #c0392b; }
     64  .nt-mention .nt-icon { background: color-mix(in srgb, #8e6cf0 16%, transparent); color: #8e6cf0; }
    6165  .nt-body { flex: 1; min-width: 0; }
    6266  .nt-line { display: flex; align-items: baseline; gap: .4rem; }
Note: See TracChangeset for help on using the changeset viewer.