social-network
social-network/frontend/App.mjs
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