Changeset b05eb97 in Klonkt for src/services


Ignore:
Timestamp:
08/24/2026 04:11:35 PM (2 weeks ago)
Author:
Robin <roboburr@โ€ฆ>
Branches:
main
Children:
911d5cd
Parents:
1d5ffc0
Message:

Opsplitsing stap 5 (shaer-drc): de tijdlijn-leeskant naar ap-timeline.js

Alles wat een client of route uit ap_timeline en de gesprekken leest
verhuist als een blok -- 556 regels, byte-voor-byte: de tijdlijn, de
feed-cursor met long-poll, de gesprekslijsten en leesmarkeringen, en de
serialisatiehulpen (bijlagen, emoji, object-links, citaten).

De grens bleek de schoonste tot nu toe: de hele regio roept precies EEN
werktuig uit de dienstlaag aan (getReactionsFor, het reactiecluster),
dat via wireTimeline binnenkomt -- zelfde injectiepatroon als ap-c2s.
tlStmts exporteert mee, want de schrijvers (inbox, backfill, self-heal,
upsertBoostedNote) blijven in de dienst wonen.

Uitvoeroppervlak voor en na identiek gemeten (199 named exports, 180
sleutels op het default-object). Volle suite 1226 groen.
ActivityPubService staat nu op 5807 regels, van 7091 aan het begin van
de dag.

