| 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 | */
|
|---|
| 15 | import 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.
|
|---|
| 20 | const 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.
|
|---|
| 24 | let getReactionsFor;
|
|---|
| 25 | export function wireTimeline(deps) {
|
|---|
| 26 | ({ getReactionsFor } = deps);
|
|---|
| 27 | }
|
|---|
| 28 |
|
|---|
| 29 | let _insTl, _listTl, _delTl;
|
|---|
| 30 | export 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 | */
|
|---|
| 51 | export 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").
|
|---|
| 82 | const 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. */
|
|---|
| 88 | export 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. */
|
|---|
| 102 | export 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 |
|
|---|
| 118 | export 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 | */
|
|---|
| 146 | export 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 | */
|
|---|
| 162 | export 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.
|
|---|
| 173 | const FEED_WAIT_MAX = 4;
|
|---|
| 174 | const _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 | */
|
|---|
| 185 | export 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 | const 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 | */
|
|---|
| 270 | export 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 | */
|
|---|
| 316 | const cursorOf = (r) => (r ? `${r.stamp}|${r.ref}` : null);
|
|---|
| 317 |
|
|---|
| 318 | export 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.
|
|---|
| 338 | const 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. */
|
|---|
| 344 | export 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 | */
|
|---|
| 363 | export 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 | */
|
|---|
| 377 | export 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 | */
|
|---|
| 402 | export 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 |
|
|---|
| 424 | export 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 | */
|
|---|
| 442 | export 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.
|
|---|
| 455 | export 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.
|
|---|
| 473 | export 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. */
|
|---|
| 488 | export 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. */
|
|---|
| 502 | export 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 |
|
|---|
| 508 | export 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.
|
|---|
| 518 | export 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 | }
|
|---|
| 529 | export 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.
|
|---|
| 539 | export 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).
|
|---|
| 552 | export 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.
|
|---|
| 566 | export 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.
|
|---|
| 580 | export function timelineQuote(quoteJson) {
|
|---|
| 581 | try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }
|
|---|
| 582 | catch { return undefined; }
|
|---|
| 583 | }
|
|---|