From 0c98276d324db649c4a15be7859a501588257972 Mon Sep 17 00:00:00 2001 From: s-ol Date: Thu, 13 Jan 2022 18:27:20 +0100 Subject: move everything into client/ --- client/src/config.js | 3 + client/src/graph.js | 205 ++++++++++++++++++++++++++++++++++++ client/src/index.js | 162 ++++++++++++++++++++++++++++ client/src/layout.js | 48 +++++++++ client/src/ui/Attachment.js | 26 +++++ client/src/ui/Discussion.js | 122 ++++++++++++++++++++++ client/src/ui/Links.js | 50 +++++++++ client/src/ui/Menu.js | 139 +++++++++++++++++++++++++ client/src/ui/Note.js | 249 ++++++++++++++++++++++++++++++++++++++++++++ client/src/ui/Selection.js | 93 +++++++++++++++++ client/src/ui/css.js | 13 +++ client/src/ui/index.js | 10 ++ client/src/ui/theme.js | 39 +++++++ client/src/viz.es.js | 194 ++++++++++++++++++++++++++++++++++ 14 files changed, 1353 insertions(+) create mode 100644 client/src/config.js create mode 100644 client/src/graph.js create mode 100644 client/src/index.js create mode 100644 client/src/layout.js create mode 100644 client/src/ui/Attachment.js create mode 100644 client/src/ui/Discussion.js create mode 100644 client/src/ui/Links.js create mode 100644 client/src/ui/Menu.js create mode 100644 client/src/ui/Note.js create mode 100644 client/src/ui/Selection.js create mode 100644 client/src/ui/css.js create mode 100644 client/src/ui/index.js create mode 100644 client/src/ui/theme.js create mode 100644 client/src/viz.es.js (limited to 'client/src') diff --git a/client/src/config.js b/client/src/config.js new file mode 100644 index 0000000..91d6231 --- /dev/null +++ b/client/src/config.js @@ -0,0 +1,3 @@ +export const PUBLIC_URL = process.env.PUBLIC_URL || `${location.origin}`; +export const API_PREFIX = process.env.API_PREFIX || `${location.origin}`; +export const CORS_PREFIX = process.env.CORS_PREFIX || `${API_PREFIX}/remote`; diff --git a/client/src/graph.js b/client/src/graph.js new file mode 100644 index 0000000..7fe842c --- /dev/null +++ b/client/src/graph.js @@ -0,0 +1,205 @@ +import { Component } from 'preact'; +import * as jsonld from 'jsonld'; +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', '@type': '@id', '@container': '@set' }, + }, +]; + +export class GraphContainer extends Component { + state = { + loading: true, + name: "loading…", + items: {}, + error: null, + }; + + cache = {}; + + componentDidMount() { + this.componentDidUpdate({}); + } + + componentDidUpdate(prevProps) { + if (prevProps.url === this.props.url) return; + + this.loadData(this.props.url) + .catch(error => { + console.error(`Error loading ${this.props.url}:`, error); + this.setState({ + loading: false, + error, + }); + }); + } + + @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', + }; + } + } + + render() { + window.state = this.state; + return this.props.render(this.state); + } +} + +export class GraphContainerMastodon extends GraphContainer { + @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(url) { + const items = {}; + const root = await this.loadNote(url, items, []); + + this.setState({ + name: root.name || root.content, + items, + loading: false, + }); + } +} + +export class GraphContainerJSONLD extends GraphContainer { + async loadData(url) { + const response = await fetch(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; + + this.setState({ + ...discussion, + loading: false, + }); + } +} diff --git a/client/src/index.js b/client/src/index.js new file mode 100644 index 0000000..a30dbf1 --- /dev/null +++ b/client/src/index.js @@ -0,0 +1,162 @@ +import 'preact/debug'; +import 'core-js/stable'; +import 'regenerator-runtime/runtime'; +import { h, Fragment, Component, render } from 'preact'; +import { GraphContainerJSONLD, GraphContainerMastodon } from './graph'; +import { Menu, Discussion, Selection } from './ui'; + +class SelectionContainer extends Component { + state = { + selection: [], + }; + + toggle = (id) => { + const { selection } = this.state; + if (selection.indexOf(id) < 0) + this.setState({ selection: [ ...selection, id ] }); + else + this.setState({ selection: selection.filter(i => i !== id) }); + } + + render() { + return this.props.render({ + ...this.state, + toggleSelected: this.toggle, + }); + } +} + +class CollapseContainer extends Component { + /** + * Contains the collapse-state for each node. + * + * - `false`: visible + * - `true`: implicitly hidden + * - `'explicit'`: explicitly hidden + */ + state = {}; + + /** + * Toggle a node's collapse-state. + * - `false` → `'explicit'` + * - `true`, `'explicit'` → `false` + */ + toggle = (id) => { + const { items } = this.props; + + const state = Object.assign({}, this.state); + state[id] = state[id] ? false : 'explicit'; + + this.setState(this.update(items[id], state)); + } + + /** + * Recursively update child node's collapse-state. + * + * If a node has parents and they are all collapsed, + * mark that node as implicitly hidden (`true`). + * + * Then update all of that node's children. + */ + update(node, state) { + const { items } = this.props; + + if (state[node.id] !== 'explicit') { + state[node.id] = + node.inReplyTo.length + && node.inReplyTo.every(p => state[p.id]); + } + + for (const child of node.replies) + this.update(items[child.id], state); + + return state; + } + + render() { + return this.props.render({ + collapsed: this.state, + toggleCollapsed: this.toggle, + }); + } +} + +const graphRender = ({ loading, error, name, items }) => { + if (loading) { + return ( +
+
loading...
+
+ ); + } + + if (error) { + return ( +
+

error loading

+
+

{error.toString()}

+
+						
+							{error.stack}
+						
+					
+
+
+ ); + } + + return ( + ( + <> + ( + + )} + /> + items[id])} + toggleSelected={toggleSelected} + /> + + )} /> + ); +}; + +const search = new URLSearchParams(window.location.search); +const graph = search.has('graph') ? search.get('graph') : 'lib/graph.json'; + +let app; +if (search.has('document')) { + app = ( + + ); +} else if (search.has('note')) { + app = ( + + ); +} else if (search.has('graph')) { + app = ( + + ); +} else { + app = ; +} + +render(app, document.body); diff --git a/client/src/layout.js b/client/src/layout.js new file mode 100644 index 0000000..c9c2a4d --- /dev/null +++ b/client/src/layout.js @@ -0,0 +1,48 @@ +import Viz from './viz.es'; + +const toNumber = (i) => Number(i); +const gv = new Viz({ workerURL: 'lib/lite.render.js' }); + +export default class Layout { + constructor() { + this.src = 'digraph { node [shape="rectangle"];\n'; + } + + addNode(id, width, height) { + this.src += ` "${id}" [fixedsize=true; width=${width / 72}; height=${height / 72}];\n`; + } + + addLink(from, to) { + this.src += ` "${from}" -> "${to}";\n`; + } + + async render() { + this.src += '}'; + + const info = await gv.renderJSONObject(this.src, { format: 'json0' }); + info.objects ||= []; + info.edges ||= []; + const [width, height] = info.bb.split(',').map(toNumber).slice(2); + + const positions = {}; + for (const object of info.objects) { + const [x, y] = object.pos.split(',').map(toNumber); + positions[object.name] = [ x, height - y ]; + } + + const links = info.edges.map((edge) => { + const pos = edge.pos.slice(2); + return pos.split(' ').map(p => { + const [x, y] = p.split(','); + return [toNumber(x), height - toNumber(y)]; + }); + + }); + + return { + width, height, + positions, + links, + }; + } +} diff --git a/client/src/ui/Attachment.js b/client/src/ui/Attachment.js new file mode 100644 index 0000000..3c07ee5 --- /dev/null +++ b/client/src/ui/Attachment.js @@ -0,0 +1,26 @@ +import { h } from 'preact'; +import css from './css'; + +css` +.attachment { + margin: 1rem; + + height: 7rem; + object-fit: contain; +} + +.attachment + .attachment { + margin-top: 0; +} +`; + +export const Attachment = ({ type, mediaType, url, style }) => { + let content; + if (mediaType.startsWith('video/')) { + content =