Location:
src/services
Files:
1 added
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r1d5ffc0 rb05eb97  
    4747import { ingestOutboxActivity, wireC2S } from './ap-c2s.js';
    4848export { ingestOutboxActivity };
     49// Stap 5 (shaer-drc): de leeskant van de tijdlijn woont in ap-timeline.js.
     50// tlStmts komt mee terug omdat de SCHRIJVERS (inbox, backfill, self-heal,
     51// upsertBoostedNote) hier wonen; wireTimeline krijgt onderaan zijn ene
     52// werktuig uit het reactiecluster.
     53import {
     54  tlStmts, wireTimeline,
     55  getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages,
     56  feedCursor, feedChangesSince, waitForFeedChange,
     57  conversationHeads, conversationHistory, messageRowsByUri,
     58  readMarkers, markRead, unreadPerConversation, getDirectMessages,
     59  isoStamp, timelineAttachments, extractEmojiTags, gateAttachments,
     60  stripEmojiTags, timelineEmojis, extractObjectLinkTags, timelineObjectLinks,
     61  extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote,
     62} from './ap-timeline.js';
     63export {
     64  getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages,
     65  feedCursor, feedChangesSince, waitForFeedChange,
     66  conversationHeads, conversationHistory, messageRowsByUri,
     67  readMarkers, markRead, unreadPerConversation, getDirectMessages,
     68  isoStamp, timelineAttachments, extractEmojiTags, gateAttachments,
     69  stripEmojiTags, timelineEmojis, extractObjectLinkTags, timelineObjectLinks,
     70  extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote,
     71};
    4972// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
    5073export { AP_CONTEXT, actorId, noteId, guessMediaType };
     
    37653788}
    37663789
    3767 let _insTl, _listTl, _delTl;
    3768 function tlStmts() {
    3769   if (!_insTl) {
    3770     _insTl = db.prepare('INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, author_handle, author_icon, author_url, content, url, published, media_json, nsfw, cw, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
    3771     _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ? OFFSET ?');
    3772     _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
    3773   }
    3774   return { ins: _insTl, list: _listTl, del: _delTl };
    3775 }
    3776 /**
    3777  * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).
    3778  *
    3779  * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --
    3780  * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn
    3781  * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en
    3782  * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de
    3783  * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.
    3784  *
    3785  * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`
    3786  * heten en hangt het van de driver af welke wint. Eรฉn extra query per pagina
    3787  * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.
    3788  */
    3789 export function getTimeline(slug, limit, offset) {
    3790   const rows = tlStmts().list.all(slug, limit || 50, offset || 0);
    3791   const reacties = getReactionsFor(slug, rows.map((r) => r.id));
    3792   for (const r of rows) {
    3793     const x = reacties.get(r.id);
    3794     r.liked = !!(x && x.liked);
    3795     r.boosted = !!(x && x.boosted);
    3796   }
    3797   return rows;
    3798 }
    3799 
    3800 /**
    3801  * The direct notes addressed to this account: a plain DM, a guardian's wave
    3802  * (ยง5), a ward's ๐Ÿ›Ÿ help request (ยง5.2.1). They live in ap_mentions and NOT in
    3803  * the timeline, because a note addressed to named people is a message and not a
    3804  * post (belongsInTimeline).
    3805  *
    3806  * A client that only reads the timeline therefore sees none of them, which is
    3807  * exactly what happened to Shaer: Berichten showed your own replies (those come
    3808  * from your outbox) and nothing that was said to you. The C2S inbox read serves
    3809  * both, so the app has one door for everything that arrives.
    3810  *
    3811  * A public mention from someone you follow is stored in both tables; those are
    3812  * skipped here and stay a post.
    3813  */
    3814 // Inbound replies on YOUR posts, for the app's message stream. They live in
    3815 // ap_interactions (the web's comment machinery) and deliberately NOT in
    3816 // ap_mentions (the mention store returns early for replies-to-us), so the
    3817 // C2S read missed them entirely: a reply arrived at the other side
    3818 // everywhere EXCEPT in the other's app (Robins melding, 30-7: "komt niet
    3819 // binnen bij de ander").
    3820 const REPLY_COLUMNS = `
    3821       i.object_uri, i.actor_uri, i.actor_name, i.actor_handle, i.actor_icon, i.actor_url,
    3822       i.content, i.published, i.created_at, i.parent_uri, i.post_id,
    3823       i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json`;
    3824 
    3825 /** Dezelfde antwoordrijen, maar op object-uri -- voor de verschil-lezing. */
    3826 export function replyRowsByUri(slug, uris) {
    3827   const list = (uris || []).filter((u) => typeof u === 'string' && u);
    3828   if (!list.length) return [];
    3829   try {
    3830     const holes = list.map(() => '?').join(',');
    3831     return db.prepare(`SELECT ${REPLY_COLUMNS} FROM ap_interactions i
    3832                         JOIN posts p ON p.id = i.post_id
    3833                         JOIN sites s ON s.id = p.site_id
    3834                        WHERE s.slug = ? AND i.kind = 'reply' AND i.object_uri IN (${holes})`)
    3835       .all(slug, ...list);
    3836   } catch { return []; }
    3837 }
    3838 
    3839 /** Tijdlijnrijen op id, met dezelfde afgeleide liked/boosted als getTimeline. */
    3840 export function timelineRowsByIds(slug, ids) {
    3841   const list = (ids || []).filter((u) => typeof u === 'string' && u);
    3842   if (!list.length) return [];
    3843   try {
    3844     const holes = list.map(() => '?').join(',');
    3845     const rows = db.prepare(`SELECT * FROM ap_timeline WHERE slug = ? AND id IN (${holes})`).all(slug, ...list);
    3846     const reacties = getReactionsFor(slug, rows.map((r) => r.id));
    3847     for (const r of rows) {
    3848       const x = reacties.get(r.id);
    3849       r.liked = !!(x && x.liked);
    3850       r.boosted = !!(x && x.boosted);
    3851     }
    3852     return rows;
    3853   } catch { return []; }
    3854 }
    3855 
    3856 export function getReplyMessages(slug, limit) {
    3857   try {
    3858     return db.prepare(`
    3859       SELECT ${REPLY_COLUMNS}
    3860       FROM ap_interactions i
    3861       JOIN posts p ON p.id = i.post_id
    3862       JOIN sites s ON s.id = p.site_id
    3863       WHERE s.slug = ? AND i.kind = 'reply'
    3864       ORDER BY COALESCE(i.published, i.created_at) DESC LIMIT ?`).all(slug, limit || 60);
    3865   } catch { return []; }
    3866 }
    3867 
    3868 /**
    3869  * Een merk voor "is er iets veranderd aan wat de inbox-lezing zou opleveren?"
    3870  * (shaer-n05).
    3871  *
    3872  * Alle VIER de poten die de inbox samenvoegt tellen mee -- tijdlijn, berichten,
    3873  * antwoorden op je eigen posts, en wat je zelf verstuurde. Zou er een ontbreken,
    3874  * dan blijft een wachtende client slapen terwijl er wel degelijk iets is
    3875  * bijgekomen, en dat is erger dan niet wachten: het lijkt te werken.
    3876  *
    3877  * rowid en niet een tijdstempel: rowid loopt strikt op per invoeging, terwijl
    3878  * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een
    3879  * andere server niet te vertrouwen is.
    3880  *
    3881  * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd
    3882  * mee; de vorm mag veranderen zonder dat dat iets breekt.
    3883  */
    3884 export function feedCursor(slug) {
    3885   try {
    3886     const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);
    3887     return String((r && r.n) || 0);
    3888   } catch { return '0'; }
    3889 }
    3890 
    3891 /**
    3892  * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,
    3893  * bewerkt, of weg.
    3894  *
    3895  * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en
    3896  * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens
    3897  * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige
    3898  * plek waar ze samen te lezen zijn.
    3899  */
    3900 export function feedChangesSince(slug, rev, limit = 200) {
    3901   try {
    3902     return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state
    3903                         WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)
    3904       .all(slug, parseInt(rev, 10) || 0, limit);
    3905   } catch { return []; }
    3906 }
    3907 
    3908 // Zoveel clients mogen er tegelijk op EEN account staan wachten. Een client met
    3909 // een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen
    3910 // krijgen gewoon meteen antwoord in plaats van een fout.
    3911 const FEED_WAIT_MAX = 4;
    3912 const _wachters = new Map();
    3913 
    3914 /**
    3915  * Wacht tot de inbox-lezing iets anders zou opleveren dan bij `since`.
    3916  *
    3917  * Bewust met een interne tik en niet met een gebeurtenis-emitter. Een emitter
    3918  * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je
    3919  * vergeet is precies de melding die nooit aankomt. Twee tot vier MAX(rowid)-
    3920  * queries per seconde is niets, en dit kan niets missen. Prijs: hooguit een tik
    3921  * vertraging.
    3922  */
    3923 export async function waitForFeedChange(slug, opts = {}) {
    3924   const tickMs = Math.max(50, opts.tickMs || 1000);
    3925   const waitMs = Math.max(0, opts.waitMs || 0);
    3926   const since = String(opts.since || '');
    3927   let cursor = feedCursor(slug);
    3928   // Geen sinds, al iets veranderd, of niet willen wachten: meteen antwoorden.
    3929   if (!since || since !== cursor || !waitMs) return { cursor, changed: !!since && since !== cursor, waited: false };
    3930 
    3931   const bezet = _wachters.get(slug) || 0;
    3932   if (bezet >= FEED_WAIT_MAX) return { cursor, changed: false, waited: false, busy: true };
    3933   _wachters.set(slug, bezet + 1);
    3934   try {
    3935     const einde = Date.now() + waitMs;
    3936     while (Date.now() < einde) {
    3937       if (opts.signal && opts.signal.aborted) break;   // client hing op
    3938       const rest = Math.min(tickMs, einde - Date.now());
    3939       await new Promise((r) => setTimeout(r, rest));
    3940       cursor = feedCursor(slug);
    3941       if (cursor !== since) return { cursor, changed: true, waited: true };
    3942     }
    3943     return { cursor, changed: false, waited: true };
    3944   } finally {
    3945     const n = (_wachters.get(slug) || 1) - 1;
    3946     if (n > 0) _wachters.set(slug, n); else _wachters.delete(slug);
    3947   }
    3948 }
    3949 
    3950 // โ”€โ”€ Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    3951 //
    3952 // De oude lezing gaf de nieuwste 60 berichten over ALLE gesprekken samen. Dat
    3953 // knipt geschiedenis weg zonder dat iemand het merkt, en het is bij DM's veel
    3954 // erger dan bij posts: dat zijn er meer en het zijn kortere berichten, dus een
    3955 // druk gesprek kan de 60 in zijn eentje opeten en de rest uit de lezing duwen.
    3956 // Viel het laatste bericht van iemand erbuiten, dan verdween die persoon
    3957 // helemaal uit Messages -- de avatarhemel plaatst mensen op de leeftijd van hun
    3958 // laatste bericht, dus geen bericht is geen gezicht.
    3959 //
    3960 // Vandaar twee lezingen. Deze geeft EEN rij per tegenpartij, hoe druk iemand
    3961 // ook is, en conversationHistory hieronder geeft het gesprek zelf met een
    3962 // cursor. Wat de client van de hemel nodig heeft -- wie, wanneer, en waarmee --
    3963 // zit in die ene nieuwste note.
    3964 //
    3965 // Een gesprek is hier hetzelfde als in de app: incoming zijn de ap_mentions
    3966 // (die tabel IS de aan ons gerichte post), uitgaand zijn de eigen notes met
    3967 // visibility 'direct'. Een publiek antwoord is geen gesprek en hoort niet als
    3968 // gezicht in de hemel.
    3969 /**
    3970  * EEN STEMPEL IN EEN VORM, en dat is hier geen netheid maar de volgorde zelf.
    3971  *
    3972  * Drie vormen kwamen samen in deze unie: `2026-08-13 19:26:17` van SQLite's
    3973  * CURRENT_TIMESTAMP, `2026-08-13T18:21:57Z` uit een object, en dezelfde met
    3974  * milliseconden. Als TEKST vergeleken staat op plek 10 een spatie tegen een
    3975  * T -- en een spatie is kleiner. Dus sorteerde binnen dezelfde dag alles wat
    3976  * JIJ stuurde vรณรณr alles wat binnenkwam, ongeacht de klok (Barts melding 14-8:
    3977  * een bericht van 00:30 stond boven een antwoord van 20:22 de avond ervoor).
    3978  *
    3979  * strftime leest alle drie en geeft er een vorm voor terug, in UTC. Lukt het
    3980  * niet, dan blijft de rauwe waarde staan -- dan is die ene rij verkeerd
    3981  * gesorteerd in plaats van de hele lijst.
    3982  *
    3983  * Dit gaat ook de client aan: `new Date('2026-08-13 19:26:17')` leest in
    3984  * JavaScript als LOKALE tijd en `...T19:26:17Z` als UTC. Dezelfde rij gaf dus
    3985  * een leeftijd die twee uur verschilde per vorm.
    3986  */
    3987 const STEMPEL = (rauw) => `COALESCE(strftime('%Y-%m-%dT%H:%M:%SZ', ${rauw}), ${rauw})`;
    3988 
    3989 const CONVERSATION_UNION = `
    3990   SELECT m.actor_uri AS other, ${STEMPEL('COALESCE(m.published, m.created_at)')} AS stamp,
    3991          'in' AS direction, m.object_uri AS ref
    3992     FROM ap_mentions m
    3993    WHERE m.slug = @slug AND m.actor_uri IS NOT NULL AND m.actor_uri <> ''
    3994   UNION ALL
    3995   SELECT j.value AS other, ${STEMPEL('o.created_at')} AS stamp,
    3996          'out' AS direction, o.id AS ref
    3997     FROM ap_outbox o
    3998     JOIN json_each(COALESCE(NULLIF(o.to_actors, ''), json_array(o.to_actor))) j
    3999    WHERE o.site_slug = @slug AND o.visibility = 'direct'
    4000      AND j.value IS NOT NULL AND j.value <> ''`;
    4001 
    4002 /**
    4003  * Een rij per tegenpartij: zijn nieuwste bericht, nieuwste gesprek eerst.
    4004  *
    4005  * Compleet van vorm -- het aantal rijen is het aantal mensen, niet het aantal
    4006  * berichten -- dus de hemel kan niemand meer kwijtraken doordat een ander druk
    4007  * was. Zonder limiet, en dat mag: dit schaalt met je kring.
    4008  */
    4009 export function conversationHeads(slug) {
    4010   try {
    4011     // Twee rijen per persoon, niet een: het nieuwste bericht (dat bepaalt waar
    4012     // iemand in de hemel hangt) EN het nieuwste bericht VAN HEM.
    4013     //
    4014     // Die tweede is er omdat het nieuwste bericht van jou kan zijn, en dan
    4015     // draagt het jouw byline. De hemel zoekt de naam en het gezicht van de
    4016     // ander in een bericht van de ander -- vond hij dat niet, dan viel hij
    4017     // terug op het staartje van de actor-uri en heette tante opeens
    4018     // 'hotelbreakfast'. Op het toestel gezien, 10-8.
    4019     //
    4020     // Valt het samen (het nieuwste is al van hem), dan is het een rij; dubbel
    4021     // sturen doen we niet.
    4022     return db.prepare(`
    4023       SELECT other, stamp, direction, ref FROM (
    4024         SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn
    4025           FROM (${CONVERSATION_UNION})
    4026       ) WHERE rn = 1
    4027       UNION
    4028       SELECT other, stamp, direction, ref FROM (
    4029         SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn
    4030           FROM (${CONVERSATION_UNION}) WHERE direction = 'in'
    4031       ) WHERE rn = 1
    4032       ORDER BY stamp DESC, ref DESC`).all({ slug });
    4033   } catch { return []; }
    4034 }
    4035 
    4036 /**
    4037  * Een gesprek, nieuwste eerst, met een cursor.
    4038  *
    4039  * BEIDE KANTEN ONDER EEN LIMIET. In de oude lezing werden jouw kant
    4040  * (getSentNotes) en hun kant apart afgekapt, waardoor een gesprek eenzijdig
    4041  * kon lijken -- alsof iemand nooit geantwoord had. Hier is de limiet er een
    4042  * voor het gesprek als geheel.
    4043  *
    4044  * `before` is de cursor van het OUDSTE bericht dat je al hebt; je krijgt wat
    4045  * daarvoor ligt. Er komt er een extra op om te weten of er nog meer is: de
    4046  * client hoort dat te weten zonder te moeten gokken, en zonder dat weten kan
    4047  * 'load more' niet eerlijk verschijnen.
    4048  *
    4049  * DE CURSOR IS SAMENGESTELD -- '<stempel>|<ref>' -- en niet alleen de stempel.
    4050  * Twee berichten in dezelfde seconde is bij DM's geen randgeval maar een
    4051  * gesprek, en met 'stamp < before' zou alles wat die grensseconde deelt stil
    4052  * overgeslagen worden. Je zou het niet merken: de pagina komt gewoon, er
    4053  * ontbreekt alleen iets in het midden.
    4054  */
    4055 const cursorOf = (r) => (r ? `${r.stamp}|${r.ref}` : null);
    4056 
    4057 export function conversationHistory(slug, other, { before = null, limit = 60 } = {}) {
    4058   try {
    4059     const n = Math.min(Math.max(parseInt(limit, 10) || 60, 1), 200);
    4060     const sep = String(before || '').indexOf('|');
    4061     const bStamp = before && sep > 0 ? String(before).slice(0, sep) : null;
    4062     const bRef = before && sep > 0 ? String(before).slice(sep + 1) : null;
    4063     const rows = db.prepare(`
    4064       SELECT other, stamp, direction, ref FROM (${CONVERSATION_UNION})
    4065        WHERE other = @other
    4066          AND (@bStamp IS NULL OR stamp < @bStamp OR (stamp = @bStamp AND ref < @bRef))
    4067        ORDER BY stamp DESC, ref DESC LIMIT @n`).all({ slug, other, bStamp, bRef, n: n + 1 });
    4068     const more = rows.length > n;
    4069     const page = more ? rows.slice(0, n) : rows;
    4070     return { rows: page, more, oldest: cursorOf(page[page.length - 1]) };
    4071   } catch { return { rows: [], more: false, oldest: null }; }
    4072 }
    4073 
    4074 // De kolommen die een bericht tot kaart maken. Een constante, want de
    4075 // gesprekslezing haalt dezelfde rows op: twee lijsten die uiteenlopen leveren
    4076 // een kaart die op de ene plek een plaatje heeft en op de andere niet.
    4077 const MESSAGE_COLUMNS = `
    4078       m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,
    4079       m.content, m.published, m.created_at, m.wave, m.help_request,
    4080       m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json`;
    4081 
    4082 /** Dezelfde berichtrijen, maar op object-uri -- voor een gesprek. */
    4083 export function messageRowsByUri(slug, uris) {
    4084   const lijst = (uris || []).filter((u) => typeof u === 'string' && u);
    4085   if (!lijst.length) return [];
    4086   try {
    4087     const gaten = lijst.map(() => '?').join(',');
    4088     return db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM ap_mentions m
    4089                         WHERE m.slug = ? AND m.object_uri IN (${gaten})`).all(slug, ...lijst);
    4090   } catch { return []; }
    4091 }
    4092 
    4093 /**
    4094  * Tot waar deze lezer elk gesprek gelezen heeft (shaer-frontend-3tx).
    4095  *
    4096  * De markering komt uit AS2 `Read`-activiteiten, en die zijn OPTELLEND: het
    4097  * lezen van bericht N maakt niets anders ongelezen. Daarom is achteruit gaan
    4098  * geen regel die iemand moet onthouden maar een eigenschap van het model --
    4099  * markRead neemt het maximum. Een 'zet mijn markering op X' zou een toestel
    4100  * dat een week uit stond je gelezen berichten weer op ongelezen laten zetten.
    4101  */
    4102 export function readMarkers(slug) {
    4103   try {
    4104     return new Map(db.prepare('SELECT other, cursor FROM ap_read_markers WHERE slug = ?')
    4105       .all(slug).map((r) => [r.other, r.cursor]));
    4106   } catch { return new Map(); }
    4107 }
    4108 
    4109 /**
    4110  * Markeer een gesprek als gelezen tot en met dit bericht.
    4111  *
    4112  * Het object van de Read is een berichturi; welk gesprek dat is en waar het in
    4113  * de tijd staat weet de server zelf, dus de client hoeft niets uit te rekenen
    4114  * en kan er ook niet naast zitten.
    4115  */
    4116 export function markRead(slug, objectUri) {
    4117   try {
    4118     const rij = db.prepare(`SELECT other, stamp, ref FROM (${CONVERSATION_UNION})
    4119                              WHERE ref = @ref ORDER BY stamp DESC LIMIT 1`)
    4120       .get({ slug, ref: String(objectUri || '') });
    4121     if (!rij) return null;
    4122     const cursor = `${rij.stamp}|${rij.ref}`;
    4123     db.prepare(`INSERT INTO ap_read_markers (slug, other, cursor) VALUES (?,?,?)
    4124                 ON CONFLICT(slug, other) DO UPDATE SET cursor = MAX(cursor, excluded.cursor), at = CURRENT_TIMESTAMP`)
    4125       .run(slug, rij.other, cursor);
    4126     return { other: rij.other, cursor };
    4127   } catch { return null; }
    4128 }
    4129 
    4130 /**
    4131  * Hoeveel er per gesprek nog ongelezen is, en of daar een zwaai bij zit.
    4132  *
    4133  * Een COUNT en geen bijgehouden getal (Barts besluit): niets om op te hogen
    4134  * bij bezorging, niets om te verlagen bij lezen, en bij een verwijdering klopt
    4135  * het vanzelf weer.
    4136  *
    4137  * Een zwaai telt apart, want dat is geen gesprek maar een zetje van een
    4138  * guardian -- die hoort een eigen teken te krijgen en niet opgeteld te worden.
    4139  * Eigen berichten tellen nooit mee: je hebt jezelf gelezen.
    4140  */
    4141 export function unreadPerConversation(slug, { messagesAllowed = true, guardians = new Set() } = {}) {
    4142   try {
    4143     // DE POORT TELT MEE. Staat messages dicht, dan toont de app die berichten
    4144     // niet -- en dan mag een badge ze ook niet aankondigen, want dat getal
    4145     // vertelt precies wat de poort verbergt. Wat er altijd door mag telt wel:
    4146     // het guardian-kanaal en de boei. Zelfde regel als bij de serialisatie.
    4147     const rijen = db.prepare(`
    4148       SELECT u.other AS other,
    4149              COUNT(*) AS n,
    4150              MAX(CASE WHEN m.wave = 1 THEN 1 ELSE 0 END) AS wave
    4151         FROM (${CONVERSATION_UNION}) u
    4152         LEFT JOIN ap_read_markers r ON r.slug = @slug AND r.other = u.other
    4153         LEFT JOIN ap_mentions m ON m.slug = @slug AND m.object_uri = u.ref
    4154        WHERE u.direction = 'in'
    4155          AND (r.cursor IS NULL OR (u.stamp || '|' || u.ref) > r.cursor)
    4156          AND (@open = 1 OR m.help_request = 1 OR u.other IN (SELECT value FROM json_each(@guardians)))
    4157        GROUP BY u.other`)
    4158       .all({ slug, open: messagesAllowed ? 1 : 0, guardians: JSON.stringify([...guardians]) });
    4159     return new Map(rijen.map((r) => [r.other, { n: r.n, wave: !!r.wave }]));
    4160   } catch { return new Map(); }
    4161 }
    4162 
    4163 export function getDirectMessages(slug, limit) {
    4164   try {
    4165     return db.prepare(`
    4166       SELECT ${MESSAGE_COLUMNS}
    4167       FROM ap_mentions m
    4168       WHERE m.slug = ?
    4169         AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
    4170       ORDER BY COALESCE(m.published, m.created_at) DESC LIMIT ?`).all(slug, limit || 60);
    4171   } catch { return []; }
    4172 }
    4173 
    4174 /**
    4175  * A stored stamp as an ISO instant. SQLite's CURRENT_TIMESTAMP writes
    4176  * 'YYYY-MM-DD HH:MM:SS' in UTC, which Date.parse reads as LOCAL time; on a
    4177  * server two hours ahead that dated every message two hours early and put the
    4178  * conversation in the wrong order. A `published` from the wire is already ISO
    4179  * and passes through untouched.
    4180  */
    4181 export function isoStamp(v) {
    4182   if (!v) return undefined;
    4183   const s = String(v);
    4184   if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s)) return `${s.replace(' ', 'T')}Z`;
    4185   const t = Date.parse(s);
    4186   return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
    4187 }
    4188 
    4189 // Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
    4190 // inbound Create) โ†’ AS2 `attachment` array, so a client (Shaer) can render a
    4191 // friend's images/audio/video natively, exactly like own outbox posts. The
    4192 // stored `type` is the mediaType and may be ''. Malformed JSON yields
    4193 // undefined and never blocks the item.
    4194 export function timelineAttachments(mediaJson) {
    4195   try {
    4196     const list = mediaJson ? JSON.parse(mediaJson) : [];
    4197     const rows = (Array.isArray(list) ? list : [])
    4198       .filter((m) => m && m.url)
    4199       .map((m) => {
    4200         const a = { type: 'Document', mediaType: m.type || undefined, url: m.url };
    4201         if (m.poster) a.icon = { type: 'Image', url: m.poster }; // the video's still (shaer-zowq)
    4202         return a;
    4203       });
    4204     return rows.length ? rows : undefined;
    4205   } catch { return undefined; }
    4206 }
    4207 
    4208 // FEP-9098 custom emojis. Inbound: keep the note's Emoji tags (as JSON) so we
    4209 // can serve them back. `extractEmojiTags` returns the JSON to store (or null);
    4210 // `timelineEmojis` turns the stored JSON back into an AS2 `tag` array for the
    4211 // C2S inbox read, so a client (Shaer) can render :shortcode: as an image.
    4212 export function extractEmojiTags(tag) {
    4213   const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
    4214   const emojis = arr.filter((t) => t && (Array.isArray(t.type) ? t.type[0] : t.type) === 'Emoji'
    4215     && typeof t.name === 'string' && t.icon);
    4216   return emojis.length ? JSON.stringify(emojis) : null;
    4217 }
    4218 // โ”€โ”€ Gate-filters voor de C2S-serialisatie (shaer-ahy.1, 8-8) โ”€โ”€โ”€โ”€โ”€โ”€
    4219 //
    4220 // Dezelfde regel als bij de embeds: de poort zit bij de AFLEVERING. Een
    4221 // bijlage die de client alleen verbergt is wel degelijk geleverd, dus wat
    4222 // dicht is wordt hier nooit geserialiseerd. Puur, zodat de regels los van de
    4223 // routes te toetsen zijn.
    4224 
    4225 /** Bijlagen door de beeld- en muziekpoort. Leeg wordt undefined, zoals de
    4226  *  serialisatie dat overal doet. */
    4227 export function gateAttachments(atts, { images = true, audio = true } = {}) {
    4228   if (!Array.isArray(atts)) return atts;
    4229   const out = atts.filter((a) => {
    4230     const mt = String((a && a.mediaType) || '');
    4231     if (!images && mt.startsWith('image/')) return false;
    4232     if (!audio && (mt.startsWith('audio/') || (a && a.type === 'Audio'))) return false;
    4233     return true;
    4234   });
    4235   return out.length ? out : undefined;
    4236 }
    4237 
    4238 /** Tag-array zonder de FEP-9098 Emoji-tags, voor een dichte emoji-poort. De
    4239  *  :shortcode: blijft als tekst staan -- dat is eerlijk: er STAAT iets, het
    4240  *  wordt alleen niet als plaatje van een vreemde server gerenderd. */
    4241 export function stripEmojiTags(tags) {
    4242   if (!Array.isArray(tags)) return tags;
    4243   const out = tags.filter((t) => (Array.isArray(t && t.type) ? t.type[0] : (t && t.type)) !== 'Emoji');
    4244   return out.length ? out : undefined;
    4245 }
    4246 
    4247 export function timelineEmojis(emojiJson) {
    4248   try { const arr = emojiJson ? JSON.parse(emojiJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
    4249   catch { return undefined; }
    4250 }
    4251 
    4252 // FEP-e232 object links (quotes / inline references). Inbound: keep the note's
    4253 // Link tags whose mediaType marks an AP object (the AS2-profiled ld+json, or
    4254 // activity+json as its equivalent) as JSON, so the C2S inbox read can serve
    4255 // them back and a client (Shaer) can render the quote/reference. Mirrors
    4256 // extractEmojiTags. Plain hyperlinks (text/html) and Mentions are dropped.
    4257 export function extractObjectLinkTags(tag) {
    4258   const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
    4259   const links = arr.filter((t) => {
    4260     if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link') return false;
    4261     if (typeof t.href !== 'string' || !t.href) return false;
    4262     const mt = String(t.mediaType || '').toLowerCase();
    4263     return (mt.startsWith('application/ld+json') && mt.includes('activitystreams'))
    4264       || mt.startsWith('application/activity+json');
    4265   });
    4266   return links.length ? JSON.stringify(links) : null;
    4267 }
    4268 export function timelineObjectLinks(linkJson) {
    4269   try { const arr = linkJson ? JSON.parse(linkJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
    4270   catch { return undefined; }
    4271 }
    4272 
    4273 // FEP-044f quote posts: a quote is usually NOT an FEP-e232 tag but an
    4274 // object-level property. FEP-044f ยง"how to recognise" lists them all:
    4275 // `quote` (the FEP property, a string or an embedded Link/object), and the
    4276 // de-facto `quoteUrl` (as:), `quoteUri` (fedibird), `_misskey_quote` (misskey).
    4277 // This returns the quoted object's URL from whichever is present.
    4278 export function extractQuoteUrl(note) {
    4279   if (!note || typeof note !== 'object') return null;
    4280   const q = note.quote ?? note.quoteUrl ?? note.quoteUri ?? note['_misskey_quote'];
    4281   if (!q) return null;
    4282   if (typeof q === 'string') return q || null;
    4283   if (typeof q === 'object') return (typeof q.id === 'string' && q.id) || (typeof q.href === 'string' && q.href) || null;
    4284   return null;
    4285 }
    4286 
    4287 // The note's object-link tags for storage: real FEP-e232 Link tags PLUS any
    4288 // FEP-044f object-level quote, normalised to one FEP-e232-shaped Link (rel
    4289 // _misskey_quote) so the client's single object-link path renders them all.
    4290 // Deduped by href. Returns the JSON to store (or null if the note has neither).
    4291 export function extractLinkJson(note) {
    4292   const links = [];
    4293   const fromTag = extractObjectLinkTags(note && note.tag);
    4294   if (fromTag) { try { links.push(...JSON.parse(fromTag)); } catch { /* ignore */ } }
    4295   const qUrl = extractQuoteUrl(note);
    4296   if (qUrl && !links.some((l) => l && l.href === qUrl)) {
    4297     links.push({ type: 'Link', mediaType: 'application/activity+json', href: qUrl,
    4298       rel: ['https://misskey-hub.net/ns#_misskey_quote'], name: qUrl });
    4299   }
    4300   return links.length ? JSON.stringify(links) : null;
    4301 }
    4302 
    4303 // The URL of the quoted post, from either an object-level quote (FEP-044f) or a
    4304 // quote-rel FEP-e232 Link tag. Used to resolve the embedded quote card.
    4305 export function quoteHrefOf(note) {
    4306   const direct = extractQuoteUrl(note);
    4307   if (direct) return direct;
    4308   const arr = Array.isArray(note && note.tag) ? note.tag : (note && note.tag ? [note.tag] : []);
    4309   for (const t of arr) {
    4310     if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link' || typeof t.href !== 'string') continue;
    4311     const rel = Array.isArray(t.rel) ? t.rel : (t.rel ? [t.rel] : []);
    4312     if (rel.some((r) => /quote/i.test(String(r)))) return t.href;
    4313   }
    4314   return null;
    4315 }
    4316 
    4317 // Turn the stored quote snapshot back into the object the C2S inbox read serves
    4318 // as `shaer:quote`, so the client can render the embedded quote card.
    4319 export function timelineQuote(quoteJson) {
    4320   try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }
    4321   catch { return undefined; }
    4322 }
    43233790
    43243791// Store the author's display-name emoji map (from actorInfo().emojis) on a
     
    63055772  deliverCreate,
    63065773});
     5774// En de tijdlijn-leeskant zijn ene werktuig (stap 5): liked/boosted komen uit
     5775// het reactiecluster, dat hier woont.
     5776wireTimeline({ getReactionsFor });
    63075777
    63085778export default {
Note: See TracChangeset for help on using the changeset viewer.