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

main
Last change on this file since 8151340 was 8151340, checked in by Robin <roboburr@…>, 5 days ago

Tijdstempels, leeskant: sorteren op tijd en niet op tekst (shaer-a937)

Deze database draagt twee spellingen door elkaar: '2026-08-06 09:02:01'
van CURRENT_TIMESTAMP en '2026-08-06T02:08:01.000Z' van toISOString().
SQLite vergelijkt tekst, en op positie 10 staat een T tegenover een
spatie -- dus binnen dezelfde dag wint de ISO-vorm altijd, hoe laat hij
ook is. Gemeten op dev: ap_timeline 80 ISO tegen 80 SQL, ap_mentions 26
tegen 38, ap_interactions 2 tegen 6. De menging is er dus echt.

De helper bestond al (STEMPEL in ap-timeline, gebouwd voor de
gesprekslezing) maar stond in een dienst, terwijl elke plek die sorteert
hem nodig heeft. Hij staat nu als isoSql in config/database.js -- daar
hoort hij, want het gaat over hoe deze OPSLAG met tijd omgaat, en elke
aanroeper importeert db toch al. STEMPEL is er nu een verwijzing naar,
zodat er niet twee kopieen van dezelfde regel bestaan.

Genormaliseerd waar de menging LEEFT: de tijdlijn zelf, de reacties
onder een post (de fout die de bead mat), de berichten en de Cirkel.
Plus de archief-export, die op dezelfde kolommen sorteert.

De terugval blijft de RAUWE waarde en niet leeg: isoSql levert ook de
cursor in de gesprekslezing, en een lege stempel zou een client zijn
plek kosten. Waar een onleesbare waarde dan landt is onbepaald, en dat
staat nu ook zo in het commentaar -- eerst schreef ik daar dat zoiets
onderaan hoort te eindigen, en dat deed de code niet.

Vier toetsen met gemengde data, ISO bewust VROEGER op de dag: precies de
stand waarin een ongenormaliseerde sortering omvalt. Tegenbewijs: maak
isoSql een doorgeefluik en alle vier vallen.

Volle suite 1248 groen.

  • Property mode set to 100644
