source: Klonkt/src/services/ap-timeline.js@ 3155d8c

main
Last change on this file since 3155d8c was 3155d8c, checked in by Bart <bart@…>, 106 minutes ago

Inbox: ?random=1, een greep uit je geschiedenis (shaer-ewrt)

Een pagina vanaf een willekeurige post, voor 'Load random' met inbox-zero
aan. Het id is zijn echte ?before=-adres en next loopt van daar door.

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

  • Property mode set to 100644
File size: 31.8 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/**
354 * Een pagina van de inbox, nieuwste eerst, met een cursor (shaer-ewrt).
355 *
356 * De inbox-lezing is vier poten van elk de nieuwste zestig, en daaronder hield
357 * het op: wat ouder was kon de app nergens vragen. Dit is de weg naar beneden.
358 *
359 * EEN LIMIET VOOR ALLE VIER SAMEN, om dezelfde reden als bij een gesprek: per
360 * poot afkappen geeft een pagina die bij de ene poot tot vorige week reikt en
361 * bij de andere tot gisteren, en dan valt er bij de volgende pagina iets in het
362 * gat ertussen. Over de unie afgekapt is een pagina compleet voor zijn periode.
363 *
364 * Alleen referenties: welke poot, welk object, welk stempel. Het kaartwerk doet
365 * de route met dezelfde bouwers als de verschil-lezing, zodat een kaart er
366 * hetzelfde uitziet hoe hij ook binnenkwam.
367 *
368 * De poten zijn dezelfde als in de volle lezing: de berichtpoot slaat over wat
369 * ook in de tijdlijn staat (getDirectMessages), en de stempels zijn die waarop
370 * elke poot daar sorteert.
371 */
372const INBOX_UNION = `
373 SELECT 'timeline' AS leg, t.id AS ref, ${STEMPEL('COALESCE(t.published, t.created_at)')} AS stamp
374 FROM ap_timeline t WHERE t.slug = @slug
375 UNION ALL
376 SELECT 'message', m.object_uri, ${STEMPEL('COALESCE(m.published, m.created_at)')}
377 FROM ap_mentions m
378 WHERE m.slug = @slug
379 AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
380 UNION ALL
381 SELECT 'reply', i.object_uri, ${STEMPEL('COALESCE(i.published, i.created_at)')}
382 FROM ap_interactions i JOIN posts p ON p.id = i.post_id JOIN sites s ON s.id = p.site_id
383 WHERE s.slug = @slug AND i.kind = 'reply'
384 UNION ALL
385 SELECT 'sent', o.id, ${STEMPEL('o.created_at')}
386 FROM ap_outbox o WHERE o.site_slug = @slug`;
387
388export function inboxPage(slug, { before = null, limit = 60 } = {}) {
389 try {
390 const n = Math.min(Math.max(parseInt(limit, 10) || 60, 1), 200);
391 const sep = String(before || '').indexOf('|');
392 const bStamp = before && sep > 0 ? String(before).slice(0, sep) : null;
393 const bRef = before && sep > 0 ? String(before).slice(sep + 1) : null;
394 const rows = db.prepare(`
395 SELECT leg, ref, stamp FROM (${INBOX_UNION})
396 WHERE (@bStamp IS NULL OR stamp < @bStamp OR (stamp = @bStamp AND ref < @bRef))
397 ORDER BY stamp DESC, ref DESC LIMIT @n`).all({ slug, bStamp, bRef, n: n + 1 });
398 const more = rows.length > n;
399 const page = more ? rows.slice(0, n) : rows;
400 return { rows: page, more, oldest: cursorOf(page[page.length - 1]) };
401 } catch { return { rows: [], more: false, oldest: null }; }
402}
403
404/**
405 * Een pagina vanaf een willekeurige post in je geschiedenis (shaer-ewrt).
406 *
407 * Het anker is een TIJDLIJNRIJ, geen willekeurige rij uit de unie: de app
408 * toont posts in het raster, en een greep die op een stapel berichten landt
409 * laat niets zien. Vanaf daar is het een gewone pagina over alle poten, dus
410 * `start` is een echte cursor en `next` loopt gewoon door.
411 *
412 * `rnd` is er voor de toetsen.
413 */
414export function inboxRandomPage(slug, { limit = 60, rnd = Math.random } = {}) {
415 try {
416 const { n } = db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE slug = ?').get(slug);
417 const k = n ? Math.min(Math.floor(rnd() * n), n - 1) : 0;
418 if (k === 0) return { ...inboxPage(slug, { limit }), start: null };
419 // De rij er net boven: de pagina begint direct daaronder, met het anker
420 // als eerste post. Zelfde stempel en volgorde als de unie.
421 const boven = db.prepare(`
422 SELECT id AS ref, ${STEMPEL('COALESCE(published, created_at)')} AS stamp
423 FROM ap_timeline WHERE slug = ?
424 ORDER BY stamp DESC, ref DESC LIMIT 1 OFFSET ?`).get(slug, k - 1);
425 const start = cursorOf(boven);
426 return { ...inboxPage(slug, { before: start, limit }), start };
427 } catch { return { rows: [], more: false, oldest: null, start: null }; }
428}
429
430// De kolommen die een bericht tot kaart maken. Een constante, want de
431// gesprekslezing haalt dezelfde rows op: twee lijsten die uiteenlopen leveren
432// een kaart die op de ene plek een plaatje heeft en op de andere niet.
433const MESSAGE_COLUMNS = `
434 m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,
435 m.content, m.published, m.created_at, m.wave, m.help_request, m.in_reply_to,
436 m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json`;
437
438/** Dezelfde berichtrijen, maar op object-uri -- voor een gesprek. */
439export function messageRowsByUri(slug, uris) {
440 const lijst = (uris || []).filter((u) => typeof u === 'string' && u);
441 if (!lijst.length) return [];
442 try {
443 const gaten = lijst.map(() => '?').join(',');
444 return db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM ap_mentions m
445 WHERE m.slug = ? AND m.object_uri IN (${gaten})`).all(slug, ...lijst);
446 } catch { return []; }
447}
448
449/**
450 * Tot waar deze lezer elk gesprek gelezen heeft (shaer-frontend-3tx).
451 *
452 * De markering komt uit AS2 `Read`-activiteiten, en die zijn OPTELLEND: het
453 * lezen van bericht N maakt niets anders ongelezen. Daarom is achteruit gaan
454 * geen regel die iemand moet onthouden maar een eigenschap van het model --
455 * markRead neemt het maximum. Een 'zet mijn markering op X' zou een toestel
456 * dat een week uit stond je gelezen berichten weer op ongelezen laten zetten.
457 */
458export function readMarkers(slug) {
459 try {
460 return new Map(db.prepare('SELECT other, cursor FROM ap_read_markers WHERE slug = ?')
461 .all(slug).map((r) => [r.other, r.cursor]));
462 } catch { return new Map(); }
463}
464
465/**
466 * Markeer een gesprek als gelezen tot en met dit bericht.
467 *
468 * Het object van de Read is een berichturi; welk gesprek dat is en waar het in
469 * de tijd staat weet de server zelf, dus de client hoeft niets uit te rekenen
470 * en kan er ook niet naast zitten.
471 */
472export function markRead(slug, objectUri) {
473 try {
474 const rij = db.prepare(`SELECT other, stamp, ref FROM (${CONVERSATION_UNION})
475 WHERE ref = @ref ORDER BY stamp DESC LIMIT 1`)
476 .get({ slug, ref: String(objectUri || '') });
477 if (!rij) return null;
478 const cursor = `${rij.stamp}|${rij.ref}`;
479 db.prepare(`INSERT INTO ap_read_markers (slug, other, cursor) VALUES (?,?,?)
480 ON CONFLICT(slug, other) DO UPDATE SET cursor = MAX(cursor, excluded.cursor), at = CURRENT_TIMESTAMP`)
481 .run(slug, rij.other, cursor);
482 return { other: rij.other, cursor };
483 } catch { return null; }
484}
485
486/**
487 * Hoeveel er per gesprek nog ongelezen is, en of daar een zwaai bij zit.
488 *
489 * Een COUNT en geen bijgehouden getal (Barts besluit): niets om op te hogen
490 * bij bezorging, niets om te verlagen bij lezen, en bij een verwijdering klopt
491 * het vanzelf weer.
492 *
493 * Een zwaai telt apart, want dat is geen gesprek maar een zetje van een
494 * guardian -- die hoort een eigen teken te krijgen en niet opgeteld te worden.
495 * Eigen berichten tellen nooit mee: je hebt jezelf gelezen.
496 */
497export function unreadPerConversation(slug, { messagesAllowed = true, guardians = new Set() } = {}) {
498 try {
499 // DE POORT TELT MEE. Staat messages dicht, dan toont de app die berichten
500 // niet -- en dan mag een badge ze ook niet aankondigen, want dat getal
501 // vertelt precies wat de poort verbergt. Wat er altijd door mag telt wel:
502 // het guardian-kanaal en de boei. Zelfde regel als bij de serialisatie.
503 const rijen = db.prepare(`
504 SELECT u.other AS other,
505 COUNT(*) AS n,
506 MAX(CASE WHEN m.wave = 1 THEN 1 ELSE 0 END) AS wave
507 FROM (${CONVERSATION_UNION}) u
508 LEFT JOIN ap_read_markers r ON r.slug = @slug AND r.other = u.other
509 LEFT JOIN ap_mentions m ON m.slug = @slug AND m.object_uri = u.ref
510 WHERE u.direction = 'in'
511 AND (r.cursor IS NULL OR (u.stamp || '|' || u.ref) > r.cursor)
512 AND (@open = 1 OR m.help_request = 1 OR u.other IN (SELECT value FROM json_each(@guardians)))
513 GROUP BY u.other`)
514 .all({ slug, open: messagesAllowed ? 1 : 0, guardians: JSON.stringify([...guardians]) });
515 return new Map(rijen.map((r) => [r.other, { n: r.n, wave: !!r.wave }]));
516 } catch { return new Map(); }
517}
518
519export function getDirectMessages(slug, limit) {
520 try {
521 return db.prepare(`
522 SELECT ${MESSAGE_COLUMNS}
523 FROM ap_mentions m
524 WHERE m.slug = ?
525 AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
526 ORDER BY ${STEMPEL('COALESCE(m.published, m.created_at)')} DESC LIMIT ?`).all(slug, limit || 60);
527 } catch { return []; }
528}
529
530/**
531 * A stored stamp as an ISO instant. SQLite's CURRENT_TIMESTAMP writes
532 * 'YYYY-MM-DD HH:MM:SS' in UTC, which Date.parse reads as LOCAL time; on a
533 * server two hours ahead that dated every message two hours early and put the
534 * conversation in the wrong order. A `published` from the wire is already ISO
535 * and passes through untouched.
536 */
537export function isoStamp(v) {
538 if (!v) return undefined;
539 const s = String(v);
540 if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s)) return `${s.replace(' ', 'T')}Z`;
541 const t = Date.parse(s);
542 return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
543}
544
545// Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
546// inbound Create) → AS2 `attachment` array, so a client (Shaer) can render a
547// friend's images/audio/video natively, exactly like own outbox posts. The
548// stored `type` is the mediaType and may be ''. Malformed JSON yields
549// undefined and never blocks the item.
550export function timelineAttachments(mediaJson) {
551 try {
552 const list = mediaJson ? JSON.parse(mediaJson) : [];
553 const rows = (Array.isArray(list) ? list : [])
554 .filter((m) => m && m.url)
555 .map((m) => {
556 const a = { type: 'Document', mediaType: m.type || undefined, url: m.url };
557 if (m.poster) a.icon = { type: 'Image', url: m.poster }; // the video's still (shaer-zowq)
558 return a;
559 });
560 return rows.length ? rows : undefined;
561 } catch { return undefined; }
562}
563
564// FEP-9098 custom emojis. Inbound: keep the note's Emoji tags (as JSON) so we
565// can serve them back. `extractEmojiTags` returns the JSON to store (or null);
566// `timelineEmojis` turns the stored JSON back into an AS2 `tag` array for the
567// C2S inbox read, so a client (Shaer) can render :shortcode: as an image.
568export function extractEmojiTags(tag) {
569 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
570 const emojis = arr.filter((t) => t && (Array.isArray(t.type) ? t.type[0] : t.type) === 'Emoji'
571 && typeof t.name === 'string' && t.icon);
572 return emojis.length ? JSON.stringify(emojis) : null;
573}
574// ── Gate-filters voor de C2S-serialisatie (shaer-ahy.1, 8-8) ──────
575//
576// Dezelfde regel als bij de embeds: de poort zit bij de AFLEVERING. Een
577// bijlage die de client alleen verbergt is wel degelijk geleverd, dus wat
578// dicht is wordt hier nooit geserialiseerd. Puur, zodat de regels los van de
579// routes te toetsen zijn.
580
581/** Bijlagen door de beeld- en muziekpoort. Leeg wordt undefined, zoals de
582 * serialisatie dat overal doet. */
583export function gateAttachments(atts, { images = true, audio = true, video = true } = {}) {
584 if (!Array.isArray(atts)) return atts;
585 const out = atts.filter((a) => {
586 const mt = String((a && a.mediaType) || '');
587 if (!images && mt.startsWith('image/')) return false;
588 if (!audio && (mt.startsWith('audio/') || (a && a.type === 'Audio'))) return false;
589 // Film hoorde hier ook thuis (shaer-mxh2) en stond er niet: met plaatjes en
590 // muziek dicht kwam een video er nog steeds gewoon doorheen.
591 if (!video && (mt.startsWith('video/') || (a && a.type === 'Video'))) return false;
592 return true;
593 });
594 return out.length ? out : undefined;
595}
596
597/** Tag-array zonder de FEP-9098 Emoji-tags, voor een dichte emoji-poort. De
598 * :shortcode: blijft als tekst staan -- dat is eerlijk: er STAAT iets, het
599 * wordt alleen niet als plaatje van een vreemde server gerenderd. */
600export function stripEmojiTags(tags) {
601 if (!Array.isArray(tags)) return tags;
602 const out = tags.filter((t) => (Array.isArray(t && t.type) ? t.type[0] : (t && t.type)) !== 'Emoji');
603 return out.length ? out : undefined;
604}
605
606export function timelineEmojis(emojiJson) {
607 try { const arr = emojiJson ? JSON.parse(emojiJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
608 catch { return undefined; }
609}
610
611// FEP-e232 object links (quotes / inline references). Inbound: keep the note's
612// Link tags whose mediaType marks an AP object (the AS2-profiled ld+json, or
613// activity+json as its equivalent) as JSON, so the C2S inbox read can serve
614// them back and a client (Shaer) can render the quote/reference. Mirrors
615// extractEmojiTags. Plain hyperlinks (text/html) and Mentions are dropped.
616export function extractObjectLinkTags(tag) {
617 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
618 const links = arr.filter((t) => {
619 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link') return false;
620 if (typeof t.href !== 'string' || !t.href) return false;
621 const mt = String(t.mediaType || '').toLowerCase();
622 return (mt.startsWith('application/ld+json') && mt.includes('activitystreams'))
623 || mt.startsWith('application/activity+json');
624 });
625 return links.length ? JSON.stringify(links) : null;
626}
627export function timelineObjectLinks(linkJson) {
628 try { const arr = linkJson ? JSON.parse(linkJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
629 catch { return undefined; }
630}
631
632// FEP-044f quote posts: a quote is usually NOT an FEP-e232 tag but an
633// object-level property. FEP-044f §"how to recognise" lists them all:
634// `quote` (the FEP property, a string or an embedded Link/object), and the
635// de-facto `quoteUrl` (as:), `quoteUri` (fedibird), `_misskey_quote` (misskey).
636// This returns the quoted object's URL from whichever is present.
637export function extractQuoteUrl(note) {
638 if (!note || typeof note !== 'object') return null;
639 const q = note.quote ?? note.quoteUrl ?? note.quoteUri ?? note['_misskey_quote'];
640 if (!q) return null;
641 if (typeof q === 'string') return q || null;
642 if (typeof q === 'object') return (typeof q.id === 'string' && q.id) || (typeof q.href === 'string' && q.href) || null;
643 return null;
644}
645
646// The note's object-link tags for storage: real FEP-e232 Link tags PLUS any
647// FEP-044f object-level quote, normalised to one FEP-e232-shaped Link (rel
648// _misskey_quote) so the client's single object-link path renders them all.
649// Deduped by href. Returns the JSON to store (or null if the note has neither).
650export function extractLinkJson(note) {
651 const links = [];
652 const fromTag = extractObjectLinkTags(note && note.tag);
653 if (fromTag) { try { links.push(...JSON.parse(fromTag)); } catch { /* ignore */ } }
654 const qUrl = extractQuoteUrl(note);
655 if (qUrl && !links.some((l) => l && l.href === qUrl)) {
656 links.push({ type: 'Link', mediaType: 'application/activity+json', href: qUrl,
657 rel: ['https://misskey-hub.net/ns#_misskey_quote'], name: qUrl });
658 }
659 return links.length ? JSON.stringify(links) : null;
660}
661
662// The URL of the quoted post, from either an object-level quote (FEP-044f) or a
663// quote-rel FEP-e232 Link tag. Used to resolve the embedded quote card.
664export function quoteHrefOf(note) {
665 const direct = extractQuoteUrl(note);
666 if (direct) return direct;
667 const arr = Array.isArray(note && note.tag) ? note.tag : (note && note.tag ? [note.tag] : []);
668 for (const t of arr) {
669 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link' || typeof t.href !== 'string') continue;
670 const rel = Array.isArray(t.rel) ? t.rel : (t.rel ? [t.rel] : []);
671 if (rel.some((r) => /quote/i.test(String(r)))) return t.href;
672 }
673 return null;
674}
675
676// Turn the stored quote snapshot back into the object the C2S inbox read serves
677// as `shaer:quote`, so the client can render the embedded quote card.
678export function timelineQuote(quoteJson) {
679 try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }
680 catch { return undefined; }
681}
Note: See TracBrowser for help on using the repository browser.