aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--client/src/actions.js44
-rw-r--r--client/src/graph.js132
-rw-r--r--client/src/index.js108
-rw-r--r--client/src/ui/Menu.js6
-rw-r--r--client/src/ui/Selection.js55
-rw-r--r--client/src/user.js68
6 files changed, 265 insertions, 148 deletions
diff --git a/client/src/actions.js b/client/src/actions.js
new file mode 100644
index 0000000..e1a3611
--- /dev/null
+++ b/client/src/actions.js
@@ -0,0 +1,44 @@
+import { h, createContext } from 'preact';
+import { useRef, useState, useCallback, useContext } from 'preact/hooks';
+import { API_PREFIX } from './config';
+
+export const fetchJSON = async (url, method='GET', data=undefined) => {
+ const res = await fetch(new URL(url, API_PREFIX).href, {
+ method,
+ credentials: 'include',
+ headers: { 'content-type': data && 'application/json' },
+ body: data && JSON.stringify(data),
+ });
+
+ if (res.status !== 200)
+ throw new Error("wrong status");
+
+ return res.json();
+};
+
+const DispatchContext = createContext((action) => {
+ throw new Error(`Unhandled action: ${action.type}`);
+});
+
+export const useDispatcher = ({ reducer, initialState }) => {
+ const parentDispatch = useDispatch();
+
+ const ref = useRef();
+ const [state, setState] = useState(initialState);
+ ref.current = state;
+
+ const dispatch = useCallback((action) => {
+ const update = reducer(action);
+ if (!update) return parentDispatch(action);
+
+ return update.then(f => setState(f(ref.current)));
+ }, [ref, reducer]);
+
+ return {
+ state,
+ dispatch,
+ DispatchProvider: ({ children }) => <DispatchContext.Provider value={dispatch} children={children} />,
+ };
+};
+
+export const useDispatch = () => useContext(DispatchContext);
diff --git a/client/src/graph.js b/client/src/graph.js
index 7fe842c..1b27ea1 100644
--- a/client/src/graph.js
+++ b/client/src/graph.js
@@ -1,5 +1,7 @@
-import { Component } from 'preact';
+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}`;
@@ -20,36 +22,41 @@ const context = [
{
replies: { '@id': 'as:replies', '@container': '@set' },
inReplyTo: { '@id': 'as:inReplyTo', '@container': '@set' },
- items: { '@id': 'as:items', '@type': '@id', '@container': '@set' },
+ items: { '@id': 'as:items', '@container': '@set' },
},
];
-export class GraphContainer extends Component {
- state = {
- loading: true,
- name: "loading…",
- items: {},
- error: null,
- };
+export const GraphContainer = ({ url, mode, children }) => {
+ const api = useMemo(() => {
+ const API = mode === "mastodon" ? GraphAPIMastodon : GraphAPIJSONLD;
+ return new API(url);
+ }, [url, mode]);
- cache = {};
+ const { state, dispatch, DispatchProvider } = useDispatcher({
+ reducer: api.reducer,
+ initialState: {
+ loading: true,
+ name: "loading…",
+ items: {},
+ error: null,
+ },
+ });
- componentDidMount() {
- this.componentDidUpdate({});
- }
+ useEffect(() => dispatch({ type: 'reload' }), []);
+
+ return (
+ <DispatchProvider>
+ {children(state)}
+ </DispatchProvider>
+ );
+};
- componentDidUpdate(prevProps) {
- if (prevProps.url === this.props.url) return;
+class GraphAPI {
+ cache = {};
- this.loadData(this.props.url)
- .catch(error => {
- console.error(`Error loading ${this.props.url}:`, error);
- this.setState({
- loading: false,
- error,
- });
- });
- }
+ constructor(url) {
+ this.url = url;
+ }
@wrapCache
async loadUser(id) {
@@ -79,13 +86,21 @@ export class GraphContainer extends Component {
}
}
- render() {
- window.state = this.state;
- return this.props.render(this.state);
+ reducer = (action) => {
+ switch (action.type) {
+ case 'reload':
+ return this.loadData();
+
+ case 'reply':
+ return this.reply(action);
+
+ default:
+ return undefined;
+ }
}
}
-export class GraphContainerMastodon extends GraphContainer {
+export class GraphAPIMastodon extends GraphAPI {
@wrapCache
async loadCollection(id, ...args) {
const collection = await jsonld.compact(cors(id), context);
@@ -154,11 +169,11 @@ export class GraphContainerMastodon extends GraphContainer {
return item;
}
- async loadData(url) {
+ async loadData() {
const items = {};
- const root = await this.loadNote(url, items, []);
+ const root = await this.loadNote(this.url, items, []);
- this.setState({
+ return () => ({
name: root.name || root.content,
items,
loading: false,
@@ -166,9 +181,9 @@ export class GraphContainerMastodon extends GraphContainer {
}
}
-export class GraphContainerJSONLD extends GraphContainer {
- async loadData(url) {
- const response = await fetch(url, {
+class GraphAPIJSONLD extends GraphAPI {
+ async loadData() {
+ const response = await fetch(this.url, {
headers: { 'Accept': 'application/ld+json, application/json' },
credentials: 'include',
});
@@ -197,9 +212,56 @@ export class GraphContainerJSONLD extends GraphContainer {
discussion.items = indexedItems;
- this.setState({
+ 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 };
+ }
+ }
}
diff --git a/client/src/index.js b/client/src/index.js
index 0ca8b1c..f610cd4 100644
--- a/client/src/index.js
+++ b/client/src/index.js
@@ -2,7 +2,7 @@ import 'preact/debug';
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import { h, Fragment, Component, render } from 'preact';
-import { GraphContainerJSONLD, GraphContainerMastodon } from './graph';
+import { GraphContainer } from './graph';
import { Menu, Discussion, Selection } from './ui';
import { UserContainer } from './user';
@@ -82,69 +82,69 @@ class CollapseContainer extends Component {
}
}
-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') || search.has('note') || search.has('graph')) {
- const Container = search.has('document') ? GraphContainerJSONLD : GraphContainerMastodon;
+ const mode = search.has('document') ? "jsonld" : "mastodon";
const url = search.get('document') || search.get('note') || search.get('graph');
app = (
<UserContainer>
- <Container
+ <GraphContainer
url={url}
- render={graphRender}
- />
+ mode={mode}
+ >
+ {({ 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}
+ />
+ </>
+ )} />
+ );
+ }}
+ </GraphContainer>
</UserContainer>
);
} else {
diff --git a/client/src/ui/Menu.js b/client/src/ui/Menu.js
index f3e912d..a91b188 100644
--- a/client/src/ui/Menu.js
+++ b/client/src/ui/Menu.js
@@ -1,7 +1,8 @@
import { h, Fragment } from 'preact';
import { useState } from 'preact/hooks';
import cn from 'classnames';
-import { useUser, useUserCtx } from '../user';
+import { useDispatch } from '../actions';
+import { useUser } from '../user';
import css from './css';
css`
@@ -93,7 +94,8 @@ div.menu div.user {
`;
const UserMenu = () => {
const [showLogin, setLogin] = useState(false);
- const { state: { user }, dispatch } = useUserCtx();
+ const { user } = useUser();
+ const dispatch = useDispatch();
if (showLogin) {
return (
diff --git a/client/src/ui/Selection.js b/client/src/ui/Selection.js
index d8dfa88..5e2fe66 100644
--- a/client/src/ui/Selection.js
+++ b/client/src/ui/Selection.js
@@ -1,6 +1,7 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import cn from 'classnames';
+import { useDispatch } from '../actions';
import css from './css';
import { Note } from './Note';
@@ -19,7 +20,6 @@ css`
.drawer .handle {
height: 1.5rem;
- margin-bottom: 1rem;
}
.drawer .handle button {
@@ -37,6 +37,7 @@ css`
display: flex;
overflow: auto;
width: 100%;
+ padding: 1rem 0;
transition: max-height 0.3s;
}
@@ -53,6 +54,16 @@ css`
.drawer .contents > * {
flex: 0 0 auto;
}
+
+.drawer .contents > .reply {
+ display: flex;
+ flex-direction: column;
+ align-self: stretch;
+}
+
+.drawer .contents > .reply textarea {
+ flex: 1 1;
+}
`;
export const Drawer = ({ height, children }) => {
@@ -80,14 +91,34 @@ export const Drawer = ({ height, children }) => {
);
};
-export const Selection = ({ items, toggleSelected }) => (
- <Drawer height="8.55rem" >
- {items.map((item) => (
- <Note
- {...item}
- ellipsis
- onSelect={toggleSelected}
- />
- ))}
- </Drawer>
-);
+export const Selection = ({ items, toggleSelected }) => {
+ const [reply, setReply] = useState('');
+ const dispatch = useDispatch();
+
+ return (
+ <Drawer height="8.55rem" >
+ {items.map((item) => (
+ <Note
+ {...item}
+ ellipsis
+ onSelect={toggleSelected}
+ />
+ ))}
+ {!!items.length && (
+ <div class="reply">
+ <textarea value={reply} onChange={(e) => setReply(e.target.value)} />
+ <button onClick={() => {
+ dispatch({
+ type: 'reply',
+ content: reply,
+ to: items.map(i => i.id),
+ })
+ .then(() => setReply(''));
+ }}>
+ reply
+ </button>
+ </div>
+ )}
+ </Drawer>
+ );
+};
diff --git a/client/src/user.js b/client/src/user.js
index dfa6384..340a4e3 100644
--- a/client/src/user.js
+++ b/client/src/user.js
@@ -1,71 +1,49 @@
import { h, createContext } from 'preact';
-import { useRef, useState, useCallback, useEffect, useContext } from 'preact/hooks';
-import { API_PREFIX } from './config';
-
-const fetchJSON = async (url, method='GET', data=undefined) => {
- const res = await fetch(API_PREFIX + url, {
- method,
- credentials: 'include',
- headers: { 'content-type': data && 'application/json' },
- body: data && JSON.stringify(data),
- });
-
- if (res.status !== 200)
- throw new Error("wrong status");
-
- return res.json();
-};
+import { useEffect, useContext } from 'preact/hooks';
+import { useDispatcher, fetchJSON } from './actions';
const initialState = { user: null, discussions: [] };
-const reducer = async (action, ref) => {
+const reducer = (action) => {
switch (action.type) {
case 'login': {
const { username, password } = action;
- await fetchJSON('/discdag/login', 'POST', { username, password });
- return await reducer({ type: 'refresh' }, ref);
+ return fetchJSON('/discdag/login', 'POST', { username, password })
+ .then(() => reducer({ type: 'refresh' }));
}
case 'logout': {
- await fetchJSON('/discdag/logout', 'POST');
- return await reducer({ type: 'refresh' }, ref);
+ return fetchJSON('/discdag/logout', 'POST')
+ .then(() => reducer({ type: 'refresh' }));
}
case 'refresh': {
- const { user, discussions } = await fetchJSON('/discdag/list');
- return { ...ref.current, user, discussions };
+ return fetchJSON('/discdag/list')
+ .then(({ user, discussions }) =>
+ (state) => ({ ...state, user, discussions })
+ );
}
default:
- throw new Error('Unexpected action');
+ return undefined;
}
};
-const UserContext = createContext({
- state: initialState,
- dispatch: () => {
- throw new Error("no UserContext Provider!");
- },
-});
+const UserContext = createContext(initialState);
export const UserContainer = ({ children }) => {
- const ref = useRef();
- const [state, setState] = useState(initialState);
- ref.current = state;
-
- const dispatch = useCallback(async (action) => {
- const nextState = await reducer(action, ref);
- setState(nextState);
- }, [ref]);
-
+ const { state, dispatch, DispatchProvider } = useDispatcher({
+ reducer,
+ initialState,
+ });
useEffect(() => dispatch({ type: 'refresh' }), []);
return (
- <UserContext.Provider value={{ state, dispatch }}>
- {children}
- </UserContext.Provider>
+ <DispatchProvider>
+ <UserContext.Provider value={state}>
+ {children}
+ </UserContext.Provider>
+ </DispatchProvider>
);
};
-export const useUser = () => useContext(UserContext).state;
-export const useDispatch = () => useContext(UserContext).dispatch;
-export const useUserCtx = () => useContext(UserContext);
+export const useUser = () => useContext(UserContext);