diff options
| author | s-ol <s+removethis@s-ol.nu> | 2022-01-24 11:12:22 +0000 |
|---|---|---|
| committer | s-ol <s+removethis@s-ol.nu> | 2022-01-24 11:12:22 +0000 |
| commit | eb1e149f1e53aba3abce645a3d7fc982333f3956 (patch) | |
| tree | aec938d23b379fd93eacf636e421429060e41b45 /client | |
| parent | server: ActivityStreams reply/link/unlink API (diff) | |
| download | fedidag-main.tar.gz fedidag-main.zip | |
Diffstat (limited to '')
| -rw-r--r-- | client/src/actions.js | 44 | ||||
| -rw-r--r-- | client/src/graph.js | 132 | ||||
| -rw-r--r-- | client/src/index.js | 108 | ||||
| -rw-r--r-- | client/src/ui/Menu.js | 6 | ||||
| -rw-r--r-- | client/src/ui/Selection.js | 55 | ||||
| -rw-r--r-- | client/src/user.js | 68 |
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); |
