source: Klonkt/src/services/ap-timeline.js@ 6e9bc70

main
Last change on this file since 6e9bc70 was 6e9bc70, checked in by Bart <bart@…>, 2 days ago

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

  • Property mode set to 100644
File size: 28.1 KB
Line 
1/**
2 * ap-timeline.js — de leeskant van de fediverse-tijdlijn (stap 5 van shaer-drc).
3 *
4 * Alles wat een client of route uit ap_timeline en de gesprekken LEEST:
5 * de tijdlijn zelf, de feed-cursor met long-poll, de gesprekslijsten en
6 * leesmarkeringen, en de serialisatiehulpen (bijlagen, emoji, object-links,
7 * citaten) die een rij naar de C2S-vorm vertalen.
8 *
9 * De SCHRIJFKANT blijft waar hij was: de inbox, de backfill en self-heal
10 * schrijven via tlStmts, dat hierom mee-exporteert. Een module importeert
11 * nooit uit ActivityPubService; de ene uitzondering op "alleen omlaag" --
12 * getReactionsFor, uit het reactiecluster -- komt daarom binnen via
13 * wireTimeline, hetzelfde injectiepatroon als guardianship en ap-c2s.
14 */
15import db, { isoSql, NU_ISO } from '../config/database.js';
16
17// De helper woont sinds shaer-a937 in config/database.js: elke plek die
18// sorteert had hem nodig, en twee kopieen van dezelfde regel lopen uit elkaar.
19// Bovenaan, want de eerste statements hieronder gebruiken hem al.
20const STEMPEL = isoSql;
21
22// Het ene werktuig uit de dienstlaag. ActivityPubService vult het onderaan
23// zijn eigen evaluatie; een aanroep voor de koppeling is een programmeerfout.
24let getReactionsFor;
25export function wireTimeline(deps) {
26 ({ getReactionsFor } = deps);
27}
28
29let _insTl, _listTl, _delTl;
30export function tlStmts() {
31 if (!_insTl) {
32 _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 (?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`);
33 _listTl = db.prepare(`SELECT * FROM ap_timeline WHERE slug = ? ORDER BY ${STEMPEL('COALESCE(published, created_at)')} DESC LIMIT ? OFFSET ?`);
34 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
35 }
36 return { ins: _insTl, list: _listTl, del: _delTl };
37}
38/**
39 * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).
40 *
41 * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --
42 * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn
43 * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en
44 * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de
45 * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.
46 *
47 * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`
48 * heten en hangt het van de driver af welke wint. Eén extra query per pagina
49 * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.
50 */
51export function getTimeline(slug, limit, offset) {
52 const rows = tlStmts().list.all(slug, limit || 50, offset || 0);
53 const reacties = getReactionsFor(slug, rows.map((r) => r.id));
54 for (const r of rows) {
55 const x = reacties.get(r.id);
56 r.liked = !!(x && x.liked);
57 r.boosted = !!(x && x.boosted);
58 }
59 return rows;
60}
61
62/**
63 * The direct notes addressed to this account: a plain DM, a guardian's wave
64 * (§5), a ward's 🛟 help request (§5.2.1). They live in ap_mentions and NOT in
65 * the timeline, because a note addressed to named people is a message and not a
66 * post (belongsInTimeline).
67 *
68 * A client that only reads the timeline therefore sees none of them, which is
69 * exactly what happened to Shaer: Berichten showed your own replies (those come
70 * from your outbox) and nothing that was said to you. The C2S inbox read serves
71 * both, so the app has one door for everything that arrives.
72 *
73 * A public mention from someone you follow is stored in both tables; those are
74 * skipped here and stay a post.
75 */
76// Inbound replies on YOUR posts, for the app's message stream. They live in
77// ap_interactions (the web's comment machinery) and deliberately NOT in
78// ap_mentions (the mention store returns early for replies-to-us), so the
79// C2S read missed them entirely: a reply arrived at the other side
80// everywhere EXCEPT in the other's app (Robins melding, 30-7: "komt niet
81// binnen bij de ander").
82const REPLY_COLUMNS = `
83 i.object_uri, i.actor_uri, i.actor_name, i.actor_handle, i.actor_icon, i.actor_url,
84 i.content, i.published, i.created_at, i.parent_uri, i.post_id,
85 i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json`;
86
87/** Dezelfde antwoordrijen, maar op object-uri -- voor de verschil-lezing. */
88export function replyRowsByUri(slug, uris) {
89 const list = (uris || []).filter((u) => typeof u === 'string' && u);
90 if (!list.length) return [];
91 try {
92 const holes = list.map(() => '?').join(',');
93 return db.prepare(`SELECT ${REPLY_COLUMNS} FROM ap_interactions i
94 JOIN posts p ON p.id = i.post_id
95 JOIN sites s ON s.id = p.site_id
96 WHERE s.slug = ? AND i.kind = 'reply' AND i.object_uri IN (${holes})`)
97 .all(slug, ...list);
98 } catch { return []; }
99}
100
101/** Tijdlijnrijen op id, met dezelfde afgeleide liked/boosted als getTimeline. */
102export function timelineRowsByIds(slug, ids) {
103 const list = (ids || []).filter((u) => typeof u === 'string' && u);
104 if (!list.length) return [];
105 try {
106 const holes = list.map(() => '?').join(',');
107 const rows = db.prepare(`SELECT * FROM ap_timeline WHERE slug = ? AND id IN (${holes})`).all(slug, ...list);
108 const reacties = getReactionsFor(slug, rows.map((r) => r.id));
109 for (const r of rows) {
110 const x = reacties.get(r.id);
111 r.liked = !!(x && x.liked);
112 r.boosted = !!(x && x.boosted);
113 }
114 return rows;
115 } catch { return []; }
116}
117
118export function getReplyMessages(slug, limit) {
119 try {
120 return db.prepare(`
121 SELECT ${REPLY_COLUMNS}
122 FROM ap_interactions i
123 JOIN posts p ON p.id = i.post_id
124 JOIN sites s ON s.id = p.site_id
125 WHERE s.slug = ? AND i.kind = 'reply'
126 ORDER BY ${STEMPEL('COALESCE(i.published, i.created_at)')} DESC LIMIT ?`).all(slug, limit || 60);
127 } catch { return []; }
128}
129
130/**
131 * Een merk voor "is er iets veranderd aan wat de inbox-lezing zou opleveren?"
132 * (shaer-n05).
133 *
134 * Alle VIER de poten die de inbox samenvoegt tellen mee -- tijdlijn, berichten,
135 * antwoorden op je eigen posts, en wat je zelf verstuurde. Zou er een ontbreken,
136 * dan blijft een wachtende client slapen terwijl er wel degelijk iets is
137 * bijgekomen, en dat is erger dan niet wachten: het lijkt te werken.
138 *
139 * rowid en niet een tijdstempel: rowid loopt strikt op per invoeging, terwijl
140 * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een
141 * andere server niet te vertrouwen is.
142 *
143 * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd
144 * mee; de vorm mag veranderen zonder dat dat iets breekt.
145 */
146export function feedCursor(slug) {
147 try {
148 const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);
149 return String((r && r.n) || 0);
150 } catch { return '0'; }
151}
152
153/**
154 * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,
155 * bewerkt, of weg.
156 *
157 * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en
158 * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens
159 * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige
160 * plek waar ze samen te lezen zijn.
161 */
162export function feedChangesSince(slug, rev, limit = 200) {
163 try {
164 return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state
165 WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)
166 .all(slug, parseInt(rev, 10) || 0, limit);
167 } catch { return []; }
168}
169
170// Zoveel clients mogen er tegelijk op EEN account staan wachten. Een client met
171// een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen
172// krijgen gewoon meteen antwoord in plaats van een fout.
173const FEED_WAIT_MAX = 4;
174const _wachters = new Map();
175
176/**
177 * Wacht tot de inbox-lezing iets anders zou opleveren dan bij `since`.
178 *
179 * Bewust met een interne tik en niet met een gebeurtenis-emitter. Een emitter
180 * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je
181 * vergeet is precies de melding die nooit aankomt. Twee tot vier MAX(rowid)-
182 * queries per seconde is niets, en dit kan niets missen. Prijs: hooguit een tik
183 * vertraging.
184 */
185export async function waitForFeedChange(slug, opts = {}) {
186 const tickMs = Math.max(50, opts.tickMs || 1000);
187 const waitMs = Math.max(0, opts.waitMs || 0);
188 const since = String(opts.since || '');
189 let cursor = feedCursor(slug);
190 // Geen sinds, al iets veranderd, of niet willen wachten: meteen antwoorden.
191 if (!since || since !== cursor || !waitMs) return { cursor, changed: !!since && since !== cursor, waited: false };
192
193 const bezet = _wachters.get(slug) || 0;
194 if (bezet >= FEED_WAIT_MAX) return { cursor, changed: false, waited: false, busy: true };
195 _wachters.set(slug, bezet + 1);
196 try {
197 const einde = Date.now() + waitMs;
198 while (Date.now() < einde) {
199 if (opts.signal && opts.signal.aborted) break; // client hing op
200 const rest = Math.min(tickMs, einde - Date.now());
201 await new Promise((r) => setTimeout(r, rest));
202 cursor = feedCursor(slug);
203 if (cursor !== since) return { cursor, changed: true, waited: true };
204 }
205 return { cursor, changed: false, waited: true };
206 } finally {
207 const n = (_wachters.get(slug) || 1) - 1;
208 if (n > 0) _wachters.set(slug, n); else _wachters.delete(slug);
209 }
210}
211
212// ── Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) ──────────
213//
214// De oude lezing gaf de nieuwste 60 berichten over ALLE gesprekken samen. Dat
215// knipt geschiedenis weg zonder dat iemand het merkt, en het is bij DM's veel
216// erger dan bij posts: dat zijn er meer en het zijn kortere berichten, dus een
217// druk gesprek kan de 60 in zijn eentje opeten en de rest uit de lezing duwen.
218// Viel het laatste bericht van iemand erbuiten, dan verdween die persoon
219// helemaal uit Messages -- de avatarhemel plaatst mensen op de leeftijd van hun
220// laatste bericht, dus geen bericht is geen gezicht.
221//
222// Vandaar twee lezingen. Deze geeft EEN rij per tegenpartij, hoe druk iemand
223// ook is, en conversationHistory hieronder geeft het gesprek zelf met een
224// cursor. Wat de client van de hemel nodig heeft -- wie, wanneer, en waarmee --
225// zit in die ene nieuwste note.
226//
227// Een gesprek is hier hetzelfde als in de app: incoming zijn de ap_mentions
228// (die tabel IS de aan ons gerichte post), uitgaand zijn de eigen notes met
229// visibility 'direct'. Een publiek antwoord is geen gesprek en hoort niet als
230// gezicht in de hemel.
231/**
232 * EEN STEMPEL IN EEN VORM, en dat is hier geen netheid maar de volgorde zelf.
233 *
234 * Drie vormen kwamen samen in deze unie: `2026-08-13 19:26:17` van SQLite's
235 * CURRENT_TIMESTAMP, `2026-08-13T18:21:57Z` uit een object, en dezelfde met
236 * milliseconden. Als TEKST vergeleken staat op plek 10 een spatie tegen een
237 * T -- en een spatie is kleiner. Dus sorteerde binnen dezelfde dag alles wat
238 * JIJ stuurde vóór alles wat binnenkwam, ongeacht de klok (Barts melding 14-8:
239 * een bericht van 00:30 stond boven een antwoord van 20:22 de avond ervoor).
240 *
241 * strftime leest alle drie en geeft er een vorm voor terug, in UTC. Lukt het
242 * niet, dan blijft de rauwe waarde staan -- dan is die ene rij verkeerd
243 * gesorteerd in plaats van de hele lijst.
244 *
245 * Dit gaat ook de client aan: `new Date('2026-08-13 19:26:17')` leest in
246 * JavaScript als LOKALE tijd en `...T19:26:17Z` als UTC. Dezelfde rij gaf dus
247 * een leeftijd die twee uur verschilde per vorm.
248 */
249
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.
260const CONVERSATION_UNION = `
261 SELECT COALESCE(NULLIF(m.conv, ''), m.actor_uri) AS other,
262 ${STEMPEL('COALESCE(m.published, m.created_at)')} AS stamp,
263 'in' AS direction, m.object_uri AS ref
264 FROM ap_mentions m
265 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 <> ''
272 UNION ALL
273 SELECT j.value AS other, ${STEMPEL('o.created_at')} AS stamp,
274 'out' AS direction, o.id AS ref
275 FROM ap_outbox o
276 JOIN json_each(COALESCE(NULLIF(o.to_actors, ''), json_array(o.to_actor))) j
277 WHERE o.site_slug = @slug AND o.visibility = 'direct'
278 AND (o.conv IS NULL OR o.conv = '')
279 AND j.value IS NOT NULL AND j.value <> ''`;
280
281/**
282 * Een rij per tegenpartij: zijn nieuwste bericht, nieuwste gesprek eerst.
283 *
284 * Compleet van vorm -- het aantal rijen is het aantal mensen, niet het aantal
285 * berichten -- dus de hemel kan niemand meer kwijtraken doordat een ander druk
286 * was. Zonder limiet, en dat mag: dit schaalt met je kring.
287 */
288export function conversationHeads(slug) {
289 try {
290 // Twee rijen per persoon, niet een: het nieuwste bericht (dat bepaalt waar
291 // iemand in de hemel hangt) EN het nieuwste bericht VAN HEM.
292 //
293 // Die tweede is er omdat het nieuwste bericht van jou kan zijn, en dan
294 // draagt het jouw byline. De hemel zoekt de naam en het gezicht van de
295 // ander in een bericht van de ander -- vond hij dat niet, dan viel hij
296 // terug op het staartje van de actor-uri en heette tante opeens
297 // 'hotelbreakfast'. Op het toestel gezien, 10-8.
298 //
299 // Valt het samen (het nieuwste is al van hem), dan is het een rij; dubbel
300 // sturen doen we niet.
301 return db.prepare(`
302 SELECT other, stamp, direction, ref FROM (
303 SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn
304 FROM (${CONVERSATION_UNION})
305 ) WHERE rn = 1
306 UNION
307 SELECT other, stamp, direction, ref FROM (
308 SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn
309 FROM (${CONVERSATION_UNION}) WHERE direction = 'in'
310 ) WHERE rn = 1
311 ORDER BY stamp DESC, ref DESC`).all({ slug });
312 } catch { return []; }
313}
314
315/**
316 * Een gesprek, nieuwste eerst, met een cursor.
317 *
318 * BEIDE KANTEN ONDER EEN LIMIET. In de oude lezing werden jouw kant
319 * (getSentNotes) en hun kant apart afgekapt, waardoor een gesprek eenzijdig
320 * kon lijken -- alsof iemand nooit geantwoord had. Hier is de limiet er een
321 * voor het gesprek als geheel.
322 *
323 * `before` is de cursor van het OUDSTE bericht dat je al hebt; je krijgt wat
324 * daarvoor ligt. Er komt er een extra op om te weten of er nog meer is: de
325 * client hoort dat te weten zonder te moeten gokken, en zonder dat weten kan
326 * 'load more' niet eerlijk verschijnen.
327 *
328 * DE CURSOR IS SAMENGESTELD -- '<stempel>|<ref>' -- en niet alleen de stempel.
329 * Twee berichten in dezelfde seconde is bij DM's geen randgeval maar een
330 * gesprek, en met 'stamp < before' zou alles wat die grensseconde deelt stil
331 * overgeslagen worden. Je zou het niet merken: de pagina komt gewoon, er
332 * ontbreekt alleen iets in het midden.
333 */
334const cursorOf = (r) => (r ? `${r.stamp}|${r.ref}` : null);
335
336export function conversationHistory(slug, other, { before = null, limit = 60 } = {}) {
337 try {
338 const n = Math.min(Math.max(parseInt(limit, 10) || 60, 1), 200);
339 const sep = String(before || '').indexOf('|');
340 const bStamp = before && sep > 0 ? String(before).slice(0, sep) : null;
341 const bRef = before && sep > 0 ? String(before).slice(sep + 1) : null;
342 const rows = db.prepare(`
343 SELECT other, stamp, direction, ref FROM (${CONVERSATION_UNION})
344 WHERE other = @other
345 AND (@bStamp IS NULL OR stamp < @bStamp OR (stamp = @bStamp AND ref < @bRef))
346 ORDER BY stamp DESC, ref DESC LIMIT @n`).all({ slug, other, bStamp, bRef, n: n + 1 });
347 const more = rows.length > n;
348 const page = more ? rows.slice(0, n) : rows;
349 return { rows: page, more, oldest: cursorOf(page[page.length - 1]) };
350 } catch { return { rows: [], more: false, oldest: null }; }
351}
352
353// De kolommen die een bericht tot kaart maken. Een constante, want de
354// gesprekslezing haalt dezelfde rows op: twee lijsten die uiteenlopen leveren
355// een kaart die op de ene plek een plaatje heeft en op de andere niet.
356const MESSAGE_COLUMNS = `
357 m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,
358 m.content, m.published, m.created_at, m.wave, m.help_request, m.in_reply_to,
359 m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json`;
360
361/** Dezelfde berichtrijen, maar op object-uri -- voor een gesprek. */
362export function messageRowsByUri(slug, uris) {
363 const lijst = (uris || []).filter((u) => typeof u === 'string' && u);
364 if (!lijst.length) return [];
365 try {
366 const gaten = lijst.map(() => '?').join(',');
367 return db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM ap_mentions m
368 WHERE m.slug = ? AND m.object_uri IN (${gaten})`).all(slug, ...lijst);
369 } catch { return []; }
370}
371
372/**
373 * Tot waar deze lezer elk gesprek gelezen heeft (shaer-frontend-3tx).
374 *
375 * De markering komt uit AS2 `Read`-activiteiten, en die zijn OPTELLEND: het
376 * lezen van bericht N maakt niets anders ongelezen. Daarom is achteruit gaan
377 * geen regel die iemand moet onthouden maar een eigenschap van het model --
378 * markRead neemt het maximum. Een 'zet mijn markering op X' zou een toestel
379 * dat een week uit stond je gelezen berichten weer op ongelezen laten zetten.
380 */
381export function readMarkers(slug) {
382 try {
383 return new Map(db.prepare('SELECT other, cursor FROM ap_read_markers WHERE slug = ?')
384 .all(slug).map((r) => [r.other, r.cursor]));
385 } catch { return new Map(); }
386}
387
388/**
389 * Markeer een gesprek als gelezen tot en met dit bericht.
390 *
391 * Het object van de Read is een berichturi; welk gesprek dat is en waar het in
392 * de tijd staat weet de server zelf, dus de client hoeft niets uit te rekenen
393 * en kan er ook niet naast zitten.
394 */
395export function markRead(slug, objectUri) {
396 try {
397 const rij = db.prepare(`SELECT other, stamp, ref FROM (${CONVERSATION_UNION})
398 WHERE ref = @ref ORDER BY stamp DESC LIMIT 1`)
399 .get({ slug, ref: String(objectUri || '') });
400 if (!rij) return null;
401 const cursor = `${rij.stamp}|${rij.ref}`;
402 db.prepare(`INSERT INTO ap_read_markers (slug, other, cursor) VALUES (?,?,?)
403 ON CONFLICT(slug, other) DO UPDATE SET cursor = MAX(cursor, excluded.cursor), at = CURRENT_TIMESTAMP`)
404 .run(slug, rij.other, cursor);
405 return { other: rij.other, cursor };
406 } catch { return null; }
407}
408
409/**
410 * Hoeveel er per gesprek nog ongelezen is, en of daar een zwaai bij zit.
411 *
412 * Een COUNT en geen bijgehouden getal (Barts besluit): niets om op te hogen
413 * bij bezorging, niets om te verlagen bij lezen, en bij een verwijdering klopt
414 * het vanzelf weer.
415 *
416 * Een zwaai telt apart, want dat is geen gesprek maar een zetje van een
417 * guardian -- die hoort een eigen teken te krijgen en niet opgeteld te worden.
418 * Eigen berichten tellen nooit mee: je hebt jezelf gelezen.
419 */
420export function unreadPerConversation(slug, { messagesAllowed = true, guardians = new Set() } = {}) {
421 try {
422 // DE POORT TELT MEE. Staat messages dicht, dan toont de app die berichten
423 // niet -- en dan mag een badge ze ook niet aankondigen, want dat getal
424 // vertelt precies wat de poort verbergt. Wat er altijd door mag telt wel:
425 // het guardian-kanaal en de boei. Zelfde regel als bij de serialisatie.
426 const rijen = db.prepare(`
427 SELECT u.other AS other,
428 COUNT(*) AS n,
429 MAX(CASE WHEN m.wave = 1 THEN 1 ELSE 0 END) AS wave
430 FROM (${CONVERSATION_UNION}) u
431 LEFT JOIN ap_read_markers r ON r.slug = @slug AND r.other = u.other
432 LEFT JOIN ap_mentions m ON m.slug = @slug AND m.object_uri = u.ref
433 WHERE u.direction = 'in'
434 AND (r.cursor IS NULL OR (u.stamp || '|' || u.ref) > r.cursor)
435 AND (@open = 1 OR m.help_request = 1 OR u.other IN (SELECT value FROM json_each(@guardians)))
436 GROUP BY u.other`)
437 .all({ slug, open: messagesAllowed ? 1 : 0, guardians: JSON.stringify([...guardians]) });
438 return new Map(rijen.map((r) => [r.other, { n: r.n, wave: !!r.wave }]));
439 } catch { return new Map(); }
440}
441
442export function getDirectMessages(slug, limit) {
443 try {
444 return db.prepare(`
445 SELECT ${MESSAGE_COLUMNS}
446 FROM ap_mentions m
447 WHERE m.slug = ?
448 AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
449 ORDER BY ${STEMPEL('COALESCE(m.published, m.created_at)')} DESC LIMIT ?`).all(slug, limit || 60);
450 } catch { return []; }
451}
452
453/**
454 * A stored stamp as an ISO instant. SQLite's CURRENT_TIMESTAMP writes
455 * 'YYYY-MM-DD HH:MM:SS' in UTC, which Date.parse reads as LOCAL time; on a
456 * server two hours ahead that dated every message two hours early and put the
457 * conversation in the wrong order. A `published` from the wire is already ISO
458 * and passes through untouched.
459 */
460export function isoStamp(v) {
461 if (!v) return undefined;
462 const s = String(v);
463 if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s)) return `${s.replace(' ', 'T')}Z`;
464 const t = Date.parse(s);
465 return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
466}
467
468// Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
469// inbound Create) → AS2 `attachment` array, so a client (Shaer) can render a
470// friend's images/audio/video natively, exactly like own outbox posts. The
471// stored `type` is the mediaType and may be ''. Malformed JSON yields
472// undefined and never blocks the item.
473export function timelineAttachments(mediaJson) {
474 try {
475 const list = mediaJson ? JSON.parse(mediaJson) : [];
476 const rows = (Array.isArray(list) ? list : [])
477 .filter((m) => m && m.url)
478 .map((m) => {
479 const a = { type: 'Document', mediaType: m.type || undefined, url: m.url };
480 if (m.poster) a.icon = { type: 'Image', url: m.poster }; // the video's still (shaer-zowq)
481 return a;
482 });
483 return rows.length ? rows : undefined;
484 } catch { return undefined; }
485}
486
487// FEP-9098 custom emojis. Inbound: keep the note's Emoji tags (as JSON) so we
488// can serve them back. `extractEmojiTags` returns the JSON to store (or null);
489// `timelineEmojis` turns the stored JSON back into an AS2 `tag` array for the
490// C2S inbox read, so a client (Shaer) can render :shortcode: as an image.
491export function extractEmojiTags(tag) {
492 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
493 const emojis = arr.filter((t) => t && (Array.isArray(t.type) ? t.type[0] : t.type) === 'Emoji'
494 && typeof t.name === 'string' && t.icon);
495 return emojis.length ? JSON.stringify(emojis) : null;
496}
497// ── Gate-filters voor de C2S-serialisatie (shaer-ahy.1, 8-8) ──────
498//
499// Dezelfde regel als bij de embeds: de poort zit bij de AFLEVERING. Een
500// bijlage die de client alleen verbergt is wel degelijk geleverd, dus wat
501// dicht is wordt hier nooit geserialiseerd. Puur, zodat de regels los van de
502// routes te toetsen zijn.
503
504/** Bijlagen door de beeld- en muziekpoort. Leeg wordt undefined, zoals de
505 * serialisatie dat overal doet. */
506export function gateAttachments(atts, { images = true, audio = true, video = true } = {}) {
507 if (!Array.isArray(atts)) return atts;
508 const out = atts.filter((a) => {
509 const mt = String((a && a.mediaType) || '');
510 if (!images && mt.startsWith('image/')) return false;
511 if (!audio && (mt.startsWith('audio/') || (a && a.type === 'Audio'))) return false;
512 // Film hoorde hier ook thuis (shaer-mxh2) en stond er niet: met plaatjes en
513 // muziek dicht kwam een video er nog steeds gewoon doorheen.
514 if (!video && (mt.startsWith('video/') || (a && a.type === 'Video'))) return false;
515 return true;
516 });
517 return out.length ? out : undefined;
518}
519
520/** Tag-array zonder de FEP-9098 Emoji-tags, voor een dichte emoji-poort. De
521 * :shortcode: blijft als tekst staan -- dat is eerlijk: er STAAT iets, het
522 * wordt alleen niet als plaatje van een vreemde server gerenderd. */
523export function stripEmojiTags(tags) {
524 if (!Array.isArray(tags)) return tags;
525 const out = tags.filter((t) => (Array.isArray(t && t.type) ? t.type[0] : (t && t.type)) !== 'Emoji');
526 return out.length ? out : undefined;
527}
528
529export function timelineEmojis(emojiJson) {
530 try { const arr = emojiJson ? JSON.parse(emojiJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
531 catch { return undefined; }
532}
533
534// FEP-e232 object links (quotes / inline references). Inbound: keep the note's
535// Link tags whose mediaType marks an AP object (the AS2-profiled ld+json, or
536// activity+json as its equivalent) as JSON, so the C2S inbox read can serve
537// them back and a client (Shaer) can render the quote/reference. Mirrors
538// extractEmojiTags. Plain hyperlinks (text/html) and Mentions are dropped.
539export function extractObjectLinkTags(tag) {
540 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
541 const links = arr.filter((t) => {
542 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link') return false;
543 if (typeof t.href !== 'string' || !t.href) return false;
544 const mt = String(t.mediaType || '').toLowerCase();
545 return (mt.startsWith('application/ld+json') && mt.includes('activitystreams'))
546 || mt.startsWith('application/activity+json');
547 });
548 return links.length ? JSON.stringify(links) : null;
549}
550export function timelineObjectLinks(linkJson) {
551 try { const arr = linkJson ? JSON.parse(linkJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
552 catch { return undefined; }
553}
554
555// FEP-044f quote posts: a quote is usually NOT an FEP-e232 tag but an
556// object-level property. FEP-044f §"how to recognise" lists them all:
557// `quote` (the FEP property, a string or an embedded Link/object), and the
558// de-facto `quoteUrl` (as:), `quoteUri` (fedibird), `_misskey_quote` (misskey).
559// This returns the quoted object's URL from whichever is present.
560export function extractQuoteUrl(note) {
561 if (!note || typeof note !== 'object') return null;
562 const q = note.quote ?? note.quoteUrl ?? note.quoteUri ?? note['_misskey_quote'];
563 if (!q) return null;
564 if (typeof q === 'string') return q || null;
565 if (typeof q === 'object') return (typeof q.id === 'string' && q.id) || (typeof q.href === 'string' && q.href) || null;
566 return null;
567}
568
569// The note's object-link tags for storage: real FEP-e232 Link tags PLUS any
570// FEP-044f object-level quote, normalised to one FEP-e232-shaped Link (rel
571// _misskey_quote) so the client's single object-link path renders them all.
572// Deduped by href. Returns the JSON to store (or null if the note has neither).
573export function extractLinkJson(note) {
574 const links = [];
575 const fromTag = extractObjectLinkTags(note && note.tag);
576 if (fromTag) { try { links.push(...JSON.parse(fromTag)); } catch { /* ignore */ } }
577 const qUrl = extractQuoteUrl(note);
578 if (qUrl && !links.some((l) => l && l.href === qUrl)) {
579 links.push({ type: 'Link', mediaType: 'application/activity+json', href: qUrl,
580 rel: ['https://misskey-hub.net/ns#_misskey_quote'], name: qUrl });
581 }
582 return links.length ? JSON.stringify(links) : null;
583}
584
585// The URL of the quoted post, from either an object-level quote (FEP-044f) or a
586// quote-rel FEP-e232 Link tag. Used to resolve the embedded quote card.
587export function quoteHrefOf(note) {
588 const direct = extractQuoteUrl(note);
589 if (direct) return direct;
590 const arr = Array.isArray(note && note.tag) ? note.tag : (note && note.tag ? [note.tag] : []);
591 for (const t of arr) {
592 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link' || typeof t.href !== 'string') continue;
593 const rel = Array.isArray(t.rel) ? t.rel : (t.rel ? [t.rel] : []);
594 if (rel.some((r) => /quote/i.test(String(r)))) return t.href;
595 }
596 return null;
597}
598
599// Turn the stored quote snapshot back into the object the C2S inbox read serves
600// as `shaer:quote`, so the client can render the embedded quote card.
601export function timelineQuote(quoteJson) {
602 try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }
603 catch { return undefined; }
604}
Note: See TracBrowser for help on using the repository browser.