Changeset 08ab8ad in Klonkt


Ignore:
Timestamp:
07/29/2026 11:26:00 AM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
6100ce9
Parents:
6d5ce0c
Message:

De app las alleen de tijdlijn, dus een zwaai kwam nooit aan

Robin vroeg waarom zwaai-acties niet als bericht in Shaer verschijnen. De app
bouwt Berichten, gesprekken en de hulpvraag-escalaties uit een bron: de
C2S-inboxlees plus de eigen outbox. Die lees serveerde alleen ap_timeline, en
een directe note staat in ap_mentions. Dus kwam er niets binnen: geen zwaai,
geen DM, en sinds gisteren ook geen hulpvraag meer.

Tot d9ad6c5 lekten directe notes toevallig in de tijdlijn: de insert vroeg
alleen of het een top-level post was van iemand die je volgt. Dat hebben we
dichtgezet om hulpvragen uit de Krant te houden, en daarmee viel de enige weg
weg waarlangs de app ze binnenkreeg. Je eigen antwoord zag je nog wel, want dat
komt uit je outbox, en daardoor leek het half te werken.

Nu serveert de inboxlees allebei: posts en de directe notes die aan jou gericht
zijn. In dezelfde vorm, dus de client heeft er een parser voor. Met to:[jij] en
een Mention-tag, want zonder die adressering groepeert de app het niet tot een
gesprek, en met shaer:wave en shaer:helpRequest zodat een zwaai er als een
zwaai uitziet.

Onderweg gevonden: SQLite schrijft CURRENT_TIMESTAMP in UTC zonder zone, en
Date.parse leest dat als lokale tijd. Twee uur verschil is genoeg om een
gesprek in de verkeerde volgorde te zetten, dus dat gaat nu door isoStamp.

Changed files:
src/services/ActivityPubService.js

  • getDirectMessages(): de mentions die niet ook een tijdlijnrij zijn, want een publieke mention van iemand die je volgt staat in allebei
  • isoStamp(): een opgeslagen stempel als echt moment
  • stripLeadingMentions op de default export, die had de route nodig

src/routes/activitypub.js

  • de inboxlees serveert posts en berichten in een collectie, nieuwste eerst
  • adressering, wave-vlag, byline en de gated shaer:embed op elk bericht

New file:
test/c2s-messages.test.js

  • de twee tabellen blijven gescheiden, de lees brengt ze samen
  • de zwaai zit erin, is gemarkeerd, en is aan jou gericht
  • een publieke mention komt een keer langs, als post