File size: 26.9 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 } 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
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
250const CONVERSATION_UNION = `
251 SELECT m.actor_uri AS other, ${STEMPEL('COALESCE(m.published, m.created_at)')} AS stamp,
252 'in' AS direction, m.object_uri AS ref
253 FROM ap_mentions m
254 WHERE m.slug = @slug AND m.actor_uri IS NOT NULL AND m.actor_uri <> ''
255 UNION ALL
256 SELECT j.value AS other, ${STEMPEL('o.created_at')} AS stamp,
257 'out' AS direction, o.id AS ref
258 FROM ap_outbox o
259 JOIN json_each(COALESCE(NULLIF(o.to_actors, ''), json_array(o.to_actor))) j
260 WHERE o.site_slug = @slug AND o.visibility = 'direct'
261 AND j.value IS NOT NULL AND j.value <> ''`;
262
263/**
264 * Een rij per tegenpartij: zijn nieuwste bericht, nieuwste gesprek eerst.
265 *
266 * Compleet van vorm -- het aantal rijen is het aantal mensen, niet het aantal
267 * berichten -- dus de hemel kan niemand meer kwijtraken doordat een ander druk
268 * was. Zonder limiet, en dat mag: dit schaalt met je kring.
269 */
270export function conversationHeads(slug) {
271 try {
272 // Twee rijen per persoon, niet een: het nieuwste bericht (dat bepaalt waar
273 // iemand in de hemel hangt) EN het nieuwste bericht VAN HEM.
274 //
275 // Die tweede is er omdat het nieuwste bericht van jou kan zijn, en dan
276 // draagt het jouw byline. De hemel zoekt de naam en het gezicht van de
277 // ander in een bericht van de ander -- vond hij dat niet, dan viel hij
278 // terug op het staartje van de actor-uri en heette tante opeens
279 // 'hotelbreakfast'. Op het toestel gezien, 10-8.
280 //
281 // Valt het samen (het nieuwste is al van hem), dan is het een rij; dubbel
282 // sturen doen we niet.
283 return db.prepare(`
284 SELECT other, stamp, direction, ref FROM (
285 SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn
286 FROM (${CONVERSATION_UNION})
287 ) WHERE rn = 1
288 UNION
289 SELECT other, stamp, direction, ref FROM (
290 SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn
291 FROM (${CONVERSATION_UNION}) WHERE direction = 'in'
292 ) WHERE rn = 1
293 ORDER BY stamp DESC, ref DESC`).all({ slug });
294 } catch { return []; }
295}
296
297/**
298 * Een gesprek, nieuwste eerst, met een cursor.
299 *
300 * BEIDE KANTEN ONDER EEN LIMIET. In de oude lezing werden jouw kant
301 * (getSentNotes) en hun kant apart afgekapt, waardoor een gesprek eenzijdig
302 * kon lijken -- alsof iemand nooit geantwoord had. Hier is de limiet er een
303 * voor het gesprek als geheel.
304 *
305 * `before` is de cursor van het OUDSTE bericht dat je al hebt; je krijgt wat
306 * daarvoor ligt. Er komt er een extra op om te weten of er nog meer is: de
307 * client hoort dat te weten zonder te moeten gokken, en zonder dat weten kan
308 * 'load more' niet eerlijk verschijnen.
309 *
310 * DE CURSOR IS SAMENGESTELD -- '<stempel>|<ref>' -- en niet alleen de stempel.
311 * Twee berichten in dezelfde seconde is bij DM's geen randgeval maar een
312 * gesprek, en met 'stamp < before' zou alles wat die grensseconde deelt stil
313 * overgeslagen worden. Je zou het niet merken: de pagina komt gewoon, er
314 * ontbreekt alleen iets in het midden.
315 */
316const cursorOf = (r) => (r ? `${r.stamp}|${r.ref}` : null);
317
318export function conversationHistory(slug, other, { before = null, limit = 60 } = {}) {
319 try {
320 const n = Math.min(Math.max(parseInt(limit, 10) || 60, 1), 200);
321 const sep = String(before || '').indexOf('|');
322 const bStamp = before && sep > 0 ? String(before).slice(0, sep) : null;
323 const bRef = before && sep > 0 ? String(before).slice(sep + 1) : null;
324 const rows = db.prepare(`
325 SELECT other, stamp, direction, ref FROM (${CONVERSATION_UNION})
326 WHERE other = @other
327 AND (@bStamp IS NULL OR stamp < @bStamp OR (stamp = @bStamp AND ref < @bRef))
328 ORDER BY stamp DESC, ref DESC LIMIT @n`).all({ slug, other, bStamp, bRef, n: n + 1 });
329 const more = rows.length > n;
330 const page = more ? rows.slice(0, n) : rows;
331 return { rows: page, more, oldest: cursorOf(page[page.length - 1]) };
332 } catch { return { rows: [], more: false, oldest: null }; }
333}
334
335// De kolommen die een bericht tot kaart maken. Een constante, want de
336// gesprekslezing haalt dezelfde rows op: twee lijsten die uiteenlopen leveren
337// een kaart die op de ene plek een plaatje heeft en op de andere niet.
338const MESSAGE_COLUMNS = `
339 m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,
340 m.content, m.published, m.created_at, m.wave, m.help_request, m.in_reply_to,
341 m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json`;
342
343/** Dezelfde berichtrijen, maar op object-uri -- voor een gesprek. */
344export function messageRowsByUri(slug, uris) {
345 const lijst = (uris || []).filter((u) => typeof u === 'string' && u);
346 if (!lijst.length) return [];
347 try {
348 const gaten = lijst.map(() => '?').join(',');
349 return db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM ap_mentions m
350 WHERE m.slug = ? AND m.object_uri IN (${gaten})`).all(slug, ...lijst);
351 } catch { return []; }
352}
353
354/**
355 * Tot waar deze lezer elk gesprek gelezen heeft (shaer-frontend-3tx).
356 *
357 * De markering komt uit AS2 `Read`-activiteiten, en die zijn OPTELLEND: het
358 * lezen van bericht N maakt niets anders ongelezen. Daarom is achteruit gaan
359 * geen regel die iemand moet onthouden maar een eigenschap van het model --
360 * markRead neemt het maximum. Een 'zet mijn markering op X' zou een toestel
361 * dat een week uit stond je gelezen berichten weer op ongelezen laten zetten.
362 */
363export function readMarkers(slug) {
364 try {
365 return new Map(db.prepare('SELECT other, cursor FROM ap_read_markers WHERE slug = ?')
366 .all(slug).map((r) => [r.other, r.cursor]));
367 } catch { return new Map(); }
368}
369
370/**
371 * Markeer een gesprek als gelezen tot en met dit bericht.
372 *
373 * Het object van de Read is een berichturi; welk gesprek dat is en waar het in
374 * de tijd staat weet de server zelf, dus de client hoeft niets uit te rekenen
375 * en kan er ook niet naast zitten.
376 */
377export function markRead(slug, objectUri) {
378 try {
379 const rij = db.prepare(`SELECT other, stamp, ref FROM (${CONVERSATION_UNION})
380 WHERE ref = @ref ORDER BY stamp DESC LIMIT 1`)
381 .get({ slug, ref: String(objectUri || '') });
382 if (!rij) return null;
383 const cursor = `${rij.stamp}|${rij.ref}`;
384 db.prepare(`INSERT INTO ap_read_markers (slug, other, cursor) VALUES (?,?,?)
385 ON CONFLICT(slug, other) DO UPDATE SET cursor = MAX(cursor, excluded.cursor), at = CURRENT_TIMESTAMP`)
386 .run(slug, rij.other, cursor);
387 return { other: rij.other, cursor };
388 } catch { return null; }
389}
390
391/**
392 * Hoeveel er per gesprek nog ongelezen is, en of daar een zwaai bij zit.
393 *
394 * Een COUNT en geen bijgehouden getal (Barts besluit): niets om op te hogen
395 * bij bezorging, niets om te verlagen bij lezen, en bij een verwijdering klopt
396 * het vanzelf weer.
397 *
398 * Een zwaai telt apart, want dat is geen gesprek maar een zetje van een
399 * guardian -- die hoort een eigen teken te krijgen en niet opgeteld te worden.
400 * Eigen berichten tellen nooit mee: je hebt jezelf gelezen.
401 */
402export function unreadPerConversation(slug, { messagesAllowed = true, guardians = new Set() } = {}) {
403 try {
404 // DE POORT TELT MEE. Staat messages dicht, dan toont de app die berichten
405 // niet -- en dan mag een badge ze ook niet aankondigen, want dat getal
406 // vertelt precies wat de poort verbergt. Wat er altijd door mag telt wel:
407 // het guardian-kanaal en de boei. Zelfde regel als bij de serialisatie.
408 const rijen = db.prepare(`
409 SELECT u.other AS other,
410 COUNT(*) AS n,
411 MAX(CASE WHEN m.wave = 1 THEN 1 ELSE 0 END) AS wave
412 FROM (${CONVERSATION_UNION}) u
413 LEFT JOIN ap_read_markers r ON r.slug = @slug AND r.other = u.other
414 LEFT JOIN ap_mentions m ON m.slug = @slug AND m.object_uri = u.ref
415 WHERE u.direction = 'in'
416 AND (r.cursor IS NULL OR (u.stamp || '|' || u.ref) > r.cursor)
417 AND (@open = 1 OR m.help_request = 1 OR u.other IN (SELECT value FROM json_each(@guardians)))
418 GROUP BY u.other`)
419 .all({ slug, open: messagesAllowed ? 1 : 0, guardians: JSON.stringify([...guardians]) });
420 return new Map(rijen.map((r) => [r.other, { n: r.n, wave: !!r.wave }]));
421 } catch { return new Map(); }
422}
423
424export function getDirectMessages(slug, limit) {
425 try {
426 return db.prepare(`
427 SELECT ${MESSAGE_COLUMNS}
428 FROM ap_mentions m
429 WHERE m.slug = ?
430 AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
431 ORDER BY ${STEMPEL('COALESCE(m.published, m.created_at)')} DESC LIMIT ?`).all(slug, limit || 60);
432 } catch { return []; }
433}
434
435/**
436 * A stored stamp as an ISO instant. SQLite's CURRENT_TIMESTAMP writes
437 * 'YYYY-MM-DD HH:MM:SS' in UTC, which Date.parse reads as LOCAL time; on a
438 * server two hours ahead that dated every message two hours early and put the
439 * conversation in the wrong order. A `published` from the wire is already ISO
440 * and passes through untouched.
441 */
442export function isoStamp(v) {
443 if (!v) return undefined;
444 const s = String(v);
445 if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s)) return `${s.replace(' ', 'T')}Z`;
446 const t = Date.parse(s);
447 return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
448}
449
450// Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
451// inbound Create) → AS2 `attachment` array, so a client (Shaer) can render a
452// friend's images/audio/video natively, exactly like own outbox posts. The
453// stored `type` is the mediaType and may be ''. Malformed JSON yields
454// undefined and never blocks the item.
455export function timelineAttachments(mediaJson) {
456 try {
457 const list = mediaJson ? JSON.parse(mediaJson) : [];
458 const rows = (Array.isArray(list) ? list : [])
459 .filter((m) => m && m.url)
460 .map((m) => {
461 const a = { type: 'Document', mediaType: m.type || undefined, url: m.url };
462 if (m.poster) a.icon = { type: 'Image', url: m.poster }; // the video's still (shaer-zowq)
463 return a;
464 });
465 return rows.length ? rows : undefined;
466 } catch { return undefined; }
467}
468
469// FEP-9098 custom emojis. Inbound: keep the note's Emoji tags (as JSON) so we
470// can serve them back. `extractEmojiTags` returns the JSON to store (or null);
471// `timelineEmojis` turns the stored JSON back into an AS2 `tag` array for the
472// C2S inbox read, so a client (Shaer) can render :shortcode: as an image.
473export function extractEmojiTags(tag) {
474 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
475 const emojis = arr.filter((t) => t && (Array.isArray(t.type) ? t.type[0] : t.type) === 'Emoji'
476 && typeof t.name === 'string' && t.icon);
477 return emojis.length ? JSON.stringify(emojis) : null;
478}
479// ── Gate-filters voor de C2S-serialisatie (shaer-ahy.1, 8-8) ──────
480//
481// Dezelfde regel als bij de embeds: de poort zit bij de AFLEVERING. Een
482// bijlage die de client alleen verbergt is wel degelijk geleverd, dus wat
483// dicht is wordt hier nooit geserialiseerd. Puur, zodat de regels los van de
484// routes te toetsen zijn.
485
486/** Bijlagen door de beeld- en muziekpoort. Leeg wordt undefined, zoals de
487 * serialisatie dat overal doet. */
488export function gateAttachments(atts, { images = true, audio = true } = {}) {
489 if (!Array.isArray(atts)) return atts;
490 const out = atts.filter((a) => {
491 const mt = String((a && a.mediaType) || '');
492 if (!images && mt.startsWith('image/')) return false;
493 if (!audio && (mt.startsWith('audio/') || (a && a.type === 'Audio'))) return false;
494 return true;
495 });
496 return out.length ? out : undefined;
497}
498
499/** Tag-array zonder de FEP-9098 Emoji-tags, voor een dichte emoji-poort. De
500 * :shortcode: blijft als tekst staan -- dat is eerlijk: er STAAT iets, het
501 * wordt alleen niet als plaatje van een vreemde server gerenderd. */
502export function stripEmojiTags(tags) {
503 if (!Array.isArray(tags)) return tags;
504 const out = tags.filter((t) => (Array.isArray(t && t.type) ? t.type[0] : (t && t.type)) !== 'Emoji');
505 return out.length ? out : undefined;
506}
507
508export function timelineEmojis(emojiJson) {
509 try { const arr = emojiJson ? JSON.parse(emojiJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
510 catch { return undefined; }
511}
512
513// FEP-e232 object links (quotes / inline references). Inbound: keep the note's
514// Link tags whose mediaType marks an AP object (the AS2-profiled ld+json, or
515// activity+json as its equivalent) as JSON, so the C2S inbox read can serve
516// them back and a client (Shaer) can render the quote/reference. Mirrors
517// extractEmojiTags. Plain hyperlinks (text/html) and Mentions are dropped.
518export function extractObjectLinkTags(tag) {
519 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
520 const links = arr.filter((t) => {
521 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link') return false;
522 if (typeof t.href !== 'string' || !t.href) return false;
523 const mt = String(t.mediaType || '').toLowerCase();
524 return (mt.startsWith('application/ld+json') && mt.includes('activitystreams'))
525 || mt.startsWith('application/activity+json');
526 });
527 return links.length ? JSON.stringify(links) : null;
528}
529export function timelineObjectLinks(linkJson) {
530 try { const arr = linkJson ? JSON.parse(linkJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
531 catch { return undefined; }
532}
533
534// FEP-044f quote posts: a quote is usually NOT an FEP-e232 tag but an
535// object-level property. FEP-044f §"how to recognise" lists them all:
536// `quote` (the FEP property, a string or an embedded Link/object), and the
537// de-facto `quoteUrl` (as:), `quoteUri` (fedibird), `_misskey_quote` (misskey).
538// This returns the quoted object's URL from whichever is present.
539export function extractQuoteUrl(note) {
540 if (!note || typeof note !== 'object') return null;
541 const q = note.quote ?? note.quoteUrl ?? note.quoteUri ?? note['_misskey_quote'];
542 if (!q) return null;
543 if (typeof q === 'string') return q || null;
544 if (typeof q === 'object') return (typeof q.id === 'string' && q.id) || (typeof q.href === 'string' && q.href) || null;
545 return null;
546}
547
548// The note's object-link tags for storage: real FEP-e232 Link tags PLUS any
549// FEP-044f object-level quote, normalised to one FEP-e232-shaped Link (rel
550// _misskey_quote) so the client's single object-link path renders them all.
551// Deduped by href. Returns the JSON to store (or null if the note has neither).
552export function extractLinkJson(note) {
553 const links = [];
554 const fromTag = extractObjectLinkTags(note && note.tag);
555 if (fromTag) { try { links.push(...JSON.parse(fromTag)); } catch { /* ignore */ } }
556 const qUrl = extractQuoteUrl(note);
557 if (qUrl && !links.some((l) => l && l.href === qUrl)) {
558 links.push({ type: 'Link', mediaType: 'application/activity+json', href: qUrl,
559 rel: ['https://misskey-hub.net/ns#_misskey_quote'], name: qUrl });
560 }
561 return links.length ? JSON.stringify(links) : null;
562}
563
564// The URL of the quoted post, from either an object-level quote (FEP-044f) or a
565// quote-rel FEP-e232 Link tag. Used to resolve the embedded quote card.
566export function quoteHrefOf(note) {
567 const direct = extractQuoteUrl(note);
568 if (direct) return direct;
569 const arr = Array.isArray(note && note.tag) ? note.tag : (note && note.tag ? [note.tag] : []);
570 for (const t of arr) {
571 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link' || typeof t.href !== 'string') continue;
572 const rel = Array.isArray(t.rel) ? t.rel : (t.rel ? [t.rel] : []);
573 if (rel.some((r) => /quote/i.test(String(r)))) return t.href;
574 }
575 return null;
576}
577
578// Turn the stored quote snapshot back into the object the C2S inbox read serves
579// as `shaer:quote`, so the client can render the embedded quote card.
580export function timelineQuote(quoteJson) {
581 try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }
582 catch { return undefined; }
583}
Note: See TracBrowser for help on using the repository browser.