diff options
| author | s-ol <s+removethis@s-ol.nu> | 2022-01-13 16:25:07 +0000 |
|---|---|---|
| committer | s-ol <s+removethis@s-ol.nu> | 2022-01-13 17:26:21 +0000 |
| commit | e065914a6fea70c25e117d8aaca466b59bcad5c6 (patch) | |
| tree | 9c003c80318427f4486691b84576233ad7742816 /src | |
| parent | use @container:@id in defualt graph (diff) | |
| download | fedidag-e065914a6fea70c25e117d8aaca466b59bcad5c6.tar.gz fedidag-e065914a6fea70c25e117d8aaca466b59bcad5c6.zip | |
Menu, DiscDAG loading
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.js | 3 | ||||
| -rw-r--r-- | src/graph.js | 205 | ||||
| -rw-r--r-- | src/index.js | 309 | ||||
| -rw-r--r-- | src/ui/Attachment.js | 3 | ||||
| -rw-r--r-- | src/ui/Discussion.js | 35 | ||||
| -rw-r--r-- | src/ui/Menu.js | 139 | ||||
| -rw-r--r-- | src/ui/Note.js | 28 | ||||
| -rw-r--r-- | src/ui/Selection.js | 21 | ||||
| -rw-r--r-- | src/ui/index.js | 2 | ||||
| -rw-r--r-- | src/ui/theme.js | 12 |
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; } `; - |