remarks: 333 tests groen. De clientkant zit in de app-repos.

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

Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r6d5ce0c r08ab8ad  
    161161  const playbackAllowed = embedsAllowed
    162162    && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
    163   const items = AP.getTimeline(auth.site.slug, 60).map((t) => ({
     163  const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({
    164164    id: `${t.id}#create`,
    165165    type: 'Create',
     
    217217    },
    218218  }));
     219  // The direct notes addressed to this account: a plain DM, a guardian's wave
     220  // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
     221  // they are not in the timeline; without them the app's Berichten shows only
     222  // what you said yourself. Same shape as a post, so one parser handles both.
     223  const me = AP.actorId(base, auth.site.slug);
     224  const myHandle = (() => { try { return `@${auth.site.slug}@${new URL(base).host}`; } catch { return `@${auth.site.slug}`; } })();
     225  const messages = AP.getDirectMessages(auth.site.slug, 60).map((m) => ({
     226    id: `${m.object_uri}#create`,
     227    type: 'Create',
     228    actor: m.actor_uri,
     229    published: AP.isoStamp(m.published || m.created_at),
     230    object: {
     231      id: m.object_uri,
     232      type: 'Note',
     233      attributedTo: m.actor_uri,
     234      content: AP.stripLeadingMentions(m.content),
     235      url: m.note_url || undefined,
     236      published: AP.isoStamp(m.published || m.created_at),
     237      // Addressed to us and to nobody we know of: the other recipients of a
     238      // note to several people are not ours to see, so we serve what we know.
     239      to: [me],
     240      // The Mention is how the client recognises itself as the addressee and
     241      // groups the note into a conversation. No FEP-e232 link tags here: a
     242      // mention row keeps the resolved quote, not the raw tags.
     243      tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
     244      attachment: AP.timelineAttachments(m.media_json),
     245      // FEP-633c: what kind of message this is. The wave is a gentle nudge from
     246      // a guardian; the help request is the buoy. Both render differently.
     247      'shaer:wave': m.wave ? true : undefined,
     248      'shaer:helpRequest': m.help_request ? true : undefined,
     249      'shaer:quote': AP.timelineQuote(m.quote_json),
     250      'shaer:author': (m.actor_name || m.actor_handle || m.actor_icon) ? {
     251        name: m.actor_name || undefined, handle: m.actor_handle || undefined,
     252        icon: m.actor_icon || undefined, url: m.actor_url || undefined,
     253        emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
     254      } : undefined,
     255      'shaer:embed': embedsAllowed ? AP.timelineEmbed(m.embed_json, { playback: playbackAllowed }) : undefined,
     256    },
     257  }));
     258  // Newest first over both, so the app can keep treating this as one feed.
     259  const items = [...posts, ...messages].sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
    219260  AP.sendAP(res, {
    220261    '@context': AP.AP_CONTEXT,
  • src/services/ActivityPubService.js

    r6d5ce0c r08ab8ad  
    27342734export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
    27352735
     2736/**
     2737 * The direct notes addressed to this account: a plain DM, a guardian's wave
     2738 * (§5), a ward's 🛟 help request (§5.2.1). They live in ap_mentions and NOT in
     2739 * the timeline, because a note addressed to named people is a message and not a
     2740 * post (belongsInTimeline).
     2741 *
     2742 * A client that only reads the timeline therefore sees none of them, which is
     2743 * exactly what happened to Shaer: Berichten showed your own replies (those come
     2744 * from your outbox) and nothing that was said to you. The C2S inbox read serves
     2745 * both, so the app has one door for everything that arrives.
     2746 *
     2747 * A public mention from someone you follow is stored in both tables; those are
     2748 * skipped here and stay a post.
     2749 */
     2750export function getDirectMessages(slug, limit) {
     2751  try {
     2752    return db.prepare(`
     2753      SELECT m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,
     2754             m.content, m.published, m.created_at, m.wave, m.help_request,
     2755             m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json
     2756      FROM ap_mentions m
     2757      WHERE m.slug = ?
     2758        AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
     2759      ORDER BY COALESCE(m.published, m.created_at) DESC LIMIT ?`).all(slug, limit || 60);
     2760  } catch { return []; }
     2761}
     2762
     2763/**
     2764 * A stored stamp as an ISO instant. SQLite's CURRENT_TIMESTAMP writes
     2765 * 'YYYY-MM-DD HH:MM:SS' in UTC, which Date.parse reads as LOCAL time; on a
     2766 * server two hours ahead that dated every message two hours early and put the
     2767 * conversation in the wrong order. A `published` from the wire is already ISO
     2768 * and passes through untouched.
     2769 */
     2770export function isoStamp(v) {
     2771  if (!v) return undefined;
     2772  const s = String(v);
     2773  if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s)) return `${s.replace(' ', 'T')}Z`;
     2774  const t = Date.parse(s);
     2775  return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
     2776}
     2777
    27362778// Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
    27372779// inbound Create) → AS2 `attachment` array, so a client (Shaer) can render a
     
    39273969
    39283970export default {
    3929   AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId,
     3971  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions,
    39303972  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    39313973  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    39323974  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
    39333975  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    3934   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
     3976  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
    39353977  acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
    39363978  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
Note: See TracChangeset for help on using the changeset viewer.