1 | import usePromise from "react-use-promise"; |
2 | import htm from "htm"; |
3 | import React, {useState, useEffect} from "react"; |
4 | import {produce, current} from "immer"; |
5 | import {makePaginatedFetcher, formatDate, sendAppSyncQuery, persistentSubscription, connection} from "./utils.mjs"; |
6 | import {APIURL} from "./utils.mjs"; |
7 | |
8 | const html = htm.bind(React.createElement); |
9 | |
10 | export const App = ({loggedin, getAccessToken, redirectToLogin, redirectToLogout}) => { |
11 | const query = sendAppSyncQuery(getAccessToken); |
12 | |
13 | const displayUserId = location.pathname.match(/\/(?<userid>.+)$/)?.groups?.userid; |
14 | |
15 | const [data, setData] = useState(undefined); |
16 | |
17 | const [, loadError, loadState] = usePromise(async () => { |
18 | if(loggedin) { |
19 | const friendsSubquery = ` |
20 | friends(nextToken: $friendsNextToken) { |
21 | nextToken |
22 | users { |
23 | avatar |
24 | id |
25 | name |
26 | } |
27 | } |
28 | `; |
29 | |
30 | const fetchFriends = makePaginatedFetcher(async (nextToken) => { |
31 | return (await query(` |
32 | query MyQuery($friendsNextToken: String${displayUserId ? ", $id: ID!" : ""}) { |
33 | user: ${displayUserId ? "user(id: $id)" : "currentUser"} { |
34 | ${friendsSubquery} |
35 | } |
36 | } |
37 | `, {friendsNextToken: nextToken, id: displayUserId})).user.friends; |
38 | }, (user) => user, {items: "users", nextToken: "nextToken"}) |
39 | |
40 | const currentUserSubquery = ` |
41 | currentUser { |
42 | id |
43 | name |
44 | avatar |
45 | } |
46 | `; |
47 | const userSubquery = ` |
48 | id |
49 | name |
50 | avatar |
51 | posts(limit: 3) { |
52 | nextToken |
53 | posts { |
54 | date |
55 | id |
56 | text |
57 | comments(limit: 3) { |
58 | nextToken |
59 | comments { |
60 | date |
61 | id |
62 | text |
63 | author { |
64 | avatar |
65 | id |
66 | name |
67 | } |
68 | } |
69 | } |
70 | } |
71 | } |
72 | ${friendsSubquery} |
73 | `; |
74 | const firstData = displayUserId ? await query(` |
75 | query MyQuery($friendsNextToken: String, $id: ID!) { |
76 | ${currentUserSubquery} |
77 | user(id: $id) { |
78 | ${userSubquery} |
79 | } |
80 | } |
81 | `, {id: displayUserId}) : await query(` |
82 | query MyQuery($friendsNextToken: String) { |
83 | ${currentUserSubquery} |
84 | user: currentUser { |
85 | ${userSubquery} |
86 | } |
87 | } |
88 | `, {}); |
89 | |
90 | const data = { |
91 | ...firstData, |
92 | user: { |
93 | ...firstData.user, |
94 | friends: firstData.user.friends === null ? firstData.user.friends : await fetchFriends()(firstData.user.friends), |
95 | } |
96 | }; |
97 | setData(data); |
98 | } |
99 | }, []); |
100 | |
101 | useEffect(() => { |
102 | if (data === undefined) { |
103 | return () => {}; |
104 | }else { |
105 | const query = ` |
106 | subscription MySubscription($userId: ID!) { |
107 | post(userId: $userId) { |
108 | post { |
109 | date |
110 | id |
111 | text |
112 | } |
113 | } |
114 | } |
115 | `; |
116 | const subscription = persistentSubscription(connection)({ |
117 | getAuthorizationHeaders: async () => ({host: new URL(APIURL).host, Authorization: await getAccessToken()}), |
118 | })(query, {userId: data.user.id}) |
119 | .subscribe({ |
120 | next: (e) => { |
121 | setData(produce((data) => { |
122 | if (!data.user.posts.posts.find(({id}) => id === e.data.post.post.id)) { |
123 | data.user.posts.posts.unshift({...e.data.post.post, comments: {nextToken: null, comments: []}}); |
124 | } |
125 | })); |
126 | }, |
127 | error: (e) => console.log("subscription.error", e), |
128 | complete: () => console.log("subscription.complete"), |
129 | }); |
130 | return () => { |
131 | subscription?.unsubscribe(); |
132 | }; |
133 | } |
134 | }, [data?.user.id]); |
135 | |
136 | const firstPostDate = data?.user.posts?.posts.find((post, index, l) => l.every((comparePost) => new Date(post.date).getTime() <= new Date(comparePost.date).getTime())).date; |
137 | |
138 | useEffect(() => { |
139 | if (data === undefined) { |
140 | return () => {}; |
141 | }else { |
142 | const query = ` |
143 | subscription MySubscription($postDateStarting: AWSDateTime!, $postUserId: ID!) { |
144 | comment(postDateStarting: $postDateStarting, postUserId: $postUserId) { |
145 | comment { |
146 | date |
147 | id |
148 | text |
149 | author { |
150 | id |
151 | } |
152 | post { |
153 | id |
154 | } |
155 | } |
156 | } |
157 | } |
158 | `; |
159 | const subscription = persistentSubscription(connection)({ |
160 | getAuthorizationHeaders: async () => ({host: new URL(APIURL).host, Authorization: await getAccessToken()}), |
161 | })(query, {postUserId: data.user.id, postDateStarting: new Date(firstPostDate).toISOString()}) |
162 | .subscribe({ |
163 | next: (e) => { |
164 | setData(produce((data) => { |
165 | const post = data.user.posts.posts.find(({id}) => id === e.data.comment.comment.post.id); |
166 | if (!post.comments.comments.find(({id}) => id === e.data.comment.comment.id)) { |
167 | const author = [{id: data.user.id, name: data.user.name, avatar: data.user.avatar}, ...data.user.friends.users].find(({id}) => id === e.data.comment.comment.author.id); |
168 | post.comments.comments.unshift({...e.data.comment.comment, author}); |
169 | } |
170 | })); |
171 | }, |
172 | error: (e) => console.log("subscription.error", e), |
173 | complete: () => console.log("subscription.complete"), |
174 | }); |
175 | return () => { |
176 | subscription?.unsubscribe(); |
177 | }; |
178 | } |
179 | }, [firstPostDate]); |
180 | |
181 | |
182 | const writePost = async (event) => { |
183 | event.preventDefault(); |
184 | [...event.target.querySelectorAll("*")].forEach((e) => e.disabled = true); |
185 | const text = event.target.querySelector("input").value; |
186 | |
187 | const res = await query(` |
188 | mutation MyQuery($text: String!, $userId: ID!) { |
189 | createPost(text: $text, userId: $userId) { |
190 | date |
191 | id |
192 | text |
193 | } |
194 | } |
195 | `, {text, userId: data.currentUser.id}); |
196 | |
197 | setData(produce((data) => { |
198 | data.user.posts.posts.unshift({...res.createPost, comments: {nextToken: null, comments: []}}); |
199 | })); |
200 | |
201 | event.target.querySelector("input").value = ""; |
202 | [...event.target.querySelectorAll("*")].forEach((e) => e.disabled = false); |
203 | }; |
204 | |
205 | const writeComment = (postId) => async (event) => { |
206 | event.preventDefault(); |
207 | [...event.target.querySelectorAll("*")].forEach((e) => e.disabled = true); |
208 | const text = event.target.querySelector("input").value; |
209 | |
210 | const res = await query(` |
211 | mutation MyQuery($text: String!, $userId: ID!, $postId: ID!) { |
212 | addComment(text: $text, userId: $userId, postId: $postId) { |
213 | date |
214 | id |
215 | text |
216 | author { |
217 | id |
218 | name |
219 | avatar |
220 | } |
221 | } |
222 | } |
223 | `, {text, userId: data.currentUser.id, postId}); |
224 | |
225 | setData(produce((data) => { |
226 | const post = data.user.posts.posts.find(({id}) => id === postId); |
227 | post.comments.comments.unshift(res.addComment); |
228 | })); |
229 | |
230 | event.target.querySelector("input").value = ""; |
231 | [...event.target.querySelectorAll("*")].forEach((e) => e.disabled = false); |
232 | }; |
233 | |
234 | const loadMorePosts = async (e) => { |
235 | e.target.disabled = true; |
236 | const res = await query(` |
237 | query MyQuery($userId: ID!, $nextToken: String) { |
238 | user(id: $userId) { |
239 | posts(limit: 3, nextToken: $nextToken) { |
240 | nextToken |
241 | posts { |
242 | date |
243 | id |
244 | text |
245 | comments(limit: 3) { |
246 | nextToken |
247 | comments { |
248 | date |
249 | id |
250 | text |
251 | author { |
252 | avatar |
253 | id |
254 | name |
255 | } |
256 | } |
257 | } |
258 | } |
259 | } |
260 | } |
261 | } |
262 | `, {userId: data.user.id, nextToken: data.user.posts.nextToken}); |
263 | setData(produce((data) => { |
264 | data.user.posts.nextToken = res.user.posts.nextToken; |
265 | data.user.posts.posts = [...data.user.posts.posts, ...res.user.posts.posts]; |
266 | })); |
267 | e.target.disabled = false; |
268 | }; |
269 | |
270 | const loadMoreComments = (postId) => async (e) => { |
271 | e.target.disabled = true; |
272 | const res = await query(` |
273 | query MyQuery($postId: ID!, $nextToken: String) { |
274 | post(id: $postId) { |
275 | comments(limit: 3, nextToken: $nextToken) { |
276 | nextToken |
277 | comments { |
278 | date |
279 | id |
280 | text |
281 | author { |
282 | avatar |
283 | id |
284 | name |
285 | } |
286 | } |
287 | } |
288 | } |
289 | } |
290 | `, {postId, nextToken: data.user.posts.posts.find(({id}) => id === postId).comments.nextToken}); |
291 | setData(produce((data) => { |
292 | const post = data.user.posts.posts.find(({id}) => id === postId); |
293 | post.comments.nextToken = res.post.comments.nextToken; |
294 | post.comments.comments = [...post.comments.comments, ...res.post.comments.comments]; |
295 | })); |
296 | e.target.disabled = false; |
297 | }; |
298 | |
299 | return html` |
300 | <div class="container"> |
301 | <ul class="nav justify-content-end mb-5"> |
302 | <li class="nav-item"> |
303 | <a class="nav-link">${loggedin ? html` |
304 | <span>Logged in</span> |
305 | ${data !== undefined && html` |
306 | <span> as <a href="/">${data.currentUser.name}</a></span> |
307 | <img class="rounded avatar ms-2" src=${`data:image/svg+xml;base64,${btoa(data.currentUser.avatar)}`}/> |
308 | `} |
309 | ` : "Not logged in"}</a> |
310 | </li> |
311 | <li class="nav-item align-self-center"> |
312 | ${loggedin ? |
313 | html`<a class="nav-link" href="#" onClick=${redirectToLogout}>Logout</a>` |
314 | : html`<a class="nav-link" href="#" onClick=${redirectToLogin}>Login</a>` |
315 | } |
316 | </li> |
317 | </ul> |
318 | ${!loggedin && html` |
319 | <div> |
320 | <h1>Welcome to this example project!</h1> |
321 | <p>To view the posts and comments <a href="#" onClick=${redirectToLogin}>log in</a> with: |
322 | <ul> |
323 | <li>user // Password.1</li> |
324 | </ul> |
325 | </p> |
326 | </div> |
327 | `} |
328 | ${loadError ? html` |
329 | <pre> |
330 | ${JSON.stringify(loadError, undefined, 4)} |
331 | </pre> |
332 | ` : ""} |
333 | ${loadState === "pending" ? html`<div class="text-center my-5"><div class="spinner-border" role="status"/></div>` : ""} |
334 | ${data && html` |
335 | <div class="row"> |
336 | <div class="col-md-8"> |
337 | <${UserPosts} user=${data.user} currentUserId=${data.currentUser.id} writePost=${displayUserId ? undefined : writePost} writeComment=${writeComment} loadMorePosts=${loadMorePosts} loadMoreComments=${loadMoreComments}/> |
338 | </div> |
339 | <div class="col-md-3 offset-md-1 mt-5"> |
340 | <div class="h4 text-end">${data.user.name}'s friends</div> |
341 | ${data.user.friends === null ? html` |
342 | <span>Not friends</span> |
343 | ` : data.user.friends.users.map((friend) => html` |
344 | <p class="mb-4 text-end h4"><a href=${`/${friend.id === data.currentUser.id ? "" : friend.id}`}>${friend.name}</a> |
345 | <img class="rounded avatar ms-3" src=${`data:image/svg+xml;base64,${btoa(friend.avatar)}`}/> |
346 | </p> |
347 | `)} |
348 | </div> |
349 | </div> |
350 | `} |
351 | </div> |
352 | `; |
353 | }; |
354 | |
355 | const UserPosts = ({user, currentUserId, writePost, writeComment, loadMorePosts, loadMoreComments}) => { |
356 | return html` |
357 | <h2>${user.name}'s posts |
358 | <img class="rounded avatar ms-3" src=${`data:image/svg+xml;base64,${btoa(user.avatar)}`}/> |
359 | </h2> |
360 | ${writePost && html` |
361 | <form onSubmit=${writePost}> |
362 | <div class="mb-3"> |
363 | <label for="postText" class="form-label">Write a new post</label> |
364 | <input required type="text" class="form-control" id="postText"/> |
365 | </div> |
366 | <button type="submit" class="btn btn-outline-primary">Submit</button> |
367 | </form> |
368 | `} |
369 | ${user.posts === null ? html` |
370 | <span>Not friends, can't show posts</span> |
371 | ` : user.posts.posts.length === 0 ? html` |
372 | <span>No posts yet</span> |
373 | ` : user.posts.posts.map((post) => html` |
374 | <div class="card my-5"> |
375 | <div class="card-body"> |
376 | <p class="card-text"> |
377 | <div class="d-flex justify-content-between mb-3"> |
378 | <span class="badge text-bg-info">Post</span> |
379 | <span class="text-muted"> |
380 | ${formatDate(post.date)} |
381 | </span> |
382 | </div> |
383 | ${post.text} |
384 | </p> |
385 | <p class="card-text text-end">By <a href=${`/${user.id === currentUserId ? "" : user.id}`}>${user.name}</a> <img class="rounded avatar" src=${`data:image/svg+xml;base64,${btoa(user.avatar)}`}/></p> |
386 | <form onSubmit=${writeComment(post.id)}> |
387 | <div class="mb-3"> |
388 | <label for="postText" class="form-label">Add comment</label> |
389 | <input required type="text" class="form-control" id="commentText"/> |
390 | </div> |
391 | <button type="submit" class="btn btn-outline-primary mb-4">Submit</button> |
392 | </form> |
393 | ${post.comments.comments.map((comment) => html` |
394 | <div class="card"> |
395 | <div class="card-body"> |
396 | <p class="card-text"> |
397 | <div class="d-flex justify-content-between mb-3"> |
398 | <span class="badge text-bg-light">Comment</span> |
399 | <span class="text-muted"> |
400 | ${formatDate(comment.date)} |
401 | </span> |
402 | </div> |
403 | ${comment.text} |
404 | </p> |
405 | <p class="card-text text-end">By <a href=${`/${comment.author.id === currentUserId ? "" : comment.author.id}`}>${comment.author.name}</a> <img class="rounded avatar" src=${`data:image/svg+xml;base64,${btoa(comment.author.avatar)}`}/></p> |
406 | </div> |
407 | </div> |
408 | `)} |
409 | ${post.comments.nextToken && html` |
410 | <button class="btn btn-outline-secondary my-3" onClick=${loadMoreComments(post.id)}>Load more comments</button> |
411 | `} |
412 | </div> |
413 | </div> |
414 | `)} |
415 | ${user.posts?.nextToken && html` |
416 | <button class="btn btn-outline-secondary my-3" onClick=${loadMorePosts}>Load more posts</button> |
417 | `} |
418 | `; |
419 | } |
420 | |
421 | |