aboutsummaryrefslogtreecommitdiffstats
path: root/client/src
diff options
context:
space:
mode:
authors-ol <s+removethis@s-ol.nu>2022-01-13 17:27:20 +0000
committers-ol <s+removethis@s-ol.nu>2022-01-13 17:27:20 +0000
commit0c98276d324db649c4a15be7859a501588257972 (patch)
tree8e2ee4784730e16290b043ee4b38418fa5166106 /client/src
parentMenu, DiscDAG loading (diff)
downloadfedidag-0c98276d324db649c4a15be7859a501588257972.tar.gz
fedidag-0c98276d324db649c4a15be7859a501588257972.zip
move everything into client/
Diffstat (limited to 'client/src')
-rw-r--r--client/src/config.js3
-rw-r--r--client/src/graph.js205
-rw-r--r--client/src/index.js162
-rw-r--r--client/src/layout.js48
-rw-r--r--client/src/ui/Attachment.js26
-rw-r--r--client/src/ui/Discussion.js122
-rw-r--r--client/src/ui/Links.js50
-rw-r--r--client/src/ui/Menu.js139
-rw-r--r--client/src/ui/Note.js249
-rw-r--r--client/src/ui/Selection.js93
-rw-r--r--client/src/ui/css.js13
-rw-r--r--client/src/ui/index.js10
-rw-r--r--client/src/ui/theme.js39
-rw-r--r--client/src/viz.es.js194
14 files changed, 1353 insertions, 0 deletions
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 (
+ <article>
+ <div>loading...</div>
+ </article>
+ );
+ }
+
+ if (error) {
+ return (
+ <article>
+ <h1>error loading</h1>
+ <div>
+ <p>{error.toString()}</p>
+ <pre>
+ <code>
+ {error.stack}
+ </code>
+ </pre>
+ </div>
+ </article>
+ );
+ }
+
+ return (
+ <SelectionContainer render={({ selection, toggleSelected }) => (
+ <>
+ <CollapseContainer
+ items={items}
+ render={({ collapsed, toggleCollapsed }) => (
+ <Discussion
+ name={name}
+ items={items}
+ collapsed={collapsed}
+ toggleCollapsed={toggleCollapsed}
+ toggleSelected={toggleSelected}
+ />
+ )}
+ />
+ <Selection
+ items={selection.map(id => 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 = (
+ <GraphContainerJSONLD
+ url={search.get('document')}
+ render={graphRender}
+ />
+ );
+} else if (search.has('note')) {
+ app = (
+ <GraphContainerMastodon
+ url={search.get('note')}
+ render={graphRender}
+ />
+ );
+} else if (search.has('graph')) {
+ app = (
+ <GraphContainerMastodon
+ url={search.get('graph')}
+ render={graphRender}
+ />
+ );
+} else {
+ app = <Menu />;
+}
+
+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 = <video class="attachment" src={url} controls />;
+ } else if (mediaType.startsWith('image/')) {
+ content = <img class="attachment" src={url} />;
+ }
+
+ return content;
+}
diff --git a/client/src/ui/Discussion.js b/client/src/ui/Discussion.js
new file mode 100644
index 0000000..5fef693
--- /dev/null
+++ b/client/src/ui/Discussion.js
@@ -0,0 +1,122 @@
+import { h } from 'preact';
+import { useState, useEffect } from 'preact/hooks';
+import Layout from '../layout';
+import css from './css';
+import { Links } from './Links';
+import { Note } from './Note';
+
+css`
+article {
+ position: absolute;
+ inset: 0;
+
+ overflow: auto;
+}
+
+article > h1 {
+ display: flex;
+ align-items: baseline;
+
+ position: sticky;
+ inset: 0;
+ bottom: auto;
+ z-index: 200;
+
+ margin: 0;
+ padding: 0.75rem 1.5rem;
+ background: var(--theme-title-bg);
+ color: var(--theme-title-fg);
+}
+
+article > h1 > span {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+article > h1 .back {
+ font-size: 0.8em;
+ color: inherit;
+ opacity: 0.5;
+
+ margin-left: 1em;
+}
+
+article > div {
+ position: relative;
+ margin: 2rem 2rem 6rem;
+}
+`;
+
+const tmp = document.createElement('span');
+
+export const Discussion = ({
+ name, items, collapsed,
+ toggleCollapsed, toggleSelected,
+}) => {
+ const [{ width, height, positions, links }, setState] = useState({
+ width: 0,
+ height: 0,
+ positions: {},
+ links: [],
+ });
+
+ const layout = new Layout();
+ const setNodeSize = layout.addNode.bind(layout);
+
+ useEffect(() => {
+ layout.render().then(setState);
+ }, [ items, collapsed ]);
+
+ tmp.innerHTML = name;
+ name = tmp.innerText;
+
+ return (
+ <article>
+ <h1>
+ <span>{name}</span>
+ <a class="back" href="?">back</a>
+ </h1>
+ <div>
+ <Links width={width} height={height} links={links} />
+ <div>
+ {Object.values(items).map((item) => {
+ const isCollapsed = collapsed[item.id];
+ const hidden =
+ item.inReplyTo.every(p => collapsed[p.id])
+ && item.replies.every(c => collapsed[c.id])
+ && item.inReplyTo.length !== 0;
+ if (hidden)
+ return;
+
+ const onlyParent = item.inReplyTo.length === 1 && items[item.inReplyTo[0].id];
+
+ for (const reply of item.replies) {
+ if (isCollapsed && collapsed[reply.id])
+ continue;
+
+ layout.addLink(item.id, reply.id);
+ }
+
+ return (
+ <Note
+ {...item}
+ smallHeader={
+ onlyParent // only one parent post
+ && onlyParent.attributedTo === item.attributedTo // posted by same user
+ && onlyParent.published === item.published // in the same second
+ }
+ collapsed={isCollapsed}
+ position={positions[item.id]}
+ onCollapse={toggleCollapsed}
+ onSelect={toggleSelected}
+ onSize={setNodeSize}
+ />
+ );
+ })}
+ </div>
+ </div>
+ </article>
+ );
+};
+
diff --git a/client/src/ui/Links.js b/client/src/ui/Links.js
new file mode 100644
index 0000000..0a0b1ef
--- /dev/null
+++ b/client/src/ui/Links.js
@@ -0,0 +1,50 @@
+import { h } from 'preact';
+import { useRef, useLayoutEffect } from 'preact/hooks';
+
+export const Links = ({ links, ...props }) => {
+ const canvas = useRef(null);
+
+ useLayoutEffect(() => {
+ const ctx = canvas.current.getContext('2d');
+ ctx.lineWidth = 4;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.strokeStyle = '#696969';
+ ctx.fillStyle = '#696969';
+ ctx.shadowBlur = 2;
+ ctx.shadowOffsetX = 2;
+ ctx.shadowOffsetY = 4;
+
+ ctx.clearRect(0, 0, canvas.current.width, canvas.current.height);
+
+ for (const points of links) {
+ ctx.shadowColor = 'rgba(0,0,0, 0.7)';
+ ctx.beginPath();
+ ctx.moveTo(...points[1]);
+ for (let i = 4; i < points.length; i += 3) {
+ const [ax, ay] = points[i - 2];
+ const [bx, by] = points[i - 1];
+ const [x, y] = points[i];
+ ctx.bezierCurveTo(ax, ay, bx, by, x, y);
+ }
+ ctx.stroke();
+
+ const [lx, ly] = points[points.length - 1];
+ const [tx, ty] = points[0];
+ const [dx, dy] = [tx - lx, ty - ly];
+ const [hx, hy] = [dy*0.7, dx*-0.7];
+
+ ctx.shadowColor = 'transparent';
+ ctx.beginPath();
+ ctx.moveTo(lx + dx*0.8, ly + dy*0.8);
+ ctx.lineTo(lx - dx*0.5 + hx, ly - dy*0.5 + hy);
+ ctx.lineTo(lx - dx*0.5 - hx, ly - dy*0.5 - hy);
+ ctx.fill();
+ }
+ });
+
+ return (
+ <canvas {...props} ref={canvas} />
+ );
+};
+
diff --git a/client/src/ui/Menu.js b/client/src/ui/Menu.js
new file mode 100644
index 0000000..6f32280
--- /dev/null
+++ b/client/src/ui/Menu.js
@@ -0,0 +1,139 @@
+import { h, Fragment } from 'preact';
+import { useState, useEffect } from 'preact/hooks';
+import cn from 'classnames';
+import { API_PREFIX } from '../config';
+import css from './css';
+
+const fetchJSON = async (url) => {
+ const res = await fetch(API_PREFIX + url, { credentials: 'include' });
+ if (res.status !== 200)
+ throw new Error("wrong status");
+
+ return res.json();
+};
+
+css`
+a.discussion {
+ display: flex;
+
+ padding: 0.25rem 0.5rem;
+ text-size: 2rem;
+ line-height: 2rem;
+ text-decoration: none;
+
+ border-radius: 0.3rem;
+
+ color: var(--theme-note-fg);
+ background: var(--theme-note-bg);
+}
+
+a.discussion .access {
+ opacity: 0.5;
+ margin-right: 0.75rem;
+}
+
+a.discussion .name {
+ flex: 1 1 auto;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+a.discussion > a {
+ margin-left: 0.75rem;
+ text-decoration: underline;
+ color: inherit;
+ opacity: 0.5;
+
+ transition: opacity 0.3s;
+}
+a.discussion > a:hover {
+ opacity: 1;
+}
+`;
+
+const DiscussionLink = ({ id, name, url, attributedTo, user }) => {
+ const writable = attributedTo && attributedTo.indexOf(user) > -1;
+
+ return (
+ <a class="discussion" href={`?document=${id}`}>
+ <span class="access">{writable ? 'W' : 'R'}</span>
+ <span class="name">{name}</span>
+ {url && (
+ <a href={url.href}>ext</a>
+ )}
+ </a>
+ );
+};
+
+css`
+ul.discussions {
+ list-style: none;
+ padding: 0;
+
+ width: 26rem;
+}
+
+ul.discussions > li {
+ margin: 0.2rem 0;
+}
+`;
+
+const DiscussionList = ({ discussions, user }) => {
+ return (
+ <ul class="discussions">
+ {discussions.map((discussion) => (
+ <li>
+ <DiscussionLink {...discussion} user={user} />
+ </li>
+ ))}
+ </ul>
+ );
+};
+
+css`
+div.menu {
+ margin: 1rem 2rem;
+}
+`;
+export const Menu = () => {
+ const [user, setUser] = useState(null);
+ const [discussions, setDiscussions] = useState([]);
+
+ useEffect(() => {
+ fetchJSON('/discdag/list')
+ .then(({ user, discussions }) => {
+ setUser(user ?? null);
+ setDiscussions(discussions);
+ });
+ }, []);
+
+ if (user === null) {
+ return (
+ <div class="menu">
+ <h2>Discussions</h2>
+ loading...
+ </div>
+ );
+ }
+
+ return (
+ <div class="menu">
+ <h2>Discussions</h2>
+ {/*user && user.name !== 'Guest'
+ ? (
+ <>
+ <span>logged in as {user.name}</span>
+ <button>log out</button>
+ </>
+ )
+ : (
+ <>
+ <span>not logged in</span>
+ <button>log in</button>
+ </>
+ )*/}
+ <DiscussionList discussions={discussions} user={user?.id} />
+ </div>
+ );
+};
diff --git a/client/src/ui/Note.js b/client/src/ui/Note.js
new file mode 100644
index 0000000..16b31cb
--- /dev/null
+++ b/client/src/ui/Note.js
@@ -0,0 +1,249 @@
+import { h } from 'preact';
+import { useRef, useLayoutEffect } from 'preact/hooks';
+import ColorHash from 'color-hash';
+import { formatRelative } from 'date-fns';
+import cn from 'classnames';
+import css from './css';
+import { Attachment } from './Attachment';
+
+const now = new Date();
+const Time = ({ time }) => {
+ const dt = new Date(time);
+ return formatRelative(dt, now);
+};
+
+css`
+.flex-space {
+ flex: 1;
+ margin: 0;
+}
+`;
+const Space = (props) => <div class="flex-space" {...props} />;
+
+css`
+section > header {
+ display: flex;
+ flex-direction: row;
+
+ height: 1.75rem;
+
+ color: var(--theme-header-fg);
+ background: var(--theme-header-bg);
+}
+
+section > header.small {
+ height: 0.5rem;
+}
+
+section > header > * {
+ margin: 0 0.35rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.75rem;
+}
+
+section > header > .avatar {
+ height: 1.25rem;
+ width: 1.25rem;
+ flex: 0 0 auto;
+ margin: 0.25rem;
+ border-radius: 0.2rem;
+
+ font-size: 1.25rem;
+ font-weight: bold;
+ line-height: 1.25rem;
+ text-align: center;
+ text-decoration: none;
+ background: white;
+ color: black;
+}
+section > header > .avatar img {
+ width: inherit;
+ height: inherit;
+}
+section > header > .collapse {
+ height: 1rem;
+ width: 1rem;
+ flex: 0 0 auto;
+ margin: 0.3rem;
+ border-radius: 0.2rem;
+ border: 0.05rem solid var(--theme-header-fg);
+
+ line-height: 0.8rem;
+ text-align: center;
+ text-decoration: none;
+ text-weight: bold;
+
+ transition: background 0.3s;
+}
+section > header > .collapse:hover {
+ background: var(--theme-note-bg);
+}
+
+section > header > .user {
+ flex: 0 0.5 auto;
+}
+
+section > header > .time {
+ flex: 0 0 auto;
+ font-size: 0.8em;
+ color: #363636;
+}
+`;
+const Header = ({ id, user, published, collapsed, onCollapse }) => {
+ const username = user.name || user.preferredUsername;
+
+ const onClick = onCollapse && ((e) => {
+ e.preventDefault();
+ onCollapse(id);
+ });
+
+ return (
+ <header>
+ <a class="avatar" href={user.id}>
+ {user.icon
+ ? <img src={new URL(user.icon.url, user.id)} />
+ : "?"}
+ </a>
+ <span class="user">{username}</span>
+ <Space />
+ <a class="time" href={id}>
+ <Time time={published} />
+ </a>
+ <a class="collapse" href="#" onClick={onClick}>
+ {collapsed ? '+' : '-'}
+ </a>
+ </header>
+ );
+};
+
+css`
+section {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+
+ width: 15rem;
+
+ overflow: hidden;
+ color: var(--theme-note-fg);
+ background: var(--theme-note-bg);
+ box-shadow: rgba(0,0,0, 0.7) 2px 4px 5px;
+
+ border-radius: 0.3rem;
+}
+
+section.type-Tombstone {
+ color: var(--theme-note-bg);
+ background: var(--theme-note-fg);
+}
+
+section > main {
+ position: relative;
+ padding: 0.5rem;
+ overflow: hidden;
+ font-family: serif;
+}
+
+section > main > p:first-child {
+ margin-top: 0;
+}
+section > main > p:last-child {
+ margin-bottom: 0;
+}
+
+section.tombstone > main {
+ font-family: inherit;
+}
+
+section.hidden {
+ display: none;
+}
+
+section.ellipsis > main {
+ max-height: 7em;
+}
+section.ellipsis > .attachment {
+ display: none;
+}
+section.ellipsis > main::after {
+ position: absolute;
+ display: block;
+ content: '';
+
+ pointer-events: none;
+
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 1.25em;
+ background: linear-gradient(0deg, var(--theme-note-bg) 5%, var(--theme-note-bg-trans) 100%);
+}
+section.type-Tombstone.ellipsis > main::after {
+ background: linear-gradient(0deg, var(--theme-note-fg) 5%, var(--theme-note-fg-trans) 100%);
+}
+
+section.collapsed > main {
+ max-height: 2em;
+}
+`;
+
+const colors = new ColorHash({ lightness: 0.8 });
+export const Note = ({
+ id, type, attributedTo, published, content, attachment,
+ smallHeader = false, collapsed = false, ellipsis = false, position,
+ onCollapse, onSelect, onSize,
+}) => {
+ ellipsis ||= collapsed;
+ attachment ||= [];
+ if (!Array.isArray(attachment))
+ attachment = [attachment];
+
+ const backgroundColor = attributedTo.color || colors.hex(attributedTo.id);
+
+ const onClick = onSelect && ((e) => {
+ e.preventDefault();
+ onSelect(id);
+ });
+
+ const root = useRef(null);
+ onSize && useLayoutEffect(() => {
+ onSize(id, root.current.offsetWidth, root.current.offsetHeight);
+ });
+
+ return (
+ <section
+ style={{
+ '--theme-header-bg': backgroundColor,
+ 'position': position && 'absolute',
+ 'left': position && `${position[0] - root.current.offsetWidth / 2}px`,
+ 'top': position && `${position[1] - root.current.offsetHeight / 2}px`,
+ 'visibility': onSize && !position ? 'hidden' : 'visible',
+ }}
+ class={cn(
+ `type-${type}`,
+ collapsed && 'collapsed',
+ ellipsis && 'ellipsis',
+ )}
+ data-id={id}
+ ref={root}
+ >
+ {smallHeader
+ ? <header className="small" onClick={onCollapse} />
+ : <Header
+ id={id}
+ user={attributedTo}
+ published={published}
+ collapsed={collapsed}
+ onCollapse={onCollapse}
+ />
+ }
+ <main
+ innerHTML={content}
+ onClick={onClick}
+ />
+ {attachment.map(a => <Attachment {...a} />)}
+ </section>
+ );
+};
diff --git a/client/src/ui/Selection.js b/client/src/ui/Selection.js
new file mode 100644
index 0000000..d8dfa88
--- /dev/null
+++ b/client/src/ui/Selection.js
@@ -0,0 +1,93 @@
+import { h } from 'preact';
+import { useState, useEffect } from 'preact/hooks';
+import cn from 'classnames';
+import css from './css';
+import { Note } from './Note';
+
+css`
+.drawer {
+ position: fixed;
+ inset: 0;
+ top: auto;
+
+ z-index: 200;
+ background: var(--theme-note-fg);
+
+ /* wtf webkit */
+ -webkit-backface-visibility: hidden;
+}
+
+.drawer .handle {
+ height: 1.5rem;
+ margin-bottom: 1rem;
+}
+
+.drawer .handle button {
+ display: block;
+
+ height: 1rem;
+ line-height: 1rem;
+ margin: -1rem auto 0;
+ padding: 0.25rem;
+ background: var(--theme-note-bg);
+ border-radius: 0.2rem;
+}
+
+.drawer .scroller {
+ display: flex;
+ overflow: auto;
+ width: 100%;
+
+ transition: max-height 0.3s;
+}
+
+.drawer .contents {
+ display: flex;
+
+ align-items: flex-start;
+
+ gap: 1rem;
+ padding: 0 1.5rem;
+}
+
+.drawer .contents > * {
+ flex: 0 0 auto;
+}
+`;
+
+export const Drawer = ({ height, children }) => {
+ const [ hidden, setHidden ] = useState(true);
+
+ return (
+ <div class="drawer">
+ <div class="handle">
+ <button onClick={() => setHidden(!hidden)}>
+ ###
+ </button>
+ </div>
+ <div
+ class="scroller"
+ style={{
+ height,
+ 'max-height': hidden ? '1rem' : height,
+ }}
+ >
+ <div class="contents">
+ {children}
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export const Selection = ({ items, toggleSelected }) => (
+ <Drawer height="8.55rem" >
+ {items.map((item) => (
+ <Note
+ {...item}
+ ellipsis
+ onSelect={toggleSelected}
+ />
+ ))}
+ </Drawer>
+);
diff --git a/client/src/ui/css.js b/client/src/ui/css.js
new file mode 100644
index 0000000..9089f1e
--- /dev/null
+++ b/client/src/ui/css.js
@@ -0,0 +1,13 @@
+export default ([ code ]) => {
+ const style = document.createElement('style');
+ document.head.appendChild(style);
+ style.appendChild(document.createTextNode(''));
+
+ let i = 0;
+ for (const v of code.split('}')) {
+ if (v.indexOf('{') < 0) continue;
+ style.sheet.insertRule(v + '}', i++);
+ }
+
+ return style;
+};
diff --git a/client/src/ui/index.js b/client/src/ui/index.js
new file mode 100644
index 0000000..f9ebf7f
--- /dev/null
+++ b/client/src/ui/index.js
@@ -0,0 +1,10 @@
+import './theme';
+import { Discussion } from './Discussion';
+import { Selection } from './Selection';
+import { Menu } from './Menu';
+
+export {
+ Discussion,
+ Selection,
+ Menu,
+};
diff --git a/client/src/ui/theme.js b/client/src/ui/theme.js
new file mode 100644
index 0000000..8977791
--- /dev/null
+++ b/client/src/ui/theme.js
@@ -0,0 +1,39 @@
+import css from './css';
+
+css`
+html {
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ --theme-backdrop: #1e1e1e;
+ --theme-fg: #eeeeee;
+ --theme-note-bg: #eeeeee;
+ --theme-note-fg: #363636;
+ --theme-note-bg-trans: rgba(238,238,238,0);
+ --theme-note-fg-trans: rgba(54,54,54,0);
+ --theme-header-fg: #121212;
+
+ --theme-title-fg: #eeeeee;
+ --theme-title-bg: #363636;
+}
+
+body.darktheme {
+ --theme-backdrop: #1e1e1e;
+ --theme-note-bg: #363636;
+ --theme-note-fg: #eeeeee;
+ --theme-note-bg-trans: rgba(54,54,54,0);
+ --theme-note-fg-trans: rgba(238,238,238,0);
+ --theme-header-fg: #121212;
+}
+
+body {
+ color: var(--theme-fg);
+ background: var(--theme-backdrop);
+ font-family: sans-serif;
+
+ margin: 0;
+ padding: 0;
+}
+`;
diff --git a/client/src/viz.es.js b/client/src/viz.es.js
new file mode 100644
index 0000000..56f6a98
--- /dev/null
+++ b/client/src/viz.es.js
@@ -0,0 +1,194 @@
+/*
+Viz.js 2.1.2 (Graphviz 2.40.1, Expat {{EXPAT_VERSION}}, Emscripten 1.37.36)
+Copyright (c) 2014-2018 Michael Daines
+Licensed under MIT license
+
+This distribution contains other software in object code form:
+
+Graphviz
+Licensed under Eclipse Public License - v 1.0
+http://www.graphviz.org
+
+Expat
+Copyright (c) 1998, 1999, 2000 Thai Open Source Software Center Ltd and Clark Cooper
+Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006 Expat maintainers.
+Licensed under MIT license
+http://www.libexpat.org
+
+zlib
+Copyright (C) 1995-2013 Jean-loup Gailly and Mark Adler
+http://www.zlib.net/zlib_license.html
+*/
+var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) {
+ return typeof obj;
+} : function (obj) {
+ return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
+};
+
+var classCallCheck = function (instance, Constructor) {
+ if (!(instance instanceof Constructor)) {
+ throw new TypeError("Cannot call a class as a function");
+ }
+};
+
+var createClass = function () {
+ function defineProperties(target, props) {
+ for (var i = 0; i < props.length; i++) {
+ var descriptor = props[i];
+ descriptor.enumerable = descriptor.enumerable || false;
+ descriptor.configurable = true;
+ if ("value" in descriptor) descriptor.writable = true;
+ Object.defineProperty(target, descriptor.key, descriptor);
+ }
+ }
+
+ return function (Constructor, protoProps, staticProps) {
+ if (protoProps) defineProperties(Constructor.prototype, protoProps);
+ if (staticProps) defineProperties(Constructor, staticProps);
+ return Constructor;
+ };
+}();
+
+var _extends = Object.assign || function (target) {
+ for (var i = 1; i < arguments.length; i++) {
+ var source = arguments[i];
+
+ for (var key in source) {
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
+ target[key] = source[key];
+ }
+ }
+ }
+
+ return target;
+};
+
+var WorkerWrapper = function () {
+ function WorkerWrapper(worker) {
+ var _this = this;
+
+ classCallCheck(this, WorkerWrapper);
+
+ this.worker = worker;
+ this.listeners = [];
+ this.nextId = 0;
+
+ this.worker.addEventListener('message', function (event) {
+ var id = event.data.id;
+ var error = event.data.error;
+ var result = event.data.result;
+
+ _this.listeners[id](error, result);
+ delete _this.listeners[id];
+ });
+ }
+
+ createClass(WorkerWrapper, [{
+ key: 'render',
+ value: function render(src, options) {
+ var _this2 = this;
+
+ return new Promise(function (resolve, reject) {
+ var id = _this2.nextId++;
+
+ _this2.listeners[id] = function (error, result) {
+ if (error) {
+ reject(new Error(error.message, error.fileName, error.lineNumber));
+ return;
+ }
+ resolve(result);
+ };
+
+ _this2.worker.postMessage({ id: id, src: src, options: options });
+ });
+ }
+ }]);
+ return WorkerWrapper;
+}();
+
+var ModuleWrapper = function ModuleWrapper(module, render) {
+ classCallCheck(this, ModuleWrapper);
+
+ var instance = module();
+ this.render = function (src, options) {
+ return new Promise(function (resolve, reject) {
+ try {
+ resolve(render(instance, src, options));
+ } catch (error) {
+ reject(error);
+ }
+ });
+ };
+};
+
+// https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
+
+var Viz = function () {
+ function Viz() {
+ var _ref3 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
+ workerURL = _ref3.workerURL,
+ worker = _ref3.worker,
+ Module = _ref3.Module,
+ render = _ref3.render;
+
+ classCallCheck(this, Viz);
+
+ if (typeof workerURL !== 'undefined') {
+ this.wrapper = new WorkerWrapper(new Worker(workerURL));
+ } else if (typeof worker !== 'undefined') {
+ this.wrapper = new WorkerWrapper(worker);
+ } else if (typeof Module !== 'undefined' && typeof render !== 'undefined') {
+ this.wrapper = new ModuleWrapper(Module, render);
+ } else if (typeof Viz.Module !== 'undefined' && typeof Viz.render !== 'undefined') {
+ this.wrapper = new ModuleWrapper(Viz.Module, Viz.render);
+ } else {
+ throw new Error('Must specify workerURL or worker option, Module and render options, or include one of full.render.js or lite.render.js after viz.js.');
+ }
+ }
+
+ createClass(Viz, [{
+ key: 'renderString',
+ value: function renderString(src) {
+ var _ref4 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {},
+ _ref4$format = _ref4.format,
+ format = _ref4$format === undefined ? 'svg' : _ref4$format,
+ _ref4$engine = _ref4.engine,
+ engine = _ref4$engine === undefined ? 'dot' : _ref4$engine,
+ _ref4$files = _ref4.files,
+ files = _ref4$files === undefined ? [] : _ref4$files,
+ _ref4$images = _ref4.images,
+ images = _ref4$images === undefined ? [] : _ref4$images,
+ _ref4$yInvert = _ref4.yInvert,
+ yInvert = _ref4$yInvert === undefined ? false : _ref4$yInvert,
+ _ref4$nop = _ref4.nop,
+ nop = _ref4$nop === undefined ? 0 : _ref4$nop;
+
+ for (var i = 0; i < images.length; i++) {
+ files.push({
+ path: images[i].path,
+ data: '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n<svg width="' + images[i].width + '" height="' + images[i].height + '"></svg>'
+ });
+ }
+
+ return this.wrapper.render(src, { format: format, engine: engine, files: files, images: images, yInvert: yInvert, nop: nop });
+ }
+ }, {
+ key: 'renderJSONObject',
+ value: function renderJSONObject(src) {
+ var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+ var format = options.format;
+
+
+ if (format !== 'json' || format !== 'json0') {
+ format = 'json';
+ }
+
+ return this.renderString(src, _extends({}, options, { format: format })).then(function (str) {
+ return JSON.parse(str);
+ });
+ }
+ }]);
+ return Viz;
+}();
+
+export default Viz;