import { h } from 'preact'; import { useMemo, useEffect } from 'preact/hooks'; import * as jsonld from 'jsonld'; import { useDispatcher, fetchJSON } from './actions'; import { CORS_PREFIX } from './config'; const cors = (url) => `${CORS_PREFIX}/${url}`; function wrapCache(target) { const orig = target.descriptor.value; target.descriptor.value = function (id, ...args) { this.cache[id] ||= orig.call(this, id, ...args); return this.cache[id]; }; return target; }; const context = [ 'https://www.w3.org/ns/activitystreams', { replies: { '@id': 'as:replies', '@container': '@set' }, inReplyTo: { '@id': 'as:inReplyTo', '@container': '@set' }, items: { '@id': 'as:items', '@container': '@set' }, }, ]; export const GraphContainer = ({ url, mode, children }) => { const api = useMemo(() => { const API = mode === "mastodon" ? GraphAPIMastodon : GraphAPIJSONLD; return new API(url); }, [url, mode]); const { state, dispatch, DispatchProvider } = useDispatcher({ reducer: api.reducer, initialState: { loading: true, name: "loading…", items: {}, error: null, }, }); useEffect(() => dispatch({ type: 'reload' }), []); return ( {children(state)} ); }; class GraphAPI { cache = {}; constructor(url) { this.url = url; } @wrapCache async loadUser(id) { if (id === "https://dag.s-ol.nu/users/unknown") { return { '@context': context, id, type: 'Person', name: 'unknown', summary: 'unknown user', }; } try { const user = await jsonld.compact(id, context); user.id = id; return user; } catch (error) { console.error(`Error loading user '${id}':`, error); return { '@context': context, id, type: 'Person', name: id, summary: 'failed to load user information', }; } } reducer = (action) => { switch (action.type) { case 'reload': return this.loadData(); case 'reply': return this.reply(action); default: return undefined; } } } export class GraphAPIMastodon extends GraphAPI { @wrapCache async loadCollection(id, ...args) { const collection = await jsonld.compact(cors(id), context); let items = []; const addItems = (i) => { if (Array.isArray(i)) items = items.concat(i); else if (i) items.push(i); }; // discover items if (collection.items) { addItems(collection.items); } else { let page = collection.first; const seen = []; while (page && seen.indexOf(page.id) < 0) { seen.push(page.id); addItems(page.items); page = page.next && await jsonld.compact(cors(page.next), context); } } // dereference items return await Promise.all(items.map(item => this.loadNote(item.id ?? item, ...args))); } @wrapCache async loadNote(id, items, inReplyTo) { let item; try { item = await jsonld.frame( cors(id), { '@context': context, type: 'Note', context: { '@embed': '@never' }, replies: { '@embed': '@always' }, inReplyTo: { '@embed': '@never' }, }, { omitGraph: true } ); if (item.replies.length) item.replies = await this.loadCollection(item.replies[0].id, items, [id]); } catch (error) { console.error(`Error loading note '${id}':`, error); item = { '@context': context, id, type: 'Tombstone', formerType: 'Note', attributedTo: 'https://dag.s-ol.nu/users/unknown', published: '1970-01-01T00:00:00Z', content: `Error loading note: ${error.toString()}`, replies: [], inReplyTo, }; } item.attributedTo = await this.loadUser(item.attributedTo); items[item.id] = item; return item; } async loadData() { const items = {}; const root = await this.loadNote(this.url, items, []); return () => ({ name: root.name || root.content, items, loading: false, }); } } class GraphAPIJSONLD extends GraphAPI { async loadData() { const response = await fetch(this.url, { headers: { 'Accept': 'application/ld+json, application/json' }, credentials: 'include', }); const discussion = await jsonld.frame( await response.json(), { '@context': context, type: 'Document', first: { '@embed': '@never' }, items: { context: { '@embed': '@never' }, replies: { '@embed': '@never' }, inReplyTo: { '@embed': '@never' }, attributedTo: { '@embed': '@always' }, }, }, { omitGraph: true } ); const indexedItems = {}; await Promise.all(discussion.items.map(async (item) => { item.attributedTo = await this.loadUser(item.attributedTo); indexedItems[item.id] = item; })); discussion.items = indexedItems; return () => ({ ...discussion, loading: false, }); } async reply({ content, to }) { const result = await fetchJSON(this.url, 'POST', { type: 'Create', object: { type: 'Note', inReplyTo: to, content, }, }); const changed = await jsonld.frame( result, { '@context': context, items: { context: { '@embed': '@never' }, replies: { '@embed': '@never' }, inReplyTo: { '@embed': '@never' }, attributedTo: { '@embed': '@always' }, }, }, { omitGraph: true } ); await Promise.all(changed.items.map(async (item) => { item.attributedTo = await this.loadUser(item.attributedTo); })); return (state) => { const items = {...state.items}; for (const note of changed.items) { items[note.id] = note; for (const { id } of note.inReplyTo) { if (items[id].replies.indexOf(note.id) > -1) continue; items[id] = { ...items[id], replies: [...items[id].replies, { id: note.id }], }; } } return { ...state, items }; } } }