client: replying
s-ol
1 year, 7 months ago
0 | import { h, createContext } from 'preact'; | |
1 | import { useRef, useState, useCallback, useContext } from 'preact/hooks'; | |
2 | import { API_PREFIX } from './config'; | |
3 | ||
4 | export const fetchJSON = async (url, method='GET', data=undefined) => { | |
5 | const res = await fetch(new URL(url, API_PREFIX).href, { | |
6 | method, | |
7 | credentials: 'include', | |
8 | headers: { 'content-type': data && 'application/json' }, | |
9 | body: data && JSON.stringify(data), | |
10 | }); | |
11 | ||
12 | if (res.status !== 200) | |
13 | throw new Error("wrong status"); | |
14 | ||
15 | return res.json(); | |
16 | }; | |
17 | ||
18 | const DispatchContext = createContext((action) => { | |
19 | throw new Error(`Unhandled action: ${action.type}`); | |
20 | }); | |
21 | ||
22 | export const useDispatcher = ({ reducer, initialState }) => { | |
23 | const parentDispatch = useDispatch(); | |
24 | ||
25 | const ref = useRef(); | |
26 | const [state, setState] = useState(initialState); | |
27 | ref.current = state; | |
28 | ||
29 | const dispatch = useCallback((action) => { | |
30 | const update = reducer(action); | |
31 | if (!update) return parentDispatch(action); | |
32 | ||
33 | return update.then(f => setState(f(ref.current))); | |
34 | }, [ref, reducer]); | |
35 | ||
36 | return { | |
37 | state, | |
38 | dispatch, | |
39 | DispatchProvider: ({ children }) => <DispatchContext.Provider value={dispatch} children={children} />, | |
40 | }; | |
41 | }; | |
42 | ||
43 | export const useDispatch = () => useContext(DispatchContext); |
0 | import { Component } from 'preact'; | |
0 | import { h } from 'preact'; | |
1 | import { useMemo, useEffect } from 'preact/hooks'; | |
1 | 2 | import * as jsonld from 'jsonld'; |
3 | import { useDispatcher, fetchJSON } from './actions'; | |
2 | 4 | import { CORS_PREFIX } from './config'; |
3 | 5 | |
4 | 6 | const cors = (url) => `${CORS_PREFIX}/${url}`; |
19 | 21 | { |
20 | 22 | replies: { '@id': 'as:replies', '@container': '@set' }, |
21 | 23 | inReplyTo: { '@id': 'as:inReplyTo', '@container': '@set' }, |
22 | items: { '@id': 'as:items', '@type': '@id', '@container': '@set' }, | |
24 | items: { '@id': 'as:items', '@container': '@set' }, | |
23 | 25 | }, |
24 | 26 | ]; |
25 | 27 | |
26 | export class GraphContainer extends Component { | |
27 | state = { | |
28 | loading: true, | |
29 | name: "loading…", | |
30 | items: {}, | |
31 | error: null, | |
32 | }; | |
33 | ||
28 | export const GraphContainer = ({ url, mode, children }) => { | |
29 | const api = useMemo(() => { | |
30 | const API = mode === "mastodon" ? GraphAPIMastodon : GraphAPIJSONLD; | |
31 | return new API(url); | |
32 | }, [url, mode]); | |
33 | ||
34 | const { state, dispatch, DispatchProvider } = useDispatcher({ | |
35 | reducer: api.reducer, | |
36 | initialState: { | |
37 | loading: true, | |
38 | name: "loading…", | |
39 | items: {}, | |
40 | error: null, | |
41 | }, | |
42 | }); | |
43 | ||
44 | useEffect(() => dispatch({ type: 'reload' }), []); | |
45 | ||
46 | return ( | |
47 | <DispatchProvider> | |
48 | {children(state)} | |
49 | </DispatchProvider> | |
50 | ); | |
51 | }; | |
52 | ||
53 | class GraphAPI { | |
34 | 54 | cache = {}; |
35 | 55 | |
36 | componentDidMount() { | |
37 | this.componentDidUpdate({}); | |
38 | } | |
39 | ||
40 | componentDidUpdate(prevProps) { | |
41 | if (prevProps.url === this.props.url) return; | |
42 | ||
43 | this.loadData(this.props.url) | |
44 | .catch(error => { | |
45 | console.error(`Error loading ${this.props.url}:`, error); | |
46 | this.setState({ | |
47 | loading: false, | |
48 | error, | |
49 | }); | |
50 | }); | |
51 | } | |
56 | constructor(url) { | |
57 | this.url = url; | |
58 | } | |
52 | 59 | |
53 | 60 | @wrapCache |
54 | 61 | async loadUser(id) { |
78 | 85 | } |
79 | 86 | } |
80 | 87 | |
81 | render() { | |
82 | window.state = this.state; | |
83 | return this.props.render(this.state); | |
88 | reducer = (action) => { | |
89 | switch (action.type) { | |
90 | case 'reload': | |
91 | return this.loadData(); | |
92 | ||
93 | case 'reply': | |
94 | return this.reply(action); | |
95 | ||
96 | default: | |
97 | return undefined; | |
98 | } | |
84 | 99 | } |
85 | 100 | } |
86 | 101 | |
87 | export class GraphContainerMastodon extends GraphContainer { | |
102 | export class GraphAPIMastodon extends GraphAPI { | |
88 | 103 | @wrapCache |
89 | 104 | async loadCollection(id, ...args) { |
90 | 105 | const collection = await jsonld.compact(cors(id), context); |
153 | 168 | return item; |
154 | 169 | } |
155 | 170 | |
156 | async loadData(url) { | |
171 | async loadData() { | |
157 | 172 | const items = {}; |
158 | const root = await this.loadNote(url, items, []); | |
159 | ||
160 | this.setState({ | |
173 | const root = await this.loadNote(this.url, items, []); | |
174 | ||
175 | return () => ({ | |
161 | 176 | name: root.name || root.content, |
162 | 177 | items, |
163 | 178 | loading: false, |
165 | 180 | } |
166 | 181 | } |
167 | 182 | |
168 | export class GraphContainerJSONLD extends GraphContainer { | |
169 | async loadData(url) { | |
170 | const response = await fetch(url, { | |
183 | class GraphAPIJSONLD extends GraphAPI { | |
184 | async loadData() { | |
185 | const response = await fetch(this.url, { | |
171 | 186 | headers: { 'Accept': 'application/ld+json, application/json' }, |
172 | 187 | credentials: 'include', |
173 | 188 | }); |
196 | 211 | |
197 | 212 | discussion.items = indexedItems; |
198 | 213 | |
199 | this.setState({ | |
214 | return () => ({ | |
200 | 215 | ...discussion, |
201 | 216 | loading: false, |
202 | 217 | }); |
203 | 218 | } |
219 | ||
220 | async reply({ content, to }) { | |
221 | const result = await fetchJSON(this.url, 'POST', { | |
222 | type: 'Create', | |
223 | object: { | |
224 | type: 'Note', | |
225 | inReplyTo: to, | |
226 | content, | |
227 | }, | |
228 | }); | |
229 | ||
230 | const changed = await jsonld.frame( | |
231 | result, | |
232 | { | |
233 | '@context': context, | |
234 | items: { | |
235 | context: { '@embed': '@never' }, | |
236 | replies: { '@embed': '@never' }, | |
237 | inReplyTo: { '@embed': '@never' }, | |
238 | attributedTo: { '@embed': '@always' }, | |
239 | }, | |
240 | }, | |
241 | { omitGraph: true } | |
242 | ); | |
243 | ||
244 | await Promise.all(changed.items.map(async (item) => { | |
245 | item.attributedTo = await this.loadUser(item.attributedTo); | |
246 | })); | |
247 | ||
248 | return (state) => { | |
249 | const items = {...state.items}; | |
250 | for (const note of changed.items) { | |
251 | items[note.id] = note; | |
252 | ||
253 | for (const { id } of note.inReplyTo) { | |
254 | if (items[id].replies.indexOf(note.id) > -1) continue; | |
255 | ||
256 | items[id] = { | |
257 | ...items[id], | |
258 | replies: [...items[id].replies, { id: note.id }], | |
259 | }; | |
260 | } | |
261 | } | |
262 | ||
263 | return { ...state, items }; | |
264 | } | |
265 | } | |
204 | 266 | } |
1 | 1 | import 'core-js/stable'; |
2 | 2 | import 'regenerator-runtime/runtime'; |
3 | 3 | import { h, Fragment, Component, render } from 'preact'; |
4 | import { GraphContainerJSONLD, GraphContainerMastodon } from './graph'; | |
4 | import { GraphContainer } from './graph'; | |
5 | 5 | import { Menu, Discussion, Selection } from './ui'; |
6 | 6 | import { UserContainer } from './user'; |
7 | 7 | |
81 | 81 | } |
82 | 82 | } |
83 | 83 | |
84 | const graphRender = ({ loading, error, name, items }) => { | |
85 | if (loading) { | |
86 | return ( | |
87 | <article> | |
88 | <div>loading...</div> | |
89 | </article> | |
90 | ); | |
91 | } | |
92 | ||
93 | if (error) { | |
94 | return ( | |
95 | <article> | |
96 | <h1>error loading</h1> | |
97 | <div> | |
98 | <p>{error.toString()}</p> | |
99 | <pre> | |
100 | <code> | |
101 | {error.stack} | |
102 | </code> | |
103 | </pre> | |
104 | </div> | |
105 | </article> | |
106 | ); | |
107 | } | |
108 | ||
109 | return ( | |
110 | <SelectionContainer render={({ selection, toggleSelected }) => ( | |
111 | <> | |
112 | <CollapseContainer | |
113 | items={items} | |
114 | render={({ collapsed, toggleCollapsed }) => ( | |
115 | <Discussion | |
116 | name={name} | |
117 | items={items} | |
118 | collapsed={collapsed} | |
119 | toggleCollapsed={toggleCollapsed} | |
120 | toggleSelected={toggleSelected} | |
121 | /> | |
122 | )} | |
123 | /> | |
124 | <Selection | |
125 | items={selection.map(id => items[id])} | |
126 | toggleSelected={toggleSelected} | |
127 | /> | |
128 | </> | |
129 | )} /> | |
130 | ); | |
131 | }; | |
132 | ||
133 | 84 | const search = new URLSearchParams(window.location.search); |
134 | 85 | const graph = search.has('graph') ? search.get('graph') : 'lib/graph.json'; |
135 | 86 | |
136 | 87 | let app; |
137 | 88 | if (search.has('document') || search.has('note') || search.has('graph')) { |
138 | const Container = search.has('document') ? GraphContainerJSONLD : GraphContainerMastodon; | |
89 | const mode = search.has('document') ? "jsonld" : "mastodon"; | |
139 | 90 | const url = search.get('document') || search.get('note') || search.get('graph'); |
140 | 91 | |
141 | 92 | app = ( |
142 | 93 | <UserContainer> |
143 | <Container | |
94 | <GraphContainer | |
144 | 95 | url={url} |
145 | render={graphRender} | |
146 | /> | |
96 | mode={mode} | |
97 | > | |
98 | {({ loading, error, name, items }) => { | |
99 | if (loading) { | |
100 | return ( | |
101 | <article> | |
102 | <div>loading...</div> | |
103 | </article> | |
104 | ); | |
105 | } | |
106 | ||
107 | if (error) { | |
108 | return ( | |
109 | <article> | |
110 | <h1>error loading</h1> | |
111 | <div> | |
112 | <p>{error.toString()}</p> | |
113 | <pre> | |
114 | <code> | |
115 | {error.stack} | |
116 | </code> | |
117 | </pre> | |
118 | </div> | |
119 | </article> | |
120 | ); | |
121 | } | |
122 | ||
123 | return ( | |
124 | <SelectionContainer render={({ selection, toggleSelected }) => ( | |
125 | <> | |
126 | <CollapseContainer | |
127 | items={items} | |
128 | render={({ collapsed, toggleCollapsed }) => ( | |
129 | <Discussion | |
130 | name={name} | |
131 | items={items} | |
132 | collapsed={collapsed} | |
133 | toggleCollapsed={toggleCollapsed} | |
134 | toggleSelected={toggleSelected} | |
135 | /> | |
136 | )} | |
137 | /> | |
138 | <Selection | |
139 | items={selection.map(id => items[id])} | |
140 | toggleSelected={toggleSelected} | |
141 | /> | |
142 | </> | |
143 | )} /> | |
144 | ); | |
145 | }} | |
146 | </GraphContainer> | |
147 | 147 | </UserContainer> |
148 | 148 | ); |
149 | 149 | } else { |
0 | 0 | import { h, Fragment } from 'preact'; |
1 | 1 | import { useState } from 'preact/hooks'; |
2 | 2 | import cn from 'classnames'; |
3 | import { useUser, useUserCtx } from '../user'; | |
3 | import { useDispatch } from '../actions'; | |
4 | import { useUser } from '../user'; | |
4 | 5 | import css from './css'; |
5 | 6 | |
6 | 7 | css` |
92 | 93 | `; |
93 | 94 | const UserMenu = () => { |
94 | 95 | const [showLogin, setLogin] = useState(false); |
95 | const { state: { user }, dispatch } = useUserCtx(); | |
96 | const { user } = useUser(); | |
97 | const dispatch = useDispatch(); | |
96 | 98 | |
97 | 99 | if (showLogin) { |
98 | 100 | return ( |
0 | 0 | import { h } from 'preact'; |
1 | 1 | import { useState, useEffect } from 'preact/hooks'; |
2 | 2 | import cn from 'classnames'; |
3 | import { useDispatch } from '../actions'; | |
3 | 4 | import css from './css'; |
4 | 5 | import { Note } from './Note'; |
5 | 6 | |
18 | 19 | |
19 | 20 | .drawer .handle { |
20 | 21 | height: 1.5rem; |
21 | margin-bottom: 1rem; | |
22 | 22 | } |
23 | 23 | |
24 | 24 | .drawer .handle button { |
36 | 36 | display: flex; |
37 | 37 | overflow: auto; |
38 | 38 | width: 100%; |
39 | padding: 1rem 0; | |
39 | 40 | |
40 | 41 | transition: max-height 0.3s; |
41 | 42 | } |
51 | 52 | |
52 | 53 | .drawer .contents > * { |
53 | 54 | flex: 0 0 auto; |
55 | } | |
56 | ||
57 | .drawer .contents > .reply { | |
58 | display: flex; | |
59 | flex-direction: column; | |
60 | align-self: stretch; | |
61 | } | |
62 | ||
63 | .drawer .contents > .reply textarea { | |
64 | flex: 1 1; | |
54 | 65 | } |
55 | 66 | `; |
56 | 67 | |
79 | 90 | ); |
80 | 91 | }; |
81 | 92 | |
82 | export const Selection = ({ items, toggleSelected }) => ( | |
83 | <Drawer height="8.55rem" > | |
84 | {items.map((item) => ( | |
85 | <Note | |
86 | {...item} | |
87 | ellipsis | |
88 | onSelect={toggleSelected} | |
89 | /> | |
90 | ))} | |
91 | </Drawer> | |
92 | ); | |
93 | export const Selection = ({ items, toggleSelected }) => { | |
94 | const [reply, setReply] = useState(''); | |
95 | const dispatch = useDispatch(); | |
96 | ||
97 | return ( | |
98 | <Drawer height="8.55rem" > | |
99 | {items.map((item) => ( | |
100 | <Note | |
101 | {...item} | |
102 | ellipsis | |
103 | onSelect={toggleSelected} | |
104 | /> | |
105 | ))} | |
106 | {!!items.length && ( | |
107 | <div class="reply"> | |
108 | <textarea value={reply} onChange={(e) => setReply(e.target.value)} /> | |
109 | <button onClick={() => { | |
110 | dispatch({ | |
111 | type: 'reply', | |
112 | content: reply, | |
113 | to: items.map(i => i.id), | |
114 | }) | |
115 | .then(() => setReply('')); | |
116 | }}> | |
117 | reply | |
118 | </button> | |
119 | </div> | |
120 | )} | |
121 | </Drawer> | |
122 | ); | |
123 | }; |
0 | 0 | import { h, createContext } from 'preact'; |
1 | import { useRef, useState, useCallback, useEffect, useContext } from 'preact/hooks'; | |
2 | import { API_PREFIX } from './config'; | |
3 | ||
4 | const fetchJSON = async (url, method='GET', data=undefined) => { | |
5 | const res = await fetch(API_PREFIX + url, { | |
6 | method, | |
7 | credentials: 'include', | |
8 | headers: { 'content-type': data && 'application/json' }, | |
9 | body: data && JSON.stringify(data), | |
10 | }); | |
11 | ||
12 | if (res.status !== 200) | |
13 | throw new Error("wrong status"); | |
14 | ||
15 | return res.json(); | |
16 | }; | |
1 | import { useEffect, useContext } from 'preact/hooks'; | |
2 | import { useDispatcher, fetchJSON } from './actions'; | |
17 | 3 | |
18 | 4 | const initialState = { user: null, discussions: [] }; |
19 | const reducer = async (action, ref) => { | |
5 | const reducer = (action) => { | |
20 | 6 | switch (action.type) { |
21 | 7 | case 'login': { |
22 | 8 | const { username, password } = action; |
23 | await fetchJSON('/discdag/login', 'POST', { username, password }); | |
24 | return await reducer({ type: 'refresh' }, ref); | |
9 | return fetchJSON('/discdag/login', 'POST', { username, password }) | |
10 | .then(() => reducer({ type: 'refresh' })); | |
25 | 11 | } |
26 | 12 | |
27 | 13 | case 'logout': { |
28 | await fetchJSON('/discdag/logout', 'POST'); | |
29 | return await reducer({ type: 'refresh' }, ref); | |
14 | return fetchJSON('/discdag/logout', 'POST') | |
15 | .then(() => reducer({ type: 'refresh' })); | |
30 | 16 | } |
31 | 17 | |
32 | 18 | case 'refresh': { |
33 | const { user, discussions } = await fetchJSON('/discdag/list'); | |
34 | return { ...ref.current, user, discussions }; | |
19 | return fetchJSON('/discdag/list') | |
20 | .then(({ user, discussions }) => | |
21 | (state) => ({ ...state, user, discussions }) | |
22 | ); | |
35 | 23 | } |
36 | 24 | |
37 | 25 | default: |
38 | throw new Error('Unexpected action'); | |
26 | return undefined; | |
39 | 27 | } |
40 | 28 | }; |
41 | 29 | |
42 | const UserContext = createContext({ | |
43 | state: initialState, | |
44 | dispatch: () => { | |
45 | throw new Error("no UserContext Provider!"); | |
46 | }, | |
47 | }); | |
30 | const UserContext = createContext(initialState); | |
48 | 31 | |
49 | 32 | export const UserContainer = ({ children }) => { |
50 | const ref = useRef(); | |
51 | const [state, setState] = useState(initialState); | |
52 | ref.current = state; | |
53 | ||
54 | const dispatch = useCallback(async (action) => { | |
55 | const nextState = await reducer(action, ref); | |
56 | setState(nextState); | |
57 | }, [ref]); | |
58 | ||
33 | const { state, dispatch, DispatchProvider } = useDispatcher({ | |
34 | reducer, | |
35 | initialState, | |
36 | }); | |
59 | 37 | useEffect(() => dispatch({ type: 'refresh' }), []); |
60 | 38 | |
61 | 39 | return ( |
62 | <UserContext.Provider value={{ state, dispatch }}> | |
63 | {children} | |
64 | </UserContext.Provider> | |
40 | <DispatchProvider> | |
41 | <UserContext.Provider value={state}> | |
42 | {children} | |
43 | </UserContext.Provider> | |
44 | </DispatchProvider> | |
65 | 45 | ); |
66 | 46 | }; |
67 | 47 | |
68 | export const useUser = () => useContext(UserContext).state; | |
69 | export const useDispatch = () => useContext(UserContext).dispatch; | |
70 | export const useUserCtx = () => useContext(UserContext); | |
48 | export const useUser = () => useContext(UserContext); |