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

main
Last change on this file since 76290bf was 76290bf, checked in by Robin <roboburr@…>, 2 weeks ago

Inbox: een binnengekomen antwoord houdt zijn inReplyTo

Robins melding, 26-8: de C2S-lezing serveerde antwoorden zonder ouder.

De oorzaak lag een laag dieper dan de serialisatie: ap_mentions had geen
kolom voor inReplyTo, dus de ouder viel al bij het OPSLAAN op de grond en
messageItem had niets te serveren. Een client kan een gesprek alleen
teruglopen langs inReplyTo, dus elk antwoord kwam aan als het begin van
een gesprek -- de ketenlezing in de app kon nooit verder dan een.

Vier plekken, want het is de hele weg: de kolom (met ensureColumn voor
bestaande databases), het schrijven in de inbox, MESSAGE_COLUMNS -- de
ene leesplek die zowel de inbox-lijst als de gesprekken voedt -- en
messageItem.

De ouder gaat door dezelfde poort als de note-url: alleen http(s), en zowel de
string- als de objectvorm die AS2 toestaat. Alleen de string erkennen
zou hetzelfde gat laten voor wie de objectvorm stuurt.

Nagegaan wat WEL goed ging, zodat de reparatie niet breder wordt dan de
kwaal: replyItem droeg hem al uit ap_interactions.parent_uri, sentItem
via buildNote uit ap_outbox.in_reply_to, en de tijdlijn kan hem per
definitie niet missen -- belongsInTimeline weigert alles met inReplyTo.
Deze leg was de enige.

Bestaande rijen blijven leeg: die ouder is niet meer te achterhalen
zonder hem opnieuw op te halen, en een verzonnen ouder is erger dan
geen. Twee toetsen over de hele keten, tegenbewijs gedraaid: allebei
vallen ze tegen de code van hiervoor. Volle suite 1231 groen.

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