Changeset 21ef238 in Klonkt


Ignore:
Timestamp:
08/10/2026 04:53:05 PM (4 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
e215090
Parents:
41de7cb
Message:

Ongelezen per gesprek: een COUNT, en Read als gebeurtenis

shaer-frontend-3tx. De kopjeslezing draagt nu shaer:unread per gesprek, plus
shaer:unreadWave als er een zwaai bij zit -- een zetje van een guardian is geen
gesprek en hoort niet opgeteld te worden.

GEEN BIJGEHOUDEN GETAL (Barts besluit): het aantal is een COUNT over de
berichten na de leesmarkering. Niets om op te hogen bij bezorging, niets om te
verlagen bij lezen, en bij een verwijdering klopt het vanzelf weer -- een
teller zou blijven staan en dan zegt de badge 3 terwijl er niets is. Er staat
een toets op precies dat geval.

READ IS EEN GEBEURTENIS, geen zetbare stand (Barts vraag: een stille client
stuurt toch geen Undo{Read}?). AS2 kent Read; markRead neemt het maximum, dus
een toestel dat een week uit stond kan gelezen berichten niet terugzetten op
ongelezen. Ook daar staat een toets op. Geen shaer:seen verzonnen.

De markering is de samengestelde cursor (stempel|ref), zelfde vorm als de
gesprekspaginering: twee berichten in dezelfde seconde is bij DM's een gesprek
en geen randgeval.

En conversationItems geeft nu PAREN terug in plaats van een losse lijst. Een
kop levert niet altijd een item op (dichte poort, ontbrekende rij), en op index
koppelen zou de telling stil aan het verkeerde gesprek hangen.

862/862 groen.

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

Files:
1 added
3 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r41de7cb r21ef238  
    899899  try {
    900900    db.exec(`
     901      -- Tot waar jij een gesprek gelezen hebt (shaer-frontend-3tx).
     902      --
     903      -- Een MARKERING, geen teller: het aantal ongelezen berichten is een
     904      -- COUNT over de berichten die na deze cursor komen. Een opgeslagen
     905      -- getal zou opgehoogd, verlaagd en gerepareerd moeten worden, en zou
     906      -- blijven staan als er iets verwijderd wordt -- badge zegt 3, er is
     907      -- niets.
     908      --
     909      -- De cursor is samengesteld ('<stempel>|<ref>'), dezelfde vorm als de
     910      -- gesprekspaginering en om dezelfde reden: twee berichten in dezelfde
     911      -- seconde is bij DM's een gesprek, geen randgeval.
     912      CREATE TABLE IF NOT EXISTS ap_read_markers (
     913        slug TEXT NOT NULL,
     914        other TEXT NOT NULL,          -- de tegenpartij (actor uri)
     915        cursor TEXT NOT NULL,
     916        at DATETIME DEFAULT CURRENT_TIMESTAMP,
     917        PRIMARY KEY (slug, other)
     918      );
    901919      CREATE TABLE IF NOT EXISTS ap_feed_state (
    902920        slug TEXT NOT NULL,
  • src/routes/activitypub.js

    r41de7cb r21ef238  
    492492  const incoming = new Map(AP.messageRowsByUri(auth.site.slug, refs.filter((r) => r.direction === 'in').map((r) => r.ref))
    493493    .map((m) => [m.object_uri, m]));
    494   const items = [];
     494  // PAREN, geen losse lijst: een kop levert niet altijd een item op (dichte
     495  // poort, ontbrekende rij), en dan zou de aanroeper op index koppelen en de
     496  // telling aan het verkeerde gesprek hangen. Stil, en pas te zien als iemand
     497  // een badge op de verkeerde naam ziet staan.
     498  const pairs = [];
    495499  for (const r of refs) {
    496500    if (r.direction === 'in') {
     
    498502      if (!m) continue;
    499503      if (!(P.messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))) continue;
    500       items.push(messageItem(m, ctx));
     504      pairs.push({ head: r, item: messageItem(m, ctx) });
    501505    } else {
    502506      const n = AP.getOutboxNote(base, r.ref);
    503507      // Je eigen woorden blijven van jou: een dichte messages-poort verbergt
    504508      // niet wat je zelf gezegd hebt.
    505       if (n) items.push(sentItem(n, { me, mine }));
     509      if (n) pairs.push({ head: r, item: sentItem(n, { me, mine }) });
    506510    }
    507511  }
    508   return items;
     512  return pairs;
    509513}
    510514
     
    513517  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    514518  const heads = AP.conversationHeads(auth.site.slug);
    515   const items = conversationItems(req, auth, heads);
     519  const pairs = conversationItems(req, auth, heads);
     520  const items = pairs.map((x) => x.item);
     521  // Ongelezen per gesprek (shaer-frontend-3tx): een COUNT, geen bijgehouden
     522  // getal. Hij hangt aan het NIEUWSTE kopje van elke persoon -- er kunnen er
     523  // twee zijn (zie conversationHeads) en het aantal hoort bij het gesprek, niet
     524  // bij een bericht.
     525  //
     526  // AS2 heeft geen term voor ongelezen; dit is per-lezer-interactiestatus,
     527  // dezelfde categorie als shaer:liked. Niet in totalItems persen: dat betekent
     528  // 'hoeveel er zijn' en niet 'hoeveel jij nog niet zag'.
     529  const ongelezen = AP.unreadPerConversation(auth.site.slug);
     530  const gezien = new Set();
     531  for (const { head, item } of pairs) {
     532    if (gezien.has(head.other)) continue;
     533    gezien.add(head.other);
     534    const u = ongelezen.get(head.other);
     535    if (!u) continue;
     536    item.object['shaer:unread'] = u.n;
     537    // Een zwaai is geen aantal maar een zetje van een guardian: eigen teken.
     538    if (u.wave) item.object['shaer:unreadWave'] = true;
     539  }
    516540  AP.sendAP(res, {
    517541    '@context': AP.AP_CONTEXT,
     
    533557    limit: req.query.limit,
    534558  });
    535   const items = conversationItems(req, auth, page.rows);
     559  const items = conversationItems(req, auth, page.rows).map((x) => x.item);
    536560  // De paginagrootte reist mee in next: vroeg je om 30, dan hoort de volgende
    537561  // pagina er ook 30 te zijn. Zonder dit wordt hij stilletjes de standaard, en
  • src/services/ActivityPubService.js

    r41de7cb r21ef238  
    32383238        return await c2sCreatePost(base, site, user, object);
    32393239      }
     3240      // ── Gelezen tot hier (shaer-frontend-3tx) ───────────────────
     3241      //
     3242      // AS2 kent Read: 'the actor has read the object'. Geen shaer:seen
     3243      // verzinnen, en geen zetbare stand: dit is een GEBEURTENIS, dus twee
     3244      // toestellen kunnen elkaar niet terugzetten. Blijft lokaal -- een
     3245      // leesbevestiging heeft in de fediverse niets te zoeken.
     3246      case 'Read': {
     3247        const targetUri = c2sIdOf(object);
     3248        if (!targetUri) return { status: 400, error: 'missing_object' };
     3249        const uit = markRead(site.slug, targetUri);
     3250        // Kennen we die note niet, dan is er niets gelezen om te onthouden.
     3251        // Geen fout: een client mag best een oud bericht aanwijzen.
     3252        return { status: uit ? 200 : 202 };
     3253      }
    32403254      case 'Like':
    32413255      case 'Announce': {
     
    43484362}
    43494363
     4364/**
     4365 * Tot waar deze lezer elk gesprek gelezen heeft (shaer-frontend-3tx).
     4366 *
     4367 * De markering komt uit AS2 `Read`-activiteiten, en die zijn OPTELLEND: het
     4368 * lezen van bericht N maakt niets anders ongelezen. Daarom is achteruit gaan
     4369 * geen regel die iemand moet onthouden maar een eigenschap van het model --
     4370 * markRead neemt het maximum. Een 'zet mijn markering op X' zou een toestel
     4371 * dat een week uit stond je gelezen berichten weer op ongelezen laten zetten.
     4372 */
     4373export function readMarkers(slug) {
     4374  try {
     4375    return new Map(db.prepare('SELECT other, cursor FROM ap_read_markers WHERE slug = ?')
     4376      .all(slug).map((r) => [r.other, r.cursor]));
     4377  } catch { return new Map(); }
     4378}
     4379
     4380/**
     4381 * Markeer een gesprek als gelezen tot en met dit bericht.
     4382 *
     4383 * Het object van de Read is een berichturi; welk gesprek dat is en waar het in
     4384 * de tijd staat weet de server zelf, dus de client hoeft niets uit te rekenen
     4385 * en kan er ook niet naast zitten.
     4386 */
     4387export function markRead(slug, objectUri) {
     4388  try {
     4389    const rij = db.prepare(`SELECT other, stamp, ref FROM (${CONVERSATION_UNION})
     4390                             WHERE ref = @ref ORDER BY stamp DESC LIMIT 1`)
     4391      .get({ slug, ref: String(objectUri || '') });
     4392    if (!rij) return null;
     4393    const cursor = `${rij.stamp}|${rij.ref}`;
     4394    db.prepare(`INSERT INTO ap_read_markers (slug, other, cursor) VALUES (?,?,?)
     4395                ON CONFLICT(slug, other) DO UPDATE SET cursor = MAX(cursor, excluded.cursor), at = CURRENT_TIMESTAMP`)
     4396      .run(slug, rij.other, cursor);
     4397    return { other: rij.other, cursor };
     4398  } catch { return null; }
     4399}
     4400
     4401/**
     4402 * Hoeveel er per gesprek nog ongelezen is, en of daar een zwaai bij zit.
     4403 *
     4404 * Een COUNT en geen bijgehouden getal (Barts besluit): niets om op te hogen
     4405 * bij bezorging, niets om te verlagen bij lezen, en bij een verwijdering klopt
     4406 * het vanzelf weer.
     4407 *
     4408 * Een zwaai telt apart, want dat is geen gesprek maar een zetje van een
     4409 * guardian -- die hoort een eigen teken te krijgen en niet opgeteld te worden.
     4410 * Eigen berichten tellen nooit mee: je hebt jezelf gelezen.
     4411 */
     4412export function unreadPerConversation(slug) {
     4413  try {
     4414    const rijen = db.prepare(`
     4415      SELECT u.other AS other,
     4416             COUNT(*) AS n,
     4417             MAX(CASE WHEN m.wave = 1 THEN 1 ELSE 0 END) AS wave
     4418        FROM (${CONVERSATION_UNION}) u
     4419        LEFT JOIN ap_read_markers r ON r.slug = @slug AND r.other = u.other
     4420        LEFT JOIN ap_mentions m ON m.slug = @slug AND m.object_uri = u.ref
     4421       WHERE u.direction = 'in'
     4422         AND (r.cursor IS NULL OR (u.stamp || '|' || u.ref) > r.cursor)
     4423       GROUP BY u.other`).all({ slug });
     4424    return new Map(rijen.map((r) => [r.other, { n: r.n, wave: !!r.wave }]));
     4425  } catch { return new Map(); }
     4426}
     4427
    43504428export function getDirectMessages(slug, limit) {
    43514429  try {
     
    64416519  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
    64426520  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    6443   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, getDirectMessages, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
     6521  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, getDirectMessages, readMarkers, markRead, unreadPerConversation, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
    64446522  acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
    64456523  gateOutgoingFollow, performApprovedFollow, recordGuardianEvent, listGuardianEvents, GUARDIAN_EVENT_KEEP,
Note: See TracChangeset for help on using the changeset viewer.