From eb1e149f1e53aba3abce645a3d7fc982333f3956 Mon Sep 17 00:00:00 2001 From: s-ol Date: Mon, 24 Jan 2022 12:12:22 +0100 Subject: client: replying --- client/src/actions.js | 44 +++++++++++++++ client/src/graph.js | 132 +++++++++++++++++++++++++++++++++------------ client/src/index.js | 108 ++++++++++++++++++------------------- client/src/ui/Menu.js | 6 ++- client/src/ui/Selection.js | 55 ++++++++++++++----- client/src/user.js | 68 ++++++++--------------- 6 files changed, 265 insertions(+), 148 deletions(-) create mode 100644 client/src/actions.js (limited to 'client') 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 }) => , + }; +}; + +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 ( + + {children(state)} + + ); +}; - 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 ( -
-
loading...
-
- ); - } - - if (error) { - return ( -
-

error loading

-
-

{error.toString()}

-
-						
-							{error.stack}
-						
-					
-
-
- ); - } - - return ( - ( - <> - ( - - )} - /> - 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 = ( - + mode={mode} + > + {({ loading, error, name, items }) => { + if (loading) { + return ( +
+
loading...
+
+ ); + } + + if (error) { + return ( +
+

error loading

+
+

{error.toString()}

+
+										
+											{error.stack}
+										
+									
+
+
+ ); + } + + return ( + ( + <> + ( + + )} + /> + items[id])} + toggleSelected={toggleSelected} + /> + + )} /> + ); + }} +
); } 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 }) => ( - - {items.map((item) => ( - - ))} - -); +export const Selection = ({ items, toggleSelected }) => { + const [reply, setReply] = useState(''); + const dispatch = useDispatch(); + + return ( + + {items.map((item) => ( + + ))} + {!!items.length && ( +
+