aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authors-ol <s+removethis@s-ol.nu>2022-01-13 16:25:07 +0000
committers-ol <s+removethis@s-ol.nu>2022-01-13 17:26:21 +0000
commite065914a6fea70c25e117d8aaca466b59bcad5c6 (patch)
tree9c003c80318427f4486691b84576233ad7742816 /src
parentuse @container:@id in defualt graph (diff)
downloadfedidag-e065914a6fea70c25e117d8aaca466b59bcad5c6.tar.gz
fedidag-e065914a6fea70c25e117d8aaca466b59bcad5c6.zip
Menu, DiscDAG loading
Diffstat (limited to 'src')
-rw-r--r--src/config.js3
-rw-r--r--src/graph.js205
-rw-r--r--src/index.js309
-rw-r--r--src/ui/Attachment.js3
-rw-r--r--src/ui/Discussion.js35
-rw-r--r--src/ui/Menu.js139
-rw-r--r--src/ui/Note.js28
-rw-r--r--src/ui/Selection.js21
-rw-r--r--src/ui/index.js2
-rw-r--r--src/ui/theme.js12
10 files changed, 481 insertions, 276 deletions
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 0000000..91d6231
--- /dev/null
+++ b/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/src/graph.js b/src/graph.js
new file mode 100644
index 0000000..7fe842c
--- /dev/null
+++ b/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/src/index.js b/src/index.js
index 8cad259..a30dbf1 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,208 +1,9 @@
import 'preact/debug';
import 'core-js/stable';
import 'regenerator-runtime/runtime';
-import * as jsonld from 'jsonld';
import { h, Fragment, Component, render } from 'preact';
-import { Discussion, Selection } from './ui';
-
-const context = [
- 'https://www.w3.org/ns/activitystreams',
- {
- replies: { '@id': 'as:replies', '@container': '@set' },
- inReplyTo: { '@id': 'as:inReplyTo', '@container': '@set' },
- },
-];
-
-
-const corsPrefix = process.env.CORS_PREFIX || `${location.origin}/remote`;
-console.log(corsPrefix);
-const cors = (url) => `${corsPrefix}/${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;
-};
-
-class GraphContainer extends Component {
- state = {
- name: "loading…",
- items: {},
- error: null,
- };
-
- cache = {};
-
- constructor(props) {
- super(props);
-
- if (this.props.graph.startsWith('http'))
- this.loadDataMastodon(this.props.graph)
- .catch(error => {
- console.error(`Error loading Mastodon graph ${this.props.graph}:`, error);
- this.setState({
- name: "error loading",
- error,
- });
- });
- else
- this.loadData(this.props.graph)
- .catch(error => {
- console.error("Error loading default graph:", error);
- this.setState({
- name: "error loading",
- error,
- });
- });
- }
-
- @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 loadDataMastodon(url) {
- const items = {};
- const root = await this.loadNote(url, items, []);
-
- this.setState({
- name: root.name || root.content,
- items,
- });
- }
-
- async loadData(url) {
- const response = await fetch(url, {
- headers: { 'Accept': 'application/json' },
- });
- 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);
- }
-
- @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);
- }
-}
+import { GraphContainerJSONLD, GraphContainerMastodon } from './graph';
+import { Menu, Discussion, Selection } from './ui';
class SelectionContainer extends Component {
state = {
@@ -280,46 +81,82 @@ class CollapseContainer extends Component {
}
}
-const search = new URLSearchParams(window.location.search);
-const graph = search.has('graph') ? search.get('graph') : 'lib/graph.json';
-const app = (
- <GraphContainer graph={graph} render={({ name, items, error }) => {
- if (error) {
- return (
- <article>
- <h1>{name}</h1>
+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>
- </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}
- />
- </>
- )} />
+ </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/src/ui/Attachment.js b/src/ui/Attachment.js
index 829dc8a..3c07ee5 100644
--- a/src/ui/Attachment.js
+++ b/src/ui/Attachment.js
@@ -5,8 +5,7 @@ css`
.attachment {
margin: 1rem;
- width: 18rem;
- height: 10rem;
+ height: 7rem;
object-fit: contain;
}
diff --git a/src/ui/Discussion.js b/src/ui/Discussion.js
index b0cdb4f..5fef693 100644
--- a/src/ui/Discussion.js
+++ b/src/ui/Discussion.js
@@ -7,24 +7,44 @@ import { Note } from './Note';
css`
article {
- display: flex;
- flex-direction: column;
+ position: absolute;
+ inset: 0;
overflow: auto;
- flex: 1 1 auto;
- padding: 0 2rem;
}
article > h1 {
- flex: 0 0 auto;
+ 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;
}
`;
@@ -53,7 +73,10 @@ export const Discussion = ({
return (
<article>
- <h1>{name}</h1>
+ <h1>
+ <span>{name}</span>
+ <a class="back" href="?">back</a>
+ </h1>
<div>
<Links width={width} height={height} links={links} />
<div>
diff --git a/src/ui/Menu.js b/src/ui/Menu.js
new file mode 100644
index 0000000..6f32280
--- /dev/null
+++ b/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/src/ui/Note.js b/src/ui/Note.js
index 63905e4..16b31cb 100644
--- a/src/ui/Note.js
+++ b/src/ui/Note.js
@@ -25,34 +25,34 @@ section > header {
display: flex;
flex-direction: row;
- height: 2.5rem;
+ height: 1.75rem;
color: var(--theme-header-fg);
background: var(--theme-header-bg);
}
section > header.small {
- height: 0.75rem;
+ height: 0.5rem;
}
section > header > * {
- margin: 0 0.5rem;
+ margin: 0 0.35rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- line-height: 2.5rem;
+ line-height: 1.75rem;
}
section > header > .avatar {
- height: 2rem;
- width: 2rem;
+ height: 1.25rem;
+ width: 1.25rem;
flex: 0 0 auto;
margin: 0.25rem;
- border-radius: 0.3rem;
+ border-radius: 0.2rem;
- font-size: 1.5rem;
+ font-size: 1.25rem;
font-weight: bold;
- line-height: 2rem;
+ line-height: 1.25rem;
text-align: center;
text-decoration: none;
background: white;
@@ -66,8 +66,8 @@ section > header > .collapse {
height: 1rem;
width: 1rem;
flex: 0 0 auto;
- margin: 0.6rem 0.5rem;
- border-radius: 0.3rem;
+ margin: 0.3rem;
+ border-radius: 0.2rem;
border: 0.05rem solid var(--theme-header-fg);
line-height: 0.8rem;
@@ -124,14 +124,14 @@ section {
flex-direction: column;
justify-content: space-between;
- width: 22rem;
+ 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.4rem;
+ border-radius: 0.3rem;
}
section.type-Tombstone {
@@ -141,7 +141,7 @@ section.type-Tombstone {
section > main {
position: relative;
- padding: 0.75rem;
+ padding: 0.5rem;
overflow: hidden;
font-family: serif;
}
diff --git a/src/ui/Selection.js b/src/ui/Selection.js
index d6e62ab..d8dfa88 100644
--- a/src/ui/Selection.js
+++ b/src/ui/Selection.js
@@ -6,28 +6,31 @@ import { Note } from './Note';
css`
.drawer {
- background: var(--theme-note-fg);
+ position: fixed;
+ inset: 0;
+ top: auto;
- flex: 0 0 auto;
+ z-index: 200;
+ background: var(--theme-note-fg);
/* wtf webkit */
-webkit-backface-visibility: hidden;
}
.drawer .handle {
- height: 2rem;
+ height: 1.5rem;
margin-bottom: 1rem;
}
.drawer .handle button {
display: block;
- height: 2rem;
- line-height: 2rem;
+ height: 1rem;
+ line-height: 1rem;
margin: -1rem auto 0;
padding: 0.25rem;
background: var(--theme-note-bg);
- border-radius: 0.4rem;
+ border-radius: 0.2rem;
}
.drawer .scroller {
@@ -44,7 +47,7 @@ css`
align-items: flex-start;
gap: 1rem;
- padding: 0 2rem;
+ padding: 0 1.5rem;
}
.drawer .contents > * {
@@ -66,7 +69,7 @@ export const Drawer = ({ height, children }) => {
class="scroller"
style={{
height,
- 'max-height': hidden ? '0' : height,
+ 'max-height': hidden ? '1rem' : height,
}}
>
<div class="contents">
@@ -78,7 +81,7 @@ export const Drawer = ({ height, children }) => {
};
export const Selection = ({ items, toggleSelected }) => (
- <Drawer height="12rem" style={{ }} >
+ <Drawer height="8.55rem" >
{items.map((item) => (
<Note
{...item}
diff --git a/src/ui/index.js b/src/ui/index.js
index fa831e2..f9ebf7f 100644
--- a/src/ui/index.js
+++ b/src/ui/index.js
@@ -1,8 +1,10 @@
import './theme';
import { Discussion } from './Discussion';
import { Selection } from './Selection';
+import { Menu } from './Menu';
export {
Discussion,
Selection,
+ Menu,
};
diff --git a/src/ui/theme.js b/src/ui/theme.js
index 58c8397..8977791 100644
--- a/src/ui/theme.js
+++ b/src/ui/theme.js
@@ -2,7 +2,6 @@ import css from './css';
css`
html {
- font-size: 11px;
margin: 0;
padding: 0;
}
@@ -15,6 +14,9 @@ body {
--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 {
@@ -33,13 +35,5 @@ body {
margin: 0;
padding: 0;
- width: 100vw;
- height: 100vh;
- overflow: hidden;
-
- display: flex;
- flex-direction: column;
- justify-content: space-around;
}
`;
-