Changeset b05eb97 in Klonkt for src/services
- Timestamp:
- 08/24/2026 04:11:35 PM (2 weeks ago)
- Branches:
- main
- Children:
- 911d5cd
- Parents:
- 1d5ffc0
- Location:
- src/services
- Files:
-
- 1 added
- 1 edited
-
ActivityPubService.js (modified) (3 diffs)
-
ap-timeline.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r1d5ffc0 rb05eb97 47 47 import { ingestOutboxActivity, wireC2S } from './ap-c2s.js'; 48 48 export { ingestOutboxActivity }; 49 // Stap 5 (shaer-drc): de leeskant van de tijdlijn woont in ap-timeline.js. 50 // tlStmts komt mee terug omdat de SCHRIJVERS (inbox, backfill, self-heal, 51 // upsertBoostedNote) hier wonen; wireTimeline krijgt onderaan zijn ene 52 // werktuig uit het reactiecluster. 53 import { 54 tlStmts, wireTimeline, 55 getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages, 56 feedCursor, feedChangesSince, waitForFeedChange, 57 conversationHeads, conversationHistory, messageRowsByUri, 58 readMarkers, markRead, unreadPerConversation, getDirectMessages, 59 isoStamp, timelineAttachments, extractEmojiTags, gateAttachments, 60 stripEmojiTags, timelineEmojis, extractObjectLinkTags, timelineObjectLinks, 61 extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote, 62 } from './ap-timeline.js'; 63 export { 64 getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages, 65 feedCursor, feedChangesSince, waitForFeedChange, 66 conversationHeads, conversationHistory, messageRowsByUri, 67 readMarkers, markRead, unreadPerConversation, getDirectMessages, 68 isoStamp, timelineAttachments, extractEmojiTags, gateAttachments, 69 stripEmojiTags, timelineEmojis, extractObjectLinkTags, timelineObjectLinks, 70 extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote, 71 }; 49 72 // Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken. 50 73 export { AP_CONTEXT, actorId, noteId, guessMediaType }; … … 3765 3788 } 3766 3789 3767 let _insTl, _listTl, _delTl;3768 function tlStmts() {3769 if (!_insTl) {3770 _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)');3771 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ? OFFSET ?');3772 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');3773 }3774 return { ins: _insTl, list: _listTl, del: _delTl };3775 }3776 /**3777 * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).3778 *3779 * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --3780 * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn3781 * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en3782 * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de3783 * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.3784 *3785 * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`3786 * heten en hangt het van de driver af welke wint. Eรฉn extra query per pagina3787 * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.3788 */3789 export function getTimeline(slug, limit, offset) {3790 const rows = tlStmts().list.all(slug, limit || 50, offset || 0);3791 const reacties = getReactionsFor(slug, rows.map((r) => r.id));3792 for (const r of rows) {3793 const x = reacties.get(r.id);3794 r.liked = !!(x && x.liked);3795 r.boosted = !!(x && x.boosted);3796 }3797 return rows;3798 }3799 3800 /**3801 * The direct notes addressed to this account: a plain DM, a guardian's wave3802 * (ยง5), a ward's ๐ help request (ยง5.2.1). They live in ap_mentions and NOT in3803 * the timeline, because a note addressed to named people is a message and not a3804 * post (belongsInTimeline).3805 *3806 * A client that only reads the timeline therefore sees none of them, which is3807 * exactly what happened to Shaer: Berichten showed your own replies (those come3808 * from your outbox) and nothing that was said to you. The C2S inbox read serves3809 * both, so the app has one door for everything that arrives.3810 *3811 * A public mention from someone you follow is stored in both tables; those are3812 * skipped here and stay a post.3813 */3814 // Inbound replies on YOUR posts, for the app's message stream. They live in3815 // ap_interactions (the web's comment machinery) and deliberately NOT in3816 // ap_mentions (the mention store returns early for replies-to-us), so the3817 // C2S read missed them entirely: a reply arrived at the other side3818 // everywhere EXCEPT in the other's app (Robins melding, 30-7: "komt niet3819 // binnen bij de ander").3820 const REPLY_COLUMNS = `3821 i.object_uri, i.actor_uri, i.actor_name, i.actor_handle, i.actor_icon, i.actor_url,3822 i.content, i.published, i.created_at, i.parent_uri, i.post_id,3823 i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json`;3824 3825 /** Dezelfde antwoordrijen, maar op object-uri -- voor de verschil-lezing. */3826 export function replyRowsByUri(slug, uris) {3827 const list = (uris || []).filter((u) => typeof u === 'string' && u);3828 if (!list.length) return [];3829 try {3830 const holes = list.map(() => '?').join(',');3831 return db.prepare(`SELECT ${REPLY_COLUMNS} FROM ap_interactions i3832 JOIN posts p ON p.id = i.post_id3833 JOIN sites s ON s.id = p.site_id3834 WHERE s.slug = ? AND i.kind = 'reply' AND i.object_uri IN (${holes})`)3835 .all(slug, ...list);3836 } catch { return []; }3837 }3838 3839 /** Tijdlijnrijen op id, met dezelfde afgeleide liked/boosted als getTimeline. */3840 export function timelineRowsByIds(slug, ids) {3841 const list = (ids || []).filter((u) => typeof u === 'string' && u);3842 if (!list.length) return [];3843 try {3844 const holes = list.map(() => '?').join(',');3845 const rows = db.prepare(`SELECT * FROM ap_timeline WHERE slug = ? AND id IN (${holes})`).all(slug, ...list);3846 const reacties = getReactionsFor(slug, rows.map((r) => r.id));3847 for (const r of rows) {3848 const x = reacties.get(r.id);3849 r.liked = !!(x && x.liked);3850 r.boosted = !!(x && x.boosted);3851 }3852 return rows;3853 } catch { return []; }3854 }3855 3856 export function getReplyMessages(slug, limit) {3857 try {3858 return db.prepare(`3859 SELECT ${REPLY_COLUMNS}3860 FROM ap_interactions i3861 JOIN posts p ON p.id = i.post_id3862 JOIN sites s ON s.id = p.site_id3863 WHERE s.slug = ? AND i.kind = 'reply'3864 ORDER BY COALESCE(i.published, i.created_at) DESC LIMIT ?`).all(slug, limit || 60);3865 } catch { return []; }3866 }3867 3868 /**3869 * Een merk voor "is er iets veranderd aan wat de inbox-lezing zou opleveren?"3870 * (shaer-n05).3871 *3872 * Alle VIER de poten die de inbox samenvoegt tellen mee -- tijdlijn, berichten,3873 * antwoorden op je eigen posts, en wat je zelf verstuurde. Zou er een ontbreken,3874 * dan blijft een wachtende client slapen terwijl er wel degelijk iets is3875 * bijgekomen, en dat is erger dan niet wachten: het lijkt te werken.3876 *3877 * rowid en niet een tijdstempel: rowid loopt strikt op per invoeging, terwijl3878 * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een3879 * andere server niet te vertrouwen is.3880 *3881 * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd3882 * mee; de vorm mag veranderen zonder dat dat iets breekt.3883 */3884 export function feedCursor(slug) {3885 try {3886 const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);3887 return String((r && r.n) || 0);3888 } catch { return '0'; }3889 }3890 3891 /**3892 * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,3893 * bewerkt, of weg.3894 *3895 * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en3896 * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens3897 * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige3898 * plek waar ze samen te lezen zijn.3899 */3900 export function feedChangesSince(slug, rev, limit = 200) {3901 try {3902 return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state3903 WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)3904 .all(slug, parseInt(rev, 10) || 0, limit);3905 } catch { return []; }3906 }3907 3908 // Zoveel clients mogen er tegelijk op EEN account staan wachten. Een client met3909 // een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen3910 // krijgen gewoon meteen antwoord in plaats van een fout.3911 const FEED_WAIT_MAX = 4;3912 const _wachters = new Map();3913 3914 /**3915 * Wacht tot de inbox-lezing iets anders zou opleveren dan bij `since`.3916 *3917 * Bewust met een interne tik en niet met een gebeurtenis-emitter. Een emitter3918 * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je3919 * vergeet is precies de melding die nooit aankomt. Twee tot vier MAX(rowid)-3920 * queries per seconde is niets, en dit kan niets missen. Prijs: hooguit een tik3921 * vertraging.3922 */3923 export async function waitForFeedChange(slug, opts = {}) {3924 const tickMs = Math.max(50, opts.tickMs || 1000);3925 const waitMs = Math.max(0, opts.waitMs || 0);3926 const since = String(opts.since || '');3927 let cursor = feedCursor(slug);3928 // Geen sinds, al iets veranderd, of niet willen wachten: meteen antwoorden.3929 if (!since || since !== cursor || !waitMs) return { cursor, changed: !!since && since !== cursor, waited: false };3930 3931 const bezet = _wachters.get(slug) || 0;3932 if (bezet >= FEED_WAIT_MAX) return { cursor, changed: false, waited: false, busy: true };3933 _wachters.set(slug, bezet + 1);3934 try {3935 const einde = Date.now() + waitMs;3936 while (Date.now() < einde) {3937 if (opts.signal && opts.signal.aborted) break; // client hing op3938 const rest = Math.min(tickMs, einde - Date.now());3939 await new Promise((r) => setTimeout(r, rest));3940 cursor = feedCursor(slug);3941 if (cursor !== since) return { cursor, changed: true, waited: true };3942 }3943 return { cursor, changed: false, waited: true };3944 } finally {3945 const n = (_wachters.get(slug) || 1) - 1;3946 if (n > 0) _wachters.set(slug, n); else _wachters.delete(slug);3947 }3948 }3949 3950 // โโ Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) โโโโโโโโโโ3951 //3952 // De oude lezing gaf de nieuwste 60 berichten over ALLE gesprekken samen. Dat3953 // knipt geschiedenis weg zonder dat iemand het merkt, en het is bij DM's veel3954 // erger dan bij posts: dat zijn er meer en het zijn kortere berichten, dus een3955 // druk gesprek kan de 60 in zijn eentje opeten en de rest uit de lezing duwen.3956 // Viel het laatste bericht van iemand erbuiten, dan verdween die persoon3957 // helemaal uit Messages -- de avatarhemel plaatst mensen op de leeftijd van hun3958 // laatste bericht, dus geen bericht is geen gezicht.3959 //3960 // Vandaar twee lezingen. Deze geeft EEN rij per tegenpartij, hoe druk iemand3961 // ook is, en conversationHistory hieronder geeft het gesprek zelf met een3962 // cursor. Wat de client van de hemel nodig heeft -- wie, wanneer, en waarmee --3963 // zit in die ene nieuwste note.3964 //3965 // Een gesprek is hier hetzelfde als in de app: incoming zijn de ap_mentions3966 // (die tabel IS de aan ons gerichte post), uitgaand zijn de eigen notes met3967 // visibility 'direct'. Een publiek antwoord is geen gesprek en hoort niet als3968 // gezicht in de hemel.3969 /**3970 * EEN STEMPEL IN EEN VORM, en dat is hier geen netheid maar de volgorde zelf.3971 *3972 * Drie vormen kwamen samen in deze unie: `2026-08-13 19:26:17` van SQLite's3973 * CURRENT_TIMESTAMP, `2026-08-13T18:21:57Z` uit een object, en dezelfde met3974 * milliseconden. Als TEKST vergeleken staat op plek 10 een spatie tegen een3975 * T -- en een spatie is kleiner. Dus sorteerde binnen dezelfde dag alles wat3976 * JIJ stuurde vรณรณr alles wat binnenkwam, ongeacht de klok (Barts melding 14-8:3977 * een bericht van 00:30 stond boven een antwoord van 20:22 de avond ervoor).3978 *3979 * strftime leest alle drie en geeft er een vorm voor terug, in UTC. Lukt het3980 * niet, dan blijft de rauwe waarde staan -- dan is die ene rij verkeerd3981 * gesorteerd in plaats van de hele lijst.3982 *3983 * Dit gaat ook de client aan: `new Date('2026-08-13 19:26:17')` leest in3984 * JavaScript als LOKALE tijd en `...T19:26:17Z` als UTC. Dezelfde rij gaf dus3985 * een leeftijd die twee uur verschilde per vorm.3986 */3987 const STEMPEL = (rauw) => `COALESCE(strftime('%Y-%m-%dT%H:%M:%SZ', ${rauw}), ${rauw})`;3988 3989 const CONVERSATION_UNION = `3990 SELECT m.actor_uri AS other, ${STEMPEL('COALESCE(m.published, m.created_at)')} AS stamp,3991 'in' AS direction, m.object_uri AS ref3992 FROM ap_mentions m3993 WHERE m.slug = @slug AND m.actor_uri IS NOT NULL AND m.actor_uri <> ''3994 UNION ALL3995 SELECT j.value AS other, ${STEMPEL('o.created_at')} AS stamp,3996 'out' AS direction, o.id AS ref3997 FROM ap_outbox o3998 JOIN json_each(COALESCE(NULLIF(o.to_actors, ''), json_array(o.to_actor))) j3999 WHERE o.site_slug = @slug AND o.visibility = 'direct'4000 AND j.value IS NOT NULL AND j.value <> ''`;4001 4002 /**4003 * Een rij per tegenpartij: zijn nieuwste bericht, nieuwste gesprek eerst.4004 *4005 * Compleet van vorm -- het aantal rijen is het aantal mensen, niet het aantal4006 * berichten -- dus de hemel kan niemand meer kwijtraken doordat een ander druk4007 * was. Zonder limiet, en dat mag: dit schaalt met je kring.4008 */4009 export function conversationHeads(slug) {4010 try {4011 // Twee rijen per persoon, niet een: het nieuwste bericht (dat bepaalt waar4012 // iemand in de hemel hangt) EN het nieuwste bericht VAN HEM.4013 //4014 // Die tweede is er omdat het nieuwste bericht van jou kan zijn, en dan4015 // draagt het jouw byline. De hemel zoekt de naam en het gezicht van de4016 // ander in een bericht van de ander -- vond hij dat niet, dan viel hij4017 // terug op het staartje van de actor-uri en heette tante opeens4018 // 'hotelbreakfast'. Op het toestel gezien, 10-8.4019 //4020 // Valt het samen (het nieuwste is al van hem), dan is het een rij; dubbel4021 // sturen doen we niet.4022 return db.prepare(`4023 SELECT other, stamp, direction, ref FROM (4024 SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn4025 FROM (${CONVERSATION_UNION})4026 ) WHERE rn = 14027 UNION4028 SELECT other, stamp, direction, ref FROM (4029 SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn4030 FROM (${CONVERSATION_UNION}) WHERE direction = 'in'4031 ) WHERE rn = 14032 ORDER BY stamp DESC, ref DESC`).all({ slug });4033 } catch { return []; }4034 }4035 4036 /**4037 * Een gesprek, nieuwste eerst, met een cursor.4038 *4039 * BEIDE KANTEN ONDER EEN LIMIET. In de oude lezing werden jouw kant4040 * (getSentNotes) en hun kant apart afgekapt, waardoor een gesprek eenzijdig4041 * kon lijken -- alsof iemand nooit geantwoord had. Hier is de limiet er een4042 * voor het gesprek als geheel.4043 *4044 * `before` is de cursor van het OUDSTE bericht dat je al hebt; je krijgt wat4045 * daarvoor ligt. Er komt er een extra op om te weten of er nog meer is: de4046 * client hoort dat te weten zonder te moeten gokken, en zonder dat weten kan4047 * 'load more' niet eerlijk verschijnen.4048 *4049 * DE CURSOR IS SAMENGESTELD -- '<stempel>|<ref>' -- en niet alleen de stempel.4050 * Twee berichten in dezelfde seconde is bij DM's geen randgeval maar een4051 * gesprek, en met 'stamp < before' zou alles wat die grensseconde deelt stil4052 * overgeslagen worden. Je zou het niet merken: de pagina komt gewoon, er4053 * ontbreekt alleen iets in het midden.4054 */4055 const cursorOf = (r) => (r ? `${r.stamp}|${r.ref}` : null);4056 4057 export function conversationHistory(slug, other, { before = null, limit = 60 } = {}) {4058 try {4059 const n = Math.min(Math.max(parseInt(limit, 10) || 60, 1), 200);4060 const sep = String(before || '').indexOf('|');4061 const bStamp = before && sep > 0 ? String(before).slice(0, sep) : null;4062 const bRef = before && sep > 0 ? String(before).slice(sep + 1) : null;4063 const rows = db.prepare(`4064 SELECT other, stamp, direction, ref FROM (${CONVERSATION_UNION})4065 WHERE other = @other4066 AND (@bStamp IS NULL OR stamp < @bStamp OR (stamp = @bStamp AND ref < @bRef))4067 ORDER BY stamp DESC, ref DESC LIMIT @n`).all({ slug, other, bStamp, bRef, n: n + 1 });4068 const more = rows.length > n;4069 const page = more ? rows.slice(0, n) : rows;4070 return { rows: page, more, oldest: cursorOf(page[page.length - 1]) };4071 } catch { return { rows: [], more: false, oldest: null }; }4072 }4073 4074 // De kolommen die een bericht tot kaart maken. Een constante, want de4075 // gesprekslezing haalt dezelfde rows op: twee lijsten die uiteenlopen leveren4076 // een kaart die op de ene plek een plaatje heeft en op de andere niet.4077 const MESSAGE_COLUMNS = `4078 m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,4079 m.content, m.published, m.created_at, m.wave, m.help_request,4080 m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json`;4081 4082 /** Dezelfde berichtrijen, maar op object-uri -- voor een gesprek. */4083 export function messageRowsByUri(slug, uris) {4084 const lijst = (uris || []).filter((u) => typeof u === 'string' && u);4085 if (!lijst.length) return [];4086 try {4087 const gaten = lijst.map(() => '?').join(',');4088 return db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM ap_mentions m4089 WHERE m.slug = ? AND m.object_uri IN (${gaten})`).all(slug, ...lijst);4090 } catch { return []; }4091 }4092 4093 /**4094 * Tot waar deze lezer elk gesprek gelezen heeft (shaer-frontend-3tx).4095 *4096 * De markering komt uit AS2 `Read`-activiteiten, en die zijn OPTELLEND: het4097 * lezen van bericht N maakt niets anders ongelezen. Daarom is achteruit gaan4098 * geen regel die iemand moet onthouden maar een eigenschap van het model --4099 * markRead neemt het maximum. Een 'zet mijn markering op X' zou een toestel4100 * dat een week uit stond je gelezen berichten weer op ongelezen laten zetten.4101 */4102 export function readMarkers(slug) {4103 try {4104 return new Map(db.prepare('SELECT other, cursor FROM ap_read_markers WHERE slug = ?')4105 .all(slug).map((r) => [r.other, r.cursor]));4106 } catch { return new Map(); }4107 }4108 4109 /**4110 * Markeer een gesprek als gelezen tot en met dit bericht.4111 *4112 * Het object van de Read is een berichturi; welk gesprek dat is en waar het in4113 * de tijd staat weet de server zelf, dus de client hoeft niets uit te rekenen4114 * en kan er ook niet naast zitten.4115 */4116 export function markRead(slug, objectUri) {4117 try {4118 const rij = db.prepare(`SELECT other, stamp, ref FROM (${CONVERSATION_UNION})4119 WHERE ref = @ref ORDER BY stamp DESC LIMIT 1`)4120 .get({ slug, ref: String(objectUri || '') });4121 if (!rij) return null;4122 const cursor = `${rij.stamp}|${rij.ref}`;4123 db.prepare(`INSERT INTO ap_read_markers (slug, other, cursor) VALUES (?,?,?)4124 ON CONFLICT(slug, other) DO UPDATE SET cursor = MAX(cursor, excluded.cursor), at = CURRENT_TIMESTAMP`)4125 .run(slug, rij.other, cursor);4126 return { other: rij.other, cursor };4127 } catch { return null; }4128 }4129 4130 /**4131 * Hoeveel er per gesprek nog ongelezen is, en of daar een zwaai bij zit.4132 *4133 * Een COUNT en geen bijgehouden getal (Barts besluit): niets om op te hogen4134 * bij bezorging, niets om te verlagen bij lezen, en bij een verwijdering klopt4135 * het vanzelf weer.4136 *4137 * Een zwaai telt apart, want dat is geen gesprek maar een zetje van een4138 * guardian -- die hoort een eigen teken te krijgen en niet opgeteld te worden.4139 * Eigen berichten tellen nooit mee: je hebt jezelf gelezen.4140 */4141 export function unreadPerConversation(slug, { messagesAllowed = true, guardians = new Set() } = {}) {4142 try {4143 // DE POORT TELT MEE. Staat messages dicht, dan toont de app die berichten4144 // niet -- en dan mag een badge ze ook niet aankondigen, want dat getal4145 // vertelt precies wat de poort verbergt. Wat er altijd door mag telt wel:4146 // het guardian-kanaal en de boei. Zelfde regel als bij de serialisatie.4147 const rijen = db.prepare(`4148 SELECT u.other AS other,4149 COUNT(*) AS n,4150 MAX(CASE WHEN m.wave = 1 THEN 1 ELSE 0 END) AS wave4151 FROM (${CONVERSATION_UNION}) u4152 LEFT JOIN ap_read_markers r ON r.slug = @slug AND r.other = u.other4153 LEFT JOIN ap_mentions m ON m.slug = @slug AND m.object_uri = u.ref4154 WHERE u.direction = 'in'4155 AND (r.cursor IS NULL OR (u.stamp || '|' || u.ref) > r.cursor)4156 AND (@open = 1 OR m.help_request = 1 OR u.other IN (SELECT value FROM json_each(@guardians)))4157 GROUP BY u.other`)4158 .all({ slug, open: messagesAllowed ? 1 : 0, guardians: JSON.stringify([...guardians]) });4159 return new Map(rijen.map((r) => [r.other, { n: r.n, wave: !!r.wave }]));4160 } catch { return new Map(); }4161 }4162 4163 export function getDirectMessages(slug, limit) {4164 try {4165 return db.prepare(`4166 SELECT ${MESSAGE_COLUMNS}4167 FROM ap_mentions m4168 WHERE m.slug = ?4169 AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)4170 ORDER BY COALESCE(m.published, m.created_at) DESC LIMIT ?`).all(slug, limit || 60);4171 } catch { return []; }4172 }4173 4174 /**4175 * A stored stamp as an ISO instant. SQLite's CURRENT_TIMESTAMP writes4176 * 'YYYY-MM-DD HH:MM:SS' in UTC, which Date.parse reads as LOCAL time; on a4177 * server two hours ahead that dated every message two hours early and put the4178 * conversation in the wrong order. A `published` from the wire is already ISO4179 * and passes through untouched.4180 */4181 export function isoStamp(v) {4182 if (!v) return undefined;4183 const s = String(v);4184 if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s)) return `${s.replace(' ', 'T')}Z`;4185 const t = Date.parse(s);4186 return Number.isFinite(t) ? new Date(t).toISOString() : undefined;4187 }4188 4189 // Inbox C2S read: a timeline row's media_json ([{url, type}], written on the4190 // inbound Create) โ AS2 `attachment` array, so a client (Shaer) can render a4191 // friend's images/audio/video natively, exactly like own outbox posts. The4192 // stored `type` is the mediaType and may be ''. Malformed JSON yields4193 // undefined and never blocks the item.4194 export function timelineAttachments(mediaJson) {4195 try {4196 const list = mediaJson ? JSON.parse(mediaJson) : [];4197 const rows = (Array.isArray(list) ? list : [])4198 .filter((m) => m && m.url)4199 .map((m) => {4200 const a = { type: 'Document', mediaType: m.type || undefined, url: m.url };4201 if (m.poster) a.icon = { type: 'Image', url: m.poster }; // the video's still (shaer-zowq)4202 return a;4203 });4204 return rows.length ? rows : undefined;4205 } catch { return undefined; }4206 }4207 4208 // FEP-9098 custom emojis. Inbound: keep the note's Emoji tags (as JSON) so we4209 // can serve them back. `extractEmojiTags` returns the JSON to store (or null);4210 // `timelineEmojis` turns the stored JSON back into an AS2 `tag` array for the4211 // C2S inbox read, so a client (Shaer) can render :shortcode: as an image.4212 export function extractEmojiTags(tag) {4213 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);4214 const emojis = arr.filter((t) => t && (Array.isArray(t.type) ? t.type[0] : t.type) === 'Emoji'4215 && typeof t.name === 'string' && t.icon);4216 return emojis.length ? JSON.stringify(emojis) : null;4217 }4218 // โโ Gate-filters voor de C2S-serialisatie (shaer-ahy.1, 8-8) โโโโโโ4219 //4220 // Dezelfde regel als bij de embeds: de poort zit bij de AFLEVERING. Een4221 // bijlage die de client alleen verbergt is wel degelijk geleverd, dus wat4222 // dicht is wordt hier nooit geserialiseerd. Puur, zodat de regels los van de4223 // routes te toetsen zijn.4224 4225 /** Bijlagen door de beeld- en muziekpoort. Leeg wordt undefined, zoals de4226 * serialisatie dat overal doet. */4227 export function gateAttachments(atts, { images = true, audio = true } = {}) {4228 if (!Array.isArray(atts)) return atts;4229 const out = atts.filter((a) => {4230 const mt = String((a && a.mediaType) || '');4231 if (!images && mt.startsWith('image/')) return false;4232 if (!audio && (mt.startsWith('audio/') || (a && a.type === 'Audio'))) return false;4233 return true;4234 });4235 return out.length ? out : undefined;4236 }4237 4238 /** Tag-array zonder de FEP-9098 Emoji-tags, voor een dichte emoji-poort. De4239 * :shortcode: blijft als tekst staan -- dat is eerlijk: er STAAT iets, het4240 * wordt alleen niet als plaatje van een vreemde server gerenderd. */4241 export function stripEmojiTags(tags) {4242 if (!Array.isArray(tags)) return tags;4243 const out = tags.filter((t) => (Array.isArray(t && t.type) ? t.type[0] : (t && t.type)) !== 'Emoji');4244 return out.length ? out : undefined;4245 }4246 4247 export function timelineEmojis(emojiJson) {4248 try { const arr = emojiJson ? JSON.parse(emojiJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }4249 catch { return undefined; }4250 }4251 4252 // FEP-e232 object links (quotes / inline references). Inbound: keep the note's4253 // Link tags whose mediaType marks an AP object (the AS2-profiled ld+json, or4254 // activity+json as its equivalent) as JSON, so the C2S inbox read can serve4255 // them back and a client (Shaer) can render the quote/reference. Mirrors4256 // extractEmojiTags. Plain hyperlinks (text/html) and Mentions are dropped.4257 export function extractObjectLinkTags(tag) {4258 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);4259 const links = arr.filter((t) => {4260 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link') return false;4261 if (typeof t.href !== 'string' || !t.href) return false;4262 const mt = String(t.mediaType || '').toLowerCase();4263 return (mt.startsWith('application/ld+json') && mt.includes('activitystreams'))4264 || mt.startsWith('application/activity+json');4265 });4266 return links.length ? JSON.stringify(links) : null;4267 }4268 export function timelineObjectLinks(linkJson) {4269 try { const arr = linkJson ? JSON.parse(linkJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }4270 catch { return undefined; }4271 }4272 4273 // FEP-044f quote posts: a quote is usually NOT an FEP-e232 tag but an4274 // object-level property. FEP-044f ยง"how to recognise" lists them all:4275 // `quote` (the FEP property, a string or an embedded Link/object), and the4276 // de-facto `quoteUrl` (as:), `quoteUri` (fedibird), `_misskey_quote` (misskey).4277 // This returns the quoted object's URL from whichever is present.4278 export function extractQuoteUrl(note) {4279 if (!note || typeof note !== 'object') return null;4280 const q = note.quote ?? note.quoteUrl ?? note.quoteUri ?? note['_misskey_quote'];4281 if (!q) return null;4282 if (typeof q === 'string') return q || null;4283 if (typeof q === 'object') return (typeof q.id === 'string' && q.id) || (typeof q.href === 'string' && q.href) || null;4284 return null;4285 }4286 4287 // The note's object-link tags for storage: real FEP-e232 Link tags PLUS any4288 // FEP-044f object-level quote, normalised to one FEP-e232-shaped Link (rel4289 // _misskey_quote) so the client's single object-link path renders them all.4290 // Deduped by href. Returns the JSON to store (or null if the note has neither).4291 export function extractLinkJson(note) {4292 const links = [];4293 const fromTag = extractObjectLinkTags(note && note.tag);4294 if (fromTag) { try { links.push(...JSON.parse(fromTag)); } catch { /* ignore */ } }4295 const qUrl = extractQuoteUrl(note);4296 if (qUrl && !links.some((l) => l && l.href === qUrl)) {4297 links.push({ type: 'Link', mediaType: 'application/activity+json', href: qUrl,4298 rel: ['https://misskey-hub.net/ns#_misskey_quote'], name: qUrl });4299 }4300 return links.length ? JSON.stringify(links) : null;4301 }4302 4303 // The URL of the quoted post, from either an object-level quote (FEP-044f) or a4304 // quote-rel FEP-e232 Link tag. Used to resolve the embedded quote card.4305 export function quoteHrefOf(note) {4306 const direct = extractQuoteUrl(note);4307 if (direct) return direct;4308 const arr = Array.isArray(note && note.tag) ? note.tag : (note && note.tag ? [note.tag] : []);4309 for (const t of arr) {4310 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link' || typeof t.href !== 'string') continue;4311 const rel = Array.isArray(t.rel) ? t.rel : (t.rel ? [t.rel] : []);4312 if (rel.some((r) => /quote/i.test(String(r)))) return t.href;4313 }4314 return null;4315 }4316 4317 // Turn the stored quote snapshot back into the object the C2S inbox read serves4318 // as `shaer:quote`, so the client can render the embedded quote card.4319 export function timelineQuote(quoteJson) {4320 try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }4321 catch { return undefined; }4322 }4323 3790 4324 3791 // Store the author's display-name emoji map (from actorInfo().emojis) on a … … 6305 5772 deliverCreate, 6306 5773 }); 5774 // En de tijdlijn-leeskant zijn ene werktuig (stap 5): liked/boosted komen uit 5775 // het reactiecluster, dat hier woont. 5776 wireTimeline({ getReactionsFor }); 6307 5777 6308 5778 export default {
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)