Changeset 6e9bc70 in Klonkt


Ignore:
Timestamp:
10/12/2026 04:15:08 PM (2 days ago)
Author:
Bart <bart@…>
Branches:
main
Children:
61d52ed
Parents:
746d98a
Message:

Zachte groepen: een gesprek is de set deelnemers, niet één tegenpartij

Een direct bericht aan Anna en Ben bestond al als groep -- to_actors droeg de
hele set -- maar elke lezing sleutelde op één other. Een uitgaand bericht
aan twee mensen viel in twee gesprekken uiteen, en een binnenkomend bericht
van Anna aan jou en Ben kwam alleen bij Anna terecht: Ben viel eruit, want de
adressering van een binnenkomer werd nergens bewaard.

Nu is er een sleutel, één keer berekend bij het schrijven
(conversation-key.js): voor één ander zijn kale URI, precies zoals het altijd
was, dus bestaande gesprekken, leesmarkeringen en ?with=-URL's blijven geldig
zonder migratie; voor een groep de gesorteerde set, gescheiden door een
spatie. Koppen, geschiedenis, leesmarkeringen en ongelezen lezen hem allemaal
via dezelfde unie, en werken daarmee per gesprek zonder dat er in die queries
iets hoefde te veranderen. Oude rijen hebben geen sleutel en lezen zoals ze
altijd lazen.

Alleen directe berichten groeperen: een openbare post aan jou en Ben is geen
besloten gesprek met z'n drieën.

/messages neemt meer dan één with, en elk item draagt shaer:conversation en
shaer:participants. Uitgaande directe notes krijgen een FEP-7888 context --
die van de ouder als het een antwoord is, anders een nieuwe -- die bewaard en
uitgestuurd wordt, maar geen bubbels samenvoegt: een bubbel die Carla toont
terwijl je antwoord haar niet bereikt, zou liegen over wie meeleest.

shaer-mbji
Co-Authored-By: Claude Opus 5.5 <claude@…>

