git.s-ol.nu fedidag / eb1e149
client: replying s-ol 1 year, 7 months ago
6 changed file(s) with 268 addition(s) and 151 deletion(s). Raw diff Collapse all Expand all
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';
12 import * as jsonld from 'jsonld';
3 import { useDispatcher, fetchJSON } from './actions';
24 import { CORS_PREFIX } from './config';
35
46 const cors = (url) => `${CORS_PREFIX}/${url}`;
1921 {
2022 replies: { '@id': 'as:replies', '@container': '@set' },
2123 inReplyTo: { '@id': 'as:inReplyTo', '@container': '@set' },
22 items: { '@id': 'as:items', '@type': '@id', '@container': '@set' },
24 items: { '@id': 'as:items', '@container': '@set' },
2325 },
2426 ];
2527
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 {
3454 cache = {};
3555
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 }
5259
5360 @wrapCache
5461 async loadUser(id) {
7885 }
7986 }
8087
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 }
8499 }
85100 }
86101
87 export class GraphContainerMastodon extends GraphContainer {
102 export class GraphAPIMastodon extends GraphAPI {
88103 @wrapCache
89104 async loadCollection(id, ...args) {
90105 const collection = await jsonld.compact(cors(id), context);
153168 return item;
154169 }
155170
156 async loadData(url) {
171 async loadData() {
157172 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 () => ({
161176 name: root.name || root.content,
162177 items,
163178 loading: false,
165180 }
166181 }
167182
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, {
171186 headers: { 'Accept': 'application/ld+json, application/json' },
172187 credentials: 'include',
173188 });
196211
197212 discussion.items = indexedItems;
198213
199 this.setState({
214 return () => ({
200215 ...discussion,
201216 loading: false,
202217 });
203218 }
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 }
204266 }
11 import 'core-js/stable';
22 import 'regenerator-runtime/runtime';
33 import { h, Fragment, Component, render } from 'preact';
4 import { GraphContainerJSONLD, GraphContainerMastodon } from './graph';
4 import { GraphContainer } from './graph';
55 import { Menu, Discussion, Selection } from './ui';
66 import { UserContainer } from './user';
77
8181 }
8282 }
8383
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
13384 const search = new URLSearchParams(window.location.search);
13485 const graph = search.has('graph') ? search.get('graph') : 'lib/graph.json';
13586
13687 let app;
13788 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";
13990 const url = search.get('document') || search.get('note') || search.get('graph');
14091
14192 app = (
14293 <UserContainer>
143 <Container
94 <GraphContainer
14495 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>
147147 </UserContainer>
148148 );
149149 } else {
00 import { h, Fragment } from 'preact';
11 import { useState } from 'preact/hooks';
22 import cn from 'classnames';
3 import { useUser, useUserCtx } from '../user';
3 import { useDispatch } from '../actions';
4 import { useUser } from '../user';
45 import css from './css';
56
67 css`
9293 `;
9394 const UserMenu = () => {
9495 const [showLogin, setLogin] = useState(false);
95 const { state: { user }, dispatch } = useUserCtx();
96 const { user } = useUser();
97 const dispatch = useDispatch();
9698
9799 if (showLogin) {
98100 return (
00 import { h } from 'preact';
11 import { useState, useEffect } from 'preact/hooks';
22 import cn from 'classnames';
3 import { useDispatch } from '../actions';
34 import css from './css';
45 import { Note } from './Note';
56
1819
1920 .drawer .handle {
2021 height: 1.5rem;
21 margin-bottom: 1rem;
2222 }
2323
2424 .drawer .handle button {
3636 display: flex;
3737 overflow: auto;
3838 width: 100%;
39 padding: 1rem 0;
3940
4041 transition: max-height 0.3s;
4142 }
5152
5253 .drawer .contents > * {
5354 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;
5465 }
5566 `;
5667
7990 );
8091 };
8192
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 };
00 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';
173
184 const initialState = { user: null, discussions: [] };
19 const reducer = async (action, ref) => {
5 const reducer = (action) => {
206 switch (action.type) {
217 case 'login': {
228 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' }));
2511 }
2612
2713 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' }));
3016 }
3117
3218 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 );
3523 }
3624
3725 default:
38 throw new Error('Unexpected action');
26 return undefined;
3927 }
4028 };
4129
42 const UserContext = createContext({
43 state: initialState,
44 dispatch: () => {
45 throw new Error("no UserContext Provider!");
46 },
47 });
30 const UserContext = createContext(initialState);
4831
4932 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 });
5937 useEffect(() => dispatch({ type: 'refresh' }), []);
6038
6139 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>
6545 );
6646 };
6747
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);