Files:
2 added
6 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r746d98a r6e9bc70  
    10581058  // opnieuw op te halen, en een verzonnen ouder is erger dan geen.
    10591059  ensureColumn('ap_mentions', 'in_reply_to', 'TEXT');
     1060  // Zachte groepen (shaer-mbji, src/services/conversation-key.js). Een gesprek
     1061  // is de SET deelnemers; die werd bij een binnenkomende note nergens bewaard,
     1062  // dus een bericht van Anna aan jou en Ben wist niet meer dat Ben erbij was.
     1063  //
     1064  // `conv` is de sleutel, één keer berekend bij het schrijven: elke lezing
     1065  // hoeft hem dan alleen te lezen, en een sleutel die bij elke query opnieuw
     1066  // uit JSON gebouwd wordt is een plek om het verschillend te doen. NULL op
     1067  // oude rijen, en dan valt de lezing terug op wat hij altijd deed.
     1068  ensureColumn('ap_mentions', 'conv', 'TEXT');
     1069  ensureColumn('ap_mentions', 'participants', 'TEXT');  // JSON: de anderen, zonder mij
     1070  ensureColumn('ap_mentions', 'context', 'TEXT');       // FEP-7888
     1071  ensureColumn('ap_outbox', 'conv', 'TEXT');
     1072  ensureColumn('ap_outbox', 'context', 'TEXT');
    10601073  ensureColumn('ap_interactions', 'media_json', 'TEXT');
    10611074  ensureColumn('ap_interactions', 'quote_json', 'TEXT');
  • src/routes/activitypub.js

    r746d98a r6e9bc70  
    2020import OAuth from '../services/OAuthService.js';
    2121import * as Guardianship from '../services/guardianship/index.js';
     22import * as ConversationKey from '../services/conversation-key.js';
    2223import * as Migration from '../services/MigrationService.js';
    2324import { getPrimarySite } from '../middleware/site.js';
    … …  
    626627    }
    627628  }
     629  // WELK GESPREK, EN MET WIE (shaer-mbji). De client kon dat afleiden uit de
     630  // adressering van de note -- maar een binnenkomer draagt die niet mee in zijn
     631  // kaart, dus een bericht van Anna aan jou en Ben las als iets van Anna alleen.
     632  // De server weet het: de sleutel staat in de kop. Twee termen, zodat een
     633  // client die alleen de deelnemers wil niet een sleutel hoeft te ontleden.
     634  for (const { head, item } of pairs) {
     635    const obj = item && item.object;
     636    if (!obj || !head.other) continue;
     637    obj['shaer:conversation'] = head.other;
     638    obj['shaer:participants'] = ConversationKey.participantsOfKey(head.other);
     639  }
    628640  return pairs;
    629641}
    … …  
    673685  const auth = OAuth.verifyBearer(req.headers.authorization);
    674686  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    675   const other = String(req.query.with || '');
    676   if (!/^https?:\/\//i.test(other)) return res.status(400).json({ error: 'with must be an actor URI' });
     687  // EÉN OF MEER `with` (shaer-mbji). Eén is het gesprek met die persoon, zoals
     688  // altijd; meer is de zachte groep van precies die mensen. De sleutel komt
     689  // uit dezelfde functie als bij het schrijven, dus {Anna, Ben} en {Ben, Anna}
     690  // vragen hetzelfde gesprek op.
     691  const withs = [].concat(req.query.with || []).map(String);
     692  if (!withs.length || !withs.every((u) => /^https?:\/\//i.test(u))) {
     693    return res.status(400).json({ error: 'with must be one or more actor URIs' });
     694  }
     695  const other = ConversationKey.conversationKey(withs);
     696  if (!other) return res.status(400).json({ error: 'with must be one or more actor URIs' });
    677697  const page = AP.conversationHistory(auth.site.slug, other, {
    678698    before: req.query.before ? String(req.query.before) : null,
    … …  
    684704  // dan klopt het ritme van een 'load more' niet meer met wat de gebruiker ziet.
    685705  const size = req.query.limit ? `&limit=${encodeURIComponent(String(req.query.limit))}` : '';
    686   const self = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?with=${encodeURIComponent(other)}`;
     706  // Elke deelnemer een eigen `with`, zodat de next-link hetzelfde gesprek
     707  // opvraagt. De sleutel zelf er kaal in zetten zou bij een groep een spatie in
     708  // de URL leggen, en wie die als één adres leest vraagt iets anders op.
     709  const withQuery = ConversationKey.participantsOfKey(other).map((u) => `with=${encodeURIComponent(u)}`).join('&');
     710  const self = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?${withQuery}`;
    687711  AP.sendAP(res, {
    688712    '@context': AP.AP_CONTEXT,
  • src/services/ActivityPubService.js

    r746d98a r6e9bc70  
    549549      attributedTo: meR,
    550550      inReplyTo: post.in_reply_to || undefined,
     551      // FEP-7888 (shaer-mbji): het gesprek waar dit bij hoort, in AS2 dat
     552      // andere servers ook lezen. Alleen op wat er een heeft -- oude rijen en
     553      // gewone antwoorden blijven precies zoals ze waren.
     554      context: post.context || undefined,
    551555      content: post.content,
    552556      // Reply language (rich replies): the AS2 language map next to `content`.
  • src/services/ap-inbox.js

    r746d98a r6e9bc70  
    3030import { fwStmts } from './ap-following.js';
    3131import * as HubInvite from './hub-invite.js';
     32import * as ConversationKey from './conversation-key.js';
    3233
    3334/**
    … …  
    852853            // laten voor iedereen die de objectvorm stuurt.
    853854            const ouder = safeUrl(typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || null;
    854             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, in_reply_to, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
    855                                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
     855            // WIE ER NOG MEER BIJ WAS (shaer-mbji). De afzender plus iedere
     856            // persoon in to/cc, zonder ons. Zonder dit wist een bericht van
     857            // Anna aan jou en Ben niet meer dat Ben erbij was, en kwam het
     858            // alleen in het gesprek met Anna terecht.
     859            //
     860            // ALLEEN VOOR EEN DIRECT BERICHT. Deze tabel draagt ook publieke
     861            // posts die ons noemen, en een openbare post aan jou en Ben is geen
     862            // besloten gesprek met z'n drieën -- dat zou een groep verzinnen
     863            // die niemand begon. Die blijft bij zijn afzender, zoals altijd.
     864            const ander = noteVisibility(o) === 'direct'
     865              ? ConversationKey.participantsOf(o, actorUri, actorId(base, slug))
     866              : [actorUri];
     867            const conv = ConversationKey.conversationKey(ander) || actorUri;
     868            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, in_reply_to, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, conv, participants, context, created_at)
     869                                  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
    856870              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, ouder, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
    857                 extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
     871                extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o),
     872                conv, JSON.stringify(ander), ConversationKey.contextOf(o));
    858873            if (r.changes) {
    859874              // The quote / link-preview card resolves out of band (a remote
  • src/services/ap-timeline.js

    r746d98a r6e9bc70  
    248248 */
    249249
     250// `other` is hier DE GESPREKSSLEUTEL (shaer-mbji, conversation-key.js), en niet
     251// meer per se één persoon. Voor één ander is dat zijn URI, precies zoals
     252// altijd; voor een groep de gesorteerde set. De naam van de kolom bleef, want
     253// koppen, geschiedenis, leesmarkeringen en ongelezen lezen hem allemaal -- en
     254// die werken nu per gesprek in plaats van per persoon zonder dat er een regel
     255// in hoefde te veranderen.
     256//
     257// Oude rijen hebben geen `conv` en lezen zoals ze altijd lazen: een binnenkomer
     258// bij zijn afzender, een uitgaande bij elke ontvanger afzonderlijk. Geen
     259// migratie die de geschiedenis herschikt; alleen wat nieuw is, groepeert.
    250260const CONVERSATION_UNION = `
    251   SELECT m.actor_uri AS other, ${STEMPEL('COALESCE(m.published, m.created_at)')} AS stamp,
     261  SELECT COALESCE(NULLIF(m.conv, ''), m.actor_uri) AS other,
     262         ${STEMPEL('COALESCE(m.published, m.created_at)')} AS stamp,
    252263         'in' AS direction, m.object_uri AS ref
    253264    FROM ap_mentions m
    254265   WHERE m.slug = @slug AND m.actor_uri IS NOT NULL AND m.actor_uri <> ''
     266  UNION ALL
     267  SELECT o.conv AS other, ${STEMPEL('o.created_at')} AS stamp,
     268         'out' AS direction, o.id AS ref
     269    FROM ap_outbox o
     270   WHERE o.site_slug = @slug AND o.visibility = 'direct'
     271     AND o.conv IS NOT NULL AND o.conv <> ''
    255272  UNION ALL
    256273  SELECT j.value AS other, ${STEMPEL('o.created_at')} AS stamp,
    … …  
    259276    JOIN json_each(COALESCE(NULLIF(o.to_actors, ''), json_array(o.to_actor))) j
    260277   WHERE o.site_slug = @slug AND o.visibility = 'direct'
     278     AND (o.conv IS NULL OR o.conv = '')
    261279     AND j.value IS NOT NULL AND j.value <> ''`;
    262280
  • src/services/guardianship/delivery.js

    r746d98a r6e9bc70  
    1414import db, { NU_ISO } from '../../config/database.js';
    1515import { carriesGuardians } from './context.js';
     16import * as ConversationKey from '../conversation-key.js';
    1617
    1718const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
    … …  
    3334  if (!to.length && !cc.length) return 'public';   // no addressing at all: legacy client, keep old behavior
    3435  return 'direct';
     36}
     37
     38/**
     39 * De context (FEP-7888) van het bericht waarop geantwoord wordt, als we die
     40 * kennen: uit wat binnenkwam of uit wat we zelf stuurden. Een antwoord hoort in
     41 * het gesprek van zijn ouder, en niet in een vers gesprek ernaast.
     42 */
     43function contextFor(slug, inReplyTo) {
     44  if (!inReplyTo) return null;
     45  try {
     46    const binnen = db.prepare('SELECT context FROM ap_mentions WHERE slug = ? AND object_uri = ? AND context IS NOT NULL')
     47      .get(slug, inReplyTo);
     48    if (binnen && binnen.context) return binnen.context;
     49    // Een eigen note heeft als uri .../ap/notes/<id>; de id is de outbox-sleutel.
     50    const eigen = /\/ap\/notes\/([^/?#]+)$/.exec(String(inReplyTo));
     51    if (eigen) {
     52      const rij = db.prepare('SELECT context FROM ap_outbox WHERE site_slug = ? AND id = ? AND context IS NOT NULL')
     53        .get(slug, eigen[1]);
     54      if (rij && rij.context) return rij.context;
     55    }
     56  } catch { /* geen context is ook een antwoord */ }
     57  return null;
    3558}
    3659
    … …  
    144167    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
    145168  const id = crypto.randomUUID();
    146   db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, away_until, created_at)
    147               VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
    148     .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0, awayUntil || null);
     169  // Het gesprek waar dit bij hoort (shaer-mbji): de set ontvangers. Aan Anna en
     170  // Ben samen is één gesprek, en niet twee die elk de helft dragen.
     171  const conv = ConversationKey.conversationKey(resolved.map((r) => r.uri));
     172  // FEP-7888: het gesprek een naam geven die andere servers ook lezen. Is dit
     173  // een antwoord op iets met een context, dan hoort het in diezelfde; anders
     174  // begint hier een nieuwe.
     175  const context = contextFor(site.slug, inReplyTo) || `${base}/ap/contexts/${crypto.randomUUID()}`;
     176  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, away_until, conv, context, created_at)
     177              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
     178    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0, awayUntil || null, conv, context);
    149179  const row = getOutboxRow(id);
    150180  const note = buildReplyNote(base, site, row);
Note: See TracChangeset for help on using the changeset viewer.