gitalk-react 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +9 -0
  2. package/README-zh-CN.md +257 -0
  3. package/README.md +257 -0
  4. package/dist/gitalk-dark.css +1 -0
  5. package/dist/gitalk-light.css +1 -0
  6. package/dist/gitalk.d.ts +445 -0
  7. package/dist/gitalk.js +12560 -0
  8. package/dist/gitalk.umd.cjs +121 -0
  9. package/lib/assets/arrow-down.svg +1 -0
  10. package/lib/assets/edit.svg +3 -0
  11. package/lib/assets/github.svg +3 -0
  12. package/lib/assets/heart-filled.svg +3 -0
  13. package/lib/assets/heart.svg +3 -0
  14. package/lib/assets/reply.svg +3 -0
  15. package/lib/assets/tip.svg +8 -0
  16. package/lib/components/action.tsx +21 -0
  17. package/lib/components/avatar.tsx +42 -0
  18. package/lib/components/button.tsx +35 -0
  19. package/lib/components/comment.tsx +153 -0
  20. package/lib/components/svg.tsx +29 -0
  21. package/lib/constants/index.ts +43 -0
  22. package/lib/contexts/I18nContext.ts +18 -0
  23. package/lib/gitalk.tsx +1231 -0
  24. package/lib/i18n/de.json +20 -0
  25. package/lib/i18n/en.json +20 -0
  26. package/lib/i18n/es-ES.json +20 -0
  27. package/lib/i18n/fa.json +20 -0
  28. package/lib/i18n/fr.json +20 -0
  29. package/lib/i18n/index.ts +40 -0
  30. package/lib/i18n/ja.json +20 -0
  31. package/lib/i18n/ko.json +20 -0
  32. package/lib/i18n/pl.json +21 -0
  33. package/lib/i18n/ru.json +20 -0
  34. package/lib/i18n/zh-CN.json +20 -0
  35. package/lib/i18n/zh-TW.json +20 -0
  36. package/lib/interfaces/index.ts +30 -0
  37. package/lib/services/graphql/comment.ts +85 -0
  38. package/lib/services/request.ts +24 -0
  39. package/lib/services/user.ts +40 -0
  40. package/lib/themes/base.scss +592 -0
  41. package/lib/themes/gitalk-dark.scss +24 -0
  42. package/lib/themes/gitalk-light.scss +24 -0
  43. package/lib/utils/compatibility.ts +35 -0
  44. package/lib/utils/dom.ts +15 -0
  45. package/lib/utils/logger.ts +56 -0
  46. package/lib/utils/query.ts +19 -0
  47. package/package.json +83 -0
package/lib/gitalk.tsx ADDED
@@ -0,0 +1,1231 @@
1
+ import "./i18n";
2
+
3
+ import { useRequest } from "ahooks";
4
+ import React, {
5
+ forwardRef,
6
+ useCallback,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import FlipMove from "react-flip-move";
13
+
14
+ import ArrowDown from "./assets/arrow-down.svg?raw";
15
+ import Github from "./assets/github.svg?raw";
16
+ import Tip from "./assets/tip.svg?raw";
17
+ import Action from "./components/action";
18
+ import Avatar from "./components/avatar";
19
+ import Button from "./components/button";
20
+ import Comment, { type CommentProps } from "./components/comment";
21
+ import Svg from "./components/svg";
22
+ import {
23
+ ACCESS_TOKEN_KEY,
24
+ DATE_FNS_LOCALE_MAP,
25
+ DEFAULT_LABELS,
26
+ HOMEPAGE,
27
+ VERSION,
28
+ } from "./constants";
29
+ import I18nContext from "./contexts/I18nContext";
30
+ import i18n, { type Lang } from "./i18n";
31
+ import type {
32
+ Comment as CommentType,
33
+ Issue as IssueType,
34
+ User as UserType,
35
+ } from "./interfaces";
36
+ import {
37
+ getIssueCommentsQL,
38
+ type IssueCommentsQLResponse,
39
+ } from "./services/graphql/comment";
40
+ import getOctokitInstance from "./services/request";
41
+ import { getAccessToken, getAuthorizeUrl } from "./services/user";
42
+ import { supportsCSSVariables, supportsES2020 } from "./utils/compatibility";
43
+ import { hasClassInParent } from "./utils/dom";
44
+ import logger from "./utils/logger";
45
+ import { parseSearchQuery, stringifySearchQuery } from "./utils/query";
46
+
47
+ export interface GitalkProps
48
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "id" | "title"> {
49
+ /**
50
+ * GitHub Application Client ID.
51
+ */
52
+ clientID: string;
53
+ /**
54
+ * GitHub Application Client Secret.
55
+ */
56
+ clientSecret: string;
57
+ /**
58
+ * GitHub repository owner.
59
+ * Can be personal user or organization.
60
+ */
61
+ owner: string;
62
+ /**
63
+ * Name of Github repository.
64
+ */
65
+ repo: string;
66
+ /**
67
+ * GitHub repository owner and collaborators.
68
+ * (Users who having write access to this repository)
69
+ */
70
+ admin: string[];
71
+ /**
72
+ * The unique id of the page.
73
+ * Length must less than 50.
74
+ *
75
+ * @default location.href
76
+ */
77
+ id?: string;
78
+ /**
79
+ * The issue ID of the page.
80
+ * If the number attribute is not defined, issue will be located using id.
81
+ *
82
+ * @default -1
83
+ */
84
+ number?: number;
85
+ /**
86
+ * GitHub issue labels.
87
+ *
88
+ * @default ['Gitalk']
89
+ */
90
+ labels?: string[];
91
+ /**
92
+ * GitHub issue title.
93
+ *
94
+ * @default document.title
95
+ */
96
+ title?: string;
97
+ /**
98
+ * GitHub issue body.
99
+ *
100
+ * @default location.href + header.meta[description]
101
+ */
102
+ body?: string;
103
+ /**
104
+ * Localization language key.
105
+ *
106
+ * @default navigator.language
107
+ */
108
+ language?: Lang;
109
+ /**
110
+ * Pagination size, with maximum 100.
111
+ *
112
+ * @default 10
113
+ */
114
+ perPage?: number;
115
+ /**
116
+ * Comment sorting direction.
117
+ * Available values are last and first.
118
+ *
119
+ * @default "last"
120
+ */
121
+ pagerDirection?: "last" | "first";
122
+ /**
123
+ * By default, Gitalk will create a corresponding github issue for your every single page automatically when the logined user is belong to the admin users.
124
+ * You can create it manually by setting this option to true.
125
+ *
126
+ * @default false
127
+ */
128
+ createIssueManually?: boolean;
129
+ /**
130
+ * Enable hot key (cmd|ctrl + enter) submit comment.
131
+ *
132
+ * @default true
133
+ */
134
+ enableHotKey?: boolean;
135
+ /**
136
+ * Facebook-like distraction free mode.
137
+ *
138
+ * @default false
139
+ */
140
+ distractionFreeMode?: boolean;
141
+ /**
142
+ * Comment list animation.
143
+ *
144
+ * @default
145
+ * ```ts
146
+ * {
147
+ * staggerDelayBy: 150,
148
+ * appearAnimation: 'accordionVertical',
149
+ * enterAnimation: 'accordionVertical',
150
+ * leaveAnimation: 'accordionVertical',
151
+ * }
152
+ * ```
153
+ * @link https://github.com/joshwcomeau/react-flip-move/blob/master/documentation/enter_leave_animations.md
154
+ */
155
+ flipMoveOptions?: FlipMove.FlipMoveProps;
156
+ /**
157
+ * GitHub oauth request reverse proxy for CORS.
158
+ * [Why need this?](https://github.com/isaacs/github/issues/330)
159
+ *
160
+ * @default "https://cors-anywhere.azm.workers.dev/https://github.com/login/oauth/access_token"
161
+ */
162
+ proxy?: string;
163
+ /**
164
+ * Default user field if comments' author is not provided
165
+ *
166
+ * @default
167
+ * ```ts
168
+ * {
169
+ * avatar_url: "//avatars1.githubusercontent.com/u/29697133?s=50",
170
+ * login: "null",
171
+ * html_url: ""
172
+ * }
173
+ */
174
+ defaultUser?: CommentType["user"];
175
+ /**
176
+ * Default user field if comments' author is not provided
177
+ *
178
+ * @deprecated use `defaultUser`
179
+ */
180
+ defaultAuthor?: IssueCommentsQLResponse["repository"]["issue"]["comments"]["nodes"][number]["author"];
181
+ /**
182
+ * Callback method invoked when updating the number of comments.
183
+ *
184
+ * @param count comments number
185
+ */
186
+ updateCountCallback?: (count: number) => void;
187
+ /**
188
+ * Callback method invoked when a new comment is successfully created.
189
+ *
190
+ * @param comment created comment
191
+ */
192
+ onCreateComment?: (comment: CommentType) => void;
193
+ }
194
+
195
+ const isModernBrowser = supportsCSSVariables() && supportsES2020();
196
+
197
+ const Gitalk: React.FC<GitalkProps> = (props) => {
198
+ const {
199
+ clientID,
200
+ clientSecret,
201
+ owner,
202
+ repo,
203
+ admin,
204
+ id: propsIssueId = location.href,
205
+ number: issueNumber = -1,
206
+ labels: issueBaseLabels = DEFAULT_LABELS,
207
+ title: issueTitle = document.title,
208
+ body: issueBody = location.href +
209
+ document
210
+ ?.querySelector('meta[name="description"]')
211
+ ?.getAttribute("content") || "",
212
+ language = navigator.language as Lang,
213
+ perPage: propsPerPage = 10,
214
+ pagerDirection = "last",
215
+ createIssueManually = false,
216
+ enableHotKey = true,
217
+ distractionFreeMode = false,
218
+ flipMoveOptions = {
219
+ staggerDelayBy: 150,
220
+ appearAnimation: "accordionVertical",
221
+ enterAnimation: "accordionVertical",
222
+ leaveAnimation: "accordionVertical",
223
+ },
224
+ proxy = "https://cors-anywhere.azm.workers.dev/https://github.com/login/oauth/access_token",
225
+ defaultUser: propsDefaultUser,
226
+ defaultAuthor: propsDefaultAuthor,
227
+ updateCountCallback,
228
+ onCreateComment,
229
+ className = "",
230
+ ...restProps
231
+ } = props;
232
+ const issueId = propsIssueId.slice(0, 50);
233
+ const commentsPerPage =
234
+ propsPerPage > 100 ? 100 : propsPerPage < 0 ? 10 : propsPerPage;
235
+ const defaultUser = propsDefaultUser
236
+ ? propsDefaultUser
237
+ : propsDefaultAuthor
238
+ ? {
239
+ avatar_url: propsDefaultAuthor.avatarUrl,
240
+ login: propsDefaultAuthor.login,
241
+ html_url: propsDefaultAuthor.url,
242
+ }
243
+ : {
244
+ avatar_url: "//avatars1.githubusercontent.com/u/29697133?s=50",
245
+ login: "null",
246
+ html_url: "",
247
+ };
248
+
249
+ logger.i("re-rendered.");
250
+
251
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
252
+ const [inputComment, setInputComment] = useState<string>("");
253
+ const [isInputFocused, setIsInputFocused] = useState<boolean>(false);
254
+ const [isPreviewComment, setIsPreviewComment] = useState<boolean>(false);
255
+
256
+ const [commentsCount, setCommentsCount] = useState<number>(0);
257
+ const [commentsCursor, setCommentsCursor] = useState("");
258
+ const [commentsPage, setCommentsPage] = useState<number>(1);
259
+ const [commentsLoaded, setCommentsLoaded] = useState<boolean>(false);
260
+ const [commentsPagerDirection, setCommentsPagerDirection] =
261
+ useState(pagerDirection);
262
+
263
+ const [showPopup, setShowPopup] = useState<boolean>(false);
264
+
265
+ const [alert, setAlert] = useState<string>("");
266
+
267
+ const polyglot = useMemo(() => i18n(language), [language]);
268
+
269
+ const {
270
+ data: accessToken = localStorage.getItem(ACCESS_TOKEN_KEY) ?? undefined,
271
+ mutate: setAccessToken,
272
+ loading: getAccessTokenLoading,
273
+ run: runGetAccessToken,
274
+ } = useRequest(
275
+ async (code: string) =>
276
+ await getAccessToken({ url: proxy, code, clientID, clientSecret }),
277
+ {
278
+ manual: true,
279
+ ready: !!proxy && !!clientID && !!clientSecret,
280
+ onSuccess: (data) => {
281
+ localStorage.setItem(ACCESS_TOKEN_KEY, data);
282
+ logger.s(`Get access token successfully:`, data);
283
+ },
284
+ onError: (error) => {
285
+ setAlert(`An error occurred while getting access token: ${error}`);
286
+ logger.e(`An error occurred while getting access token:`, error);
287
+ },
288
+ },
289
+ );
290
+
291
+ useEffect(() => {
292
+ const query = parseSearchQuery();
293
+ const code = query["code"];
294
+
295
+ if (code && !accessToken) {
296
+ delete query["code"];
297
+ const replacedUrl = `${window.location.origin}${window.location.pathname}?${stringifySearchQuery(query)}${window.location.hash}`;
298
+ history.replaceState(null, "", replacedUrl);
299
+
300
+ runGetAccessToken(code);
301
+ }
302
+ }, [accessToken, runGetAccessToken]);
303
+
304
+ const octokit = useMemo(() => getOctokitInstance(accessToken), [accessToken]);
305
+
306
+ const {
307
+ data: user,
308
+ mutate: setUser,
309
+ loading: getUserLoading,
310
+ } = useRequest(
311
+ async () => {
312
+ const getUserRes = await octokit.request("GET /user");
313
+ if (getUserRes.status === 200) {
314
+ const _user = getUserRes.data;
315
+ logger.s(`Login successfully:`, _user);
316
+ return _user;
317
+ } else {
318
+ localStorage.removeItem(ACCESS_TOKEN_KEY);
319
+ setAccessToken(undefined);
320
+ logger.e(`Get user details with access token failed:`, getUserRes);
321
+ return undefined;
322
+ }
323
+ },
324
+ {
325
+ ready: !!accessToken,
326
+ refreshDeps: [accessToken],
327
+ },
328
+ );
329
+
330
+ const isAdmin = useMemo(() => {
331
+ return (
332
+ user &&
333
+ !!admin.find(
334
+ (username) => username.toLowerCase() === user.login.toLocaleLowerCase(),
335
+ )
336
+ );
337
+ }, [admin, user]);
338
+
339
+ const issueLabels = useMemo(
340
+ () => issueBaseLabels.concat([issueId]),
341
+ [issueBaseLabels, issueId],
342
+ );
343
+
344
+ const { loading: createIssueLoading, runAsync: runCreateIssue } = useRequest(
345
+ async () => {
346
+ logger.i(`Creating issue...`);
347
+
348
+ const createIssueRes = await octokit.request(
349
+ "POST /repos/{owner}/{repo}/issues",
350
+ {
351
+ owner,
352
+ repo,
353
+ title: issueTitle,
354
+ labels: issueLabels,
355
+ body: issueBody,
356
+ },
357
+ );
358
+
359
+ if (createIssueRes.status === 201) {
360
+ const _issue = createIssueRes.data;
361
+ logger.s(`Create issue successfully:`, _issue);
362
+ return _issue;
363
+ } else {
364
+ setAlert(`Create issue failed: ${createIssueRes}`);
365
+ logger.e(`Create issue failed:`, createIssueRes);
366
+ return undefined;
367
+ }
368
+ },
369
+ {
370
+ manual: true,
371
+ ready:
372
+ !!isAdmin && !!owner && !!repo && !!issueTitle && !!issueLabels.length,
373
+ },
374
+ );
375
+
376
+ const {
377
+ data: issue,
378
+ mutate: setIssue,
379
+ loading: getIssueLoading,
380
+ } = useRequest(
381
+ async () => {
382
+ if (issueNumber) {
383
+ const getIssueRes = await octokit.request(
384
+ "GET /repos/{owner}/{repo}/issues/{issue_number}",
385
+ {
386
+ owner,
387
+ repo,
388
+ issue_number: issueNumber,
389
+ },
390
+ );
391
+
392
+ if (getIssueRes.status === 200) {
393
+ const _issue = getIssueRes.data;
394
+ logger.s(
395
+ `Locate issue ${issueNumber} in repository ${owner}/${repo} successfully:`,
396
+ _issue,
397
+ );
398
+ return _issue;
399
+ } else if (getIssueRes.status === 404) {
400
+ logger.w(
401
+ `Issue ${issueNumber} in repository ${owner}/${repo} was not found.`,
402
+ );
403
+
404
+ if (isAdmin && !createIssueManually) {
405
+ const _issue = await runCreateIssue();
406
+ return _issue;
407
+ }
408
+ } else {
409
+ setAlert(
410
+ `Get issue ${issueNumber} in repository ${owner}/${repo} failed: ${getIssueRes}`,
411
+ );
412
+ logger.e(
413
+ `Get issue ${issueNumber} in repository ${owner}/${repo} failed:`,
414
+ getIssueRes,
415
+ );
416
+ }
417
+ } else if (issueId) {
418
+ const getIssuesRes = await octokit.request(
419
+ "GET /repos/{owner}/{repo}/issues",
420
+ {
421
+ owner,
422
+ repo,
423
+ labels: issueLabels.join(","),
424
+ per_page: 1,
425
+ },
426
+ );
427
+ const { status: getIssuesStatus, data: issues = [] } = getIssuesRes;
428
+
429
+ if (getIssuesStatus === 200) {
430
+ if (issues.length) {
431
+ const _issue = issues[0];
432
+ logger.s(
433
+ `Locate issue with labels ${issueLabels} in repository ${owner}/${repo} successfully:`,
434
+ _issue,
435
+ );
436
+ return _issue;
437
+ } else {
438
+ logger.w(
439
+ `Issue with labels ${issueLabels} in repository ${owner}/${repo} was not found.`,
440
+ );
441
+
442
+ if (isAdmin && !createIssueManually) {
443
+ const _issue = await runCreateIssue();
444
+ return _issue;
445
+ }
446
+ }
447
+ } else if (getIssuesStatus === 404) {
448
+ logger.w(
449
+ `Issue with labels ${issueLabels} in repository ${owner}/${repo} was not found.`,
450
+ );
451
+
452
+ if (isAdmin && !createIssueManually) {
453
+ const _issue = await runCreateIssue();
454
+ return _issue;
455
+ }
456
+ } else {
457
+ setAlert(
458
+ `Get issue with labels ${issueLabels} in repository ${owner}/${repo} failed: ${getIssuesRes}`,
459
+ );
460
+ logger.e(
461
+ `Get issue with labels ${issueLabels} in repository ${owner}/${repo} failed:`,
462
+ getIssuesRes,
463
+ );
464
+ }
465
+ }
466
+
467
+ return undefined;
468
+ },
469
+ {
470
+ ready: !!owner && !!repo && (!!issueNumber || !!issueId),
471
+ refreshDeps: [owner, repo, issueNumber, issueId],
472
+ onBefore: () => {
473
+ setIssue(undefined);
474
+ },
475
+ },
476
+ );
477
+
478
+ const {
479
+ data: comments = [],
480
+ mutate: setComments,
481
+ loading: getCommentsLoading,
482
+ } = useRequest(
483
+ async (): Promise<CommentType[]> => {
484
+ const { number: issueNumber } = issue as IssueType;
485
+ const from = (commentsPage - 1) * commentsPerPage + 1;
486
+ const to = commentsPage * commentsPerPage;
487
+
488
+ if (user) {
489
+ // Get comments via GraphQL, witch requires being logged and able to sort
490
+ const query = getIssueCommentsQL({
491
+ pagerDirection,
492
+ });
493
+
494
+ const getIssueCommentsRes: IssueCommentsQLResponse =
495
+ await octokit.graphql(query, {
496
+ owner,
497
+ repo,
498
+ id: issueNumber,
499
+ pageSize: commentsPerPage,
500
+ ...(commentsCursor ? { cursor: commentsCursor } : {}),
501
+ });
502
+
503
+ if (getIssueCommentsRes.repository) {
504
+ const _comments =
505
+ getIssueCommentsRes.repository.issue.comments.nodes.map(
506
+ (comment) => {
507
+ return {
508
+ ...comment,
509
+ id: comment.databaseId,
510
+ user: {
511
+ ...defaultUser,
512
+ avatar_url: comment.author.avatarUrl,
513
+ login: comment.author.login,
514
+ html_url: comment.author.url,
515
+ },
516
+ body_html: comment.bodyHTML,
517
+ created_at: comment.createdAt,
518
+ html_url: comment.resourcePath,
519
+ reactions: {
520
+ heart: comment.reactions?.totalCount ?? 0,
521
+ },
522
+ reactionsHeart: comment.reactions,
523
+ } as CommentType;
524
+ },
525
+ );
526
+ logger.s(
527
+ `Get comments from ${from} to ${to} successfully:`,
528
+ _comments,
529
+ );
530
+
531
+ if (_comments.length < commentsPerPage) {
532
+ setCommentsLoaded(true);
533
+ }
534
+
535
+ if (pagerDirection === "last") return [..._comments, ...comments];
536
+ else return [...comments, ..._comments];
537
+ } else {
538
+ setAlert(
539
+ `Get comments from ${from} to ${to} failed: ${getIssueCommentsRes}`,
540
+ );
541
+ logger.e(
542
+ `Get comments from ${from} to ${to} failed:`,
543
+ getIssueCommentsRes,
544
+ );
545
+ }
546
+ } else {
547
+ // Get comments via RESTful API, which not need be logged but unable to sort
548
+ const getIssueCommentsRes = await octokit.request(
549
+ "GET /repos/{owner}/{repo}/issues/{issue_number}/comments",
550
+ {
551
+ owner,
552
+ repo,
553
+ issue_number: issueNumber,
554
+ page: commentsPage,
555
+ per_page: commentsPerPage,
556
+ headers: {
557
+ accept: "application/vnd.github.v3.full+json",
558
+ },
559
+ },
560
+ );
561
+
562
+ if (getIssueCommentsRes.status === 200) {
563
+ const _comments = getIssueCommentsRes.data.map((comment) => {
564
+ const reactionsHeartTotalCount = comment.reactions?.heart ?? 0;
565
+ return {
566
+ ...defaultUser,
567
+ ...comment,
568
+ reactionsHeart: {
569
+ totalCount: reactionsHeartTotalCount,
570
+ viewerHasReacted: false,
571
+ nodes: [],
572
+ },
573
+ } as CommentType;
574
+ });
575
+ logger.s(
576
+ `Get comments from ${from} to ${to} successfully:`,
577
+ _comments,
578
+ );
579
+
580
+ if (_comments.length < commentsPerPage) {
581
+ setCommentsLoaded(true);
582
+ }
583
+
584
+ return [...comments, ..._comments];
585
+ } else {
586
+ setAlert(
587
+ `Get comments from ${from} to ${to} failed: ${getIssueCommentsRes}`,
588
+ );
589
+ logger.e(
590
+ `Get comments from ${from} to ${to} failed:`,
591
+ getIssueCommentsRes,
592
+ );
593
+ }
594
+ }
595
+
596
+ return comments;
597
+ },
598
+ {
599
+ ready: !!owner && !!repo && !!issue && !getUserLoading && !commentsLoaded,
600
+ refreshDeps: [commentsPage, issue, user, pagerDirection],
601
+ },
602
+ );
603
+
604
+ const {
605
+ data: localComments = [],
606
+ mutate: setLocalComments,
607
+ loading: createIssueCommentLoading,
608
+ run: runCreateIssueComment,
609
+ } = useRequest(
610
+ async (): Promise<CommentType[]> => {
611
+ const { number: issueNumber } = issue as IssueType;
612
+
613
+ const createIssueCommentRes = await octokit.request(
614
+ "POST /repos/{owner}/{repo}/issues/{issue_number}/comments",
615
+ {
616
+ owner,
617
+ repo,
618
+ issue_number: issueNumber,
619
+ body: inputComment,
620
+ headers: {
621
+ accept: "application/vnd.github.v3.full+json",
622
+ },
623
+ },
624
+ );
625
+
626
+ if (createIssueCommentRes.status === 201) {
627
+ const createdIssueComment = {
628
+ ...defaultUser,
629
+ ...createIssueCommentRes.data,
630
+ reactions: { heart: 0 },
631
+ reactionsHeart: {
632
+ totalCount: 0,
633
+ viewerHasReacted: false,
634
+ nodes: [],
635
+ },
636
+ } as CommentType;
637
+ logger.s(`Create issue comment successfully.`);
638
+
639
+ setInputComment("");
640
+ onCreateComment?.(createdIssueComment);
641
+
642
+ return localComments.concat([createdIssueComment]);
643
+ } else {
644
+ setAlert(`Create issue comment failed: ${createIssueCommentRes}`);
645
+ logger.e(`Create issue comment failed:`, createIssueCommentRes);
646
+ return localComments;
647
+ }
648
+ },
649
+ {
650
+ manual: true,
651
+ ready: !!owner && !!repo && !!issue && !!inputComment,
652
+ },
653
+ );
654
+
655
+ useEffect(() => {
656
+ setComments([]);
657
+ setCommentsCount(0);
658
+ setCommentsCursor("");
659
+ setCommentsPage(1);
660
+ setCommentsLoaded(false);
661
+ setLocalComments([]);
662
+
663
+ if (issue) {
664
+ setCommentsCount(issue.comments);
665
+ }
666
+ }, [issue, user, pagerDirection, setComments, setLocalComments]);
667
+
668
+ /** sorted all comments */
669
+ const allComments = useMemo(() => {
670
+ const _allComments = comments.concat(localComments);
671
+
672
+ if (commentsPagerDirection === "last" && !!user) {
673
+ // sort comments by date DESC
674
+ _allComments.reverse();
675
+ }
676
+
677
+ return _allComments;
678
+ }, [comments, commentsPagerDirection, localComments, user]);
679
+
680
+ const allCommentsCount = commentsCount + (localComments ?? []).length;
681
+
682
+ useEffect(() => {
683
+ updateCountCallback?.(allCommentsCount);
684
+ }, [allCommentsCount, updateCountCallback]);
685
+
686
+ const {
687
+ data: commentHtml = "",
688
+ mutate: setCommentHtml,
689
+ loading: getCommentHtmlLoading,
690
+ run: runGetCommentHtml,
691
+ cancel: cancelGetCommentHtml,
692
+ } = useRequest(
693
+ async () => {
694
+ const getPreviewedHtmlRes = await octokit.request("POST /markdown", {
695
+ text: inputComment,
696
+ });
697
+
698
+ if (getPreviewedHtmlRes.status === 200) {
699
+ const _commentHtml = getPreviewedHtmlRes.data;
700
+ return _commentHtml;
701
+ } else {
702
+ setAlert(`Preview rendered comment failed: ${getPreviewedHtmlRes}`);
703
+ logger.e(`Preview rendered comment failed:`, getPreviewedHtmlRes);
704
+ return "";
705
+ }
706
+ },
707
+ {
708
+ manual: true,
709
+ onBefore: () => {
710
+ setCommentHtml("");
711
+ },
712
+ },
713
+ );
714
+
715
+ const { loading: likeOrDislikeCommentLoading, run: runLikeOrDislikeComment } =
716
+ useRequest(
717
+ async (like: boolean, commentId: number, reactionId?: number) => {
718
+ const deletedReactionId = reactionId;
719
+ let createdReactionId: number = -1;
720
+ if (like) {
721
+ const likeCommentRes = await octokit.request(
722
+ "POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions",
723
+ {
724
+ owner,
725
+ repo,
726
+ comment_id: commentId,
727
+ content: "heart",
728
+ },
729
+ );
730
+
731
+ if (likeCommentRes.status === 201) {
732
+ logger.s(`You like the comment!`);
733
+ createdReactionId = likeCommentRes.data.id;
734
+ } else if (likeCommentRes.status === 200) {
735
+ logger.i(`You already liked the comment!`);
736
+ } else {
737
+ logger.e(`Failed to like the comment.`);
738
+ return;
739
+ }
740
+ } else {
741
+ if (reactionId) {
742
+ const dislikeCommentRes = await octokit.request(
743
+ "DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions/{reaction_id}",
744
+ {
745
+ owner,
746
+ repo,
747
+ comment_id: commentId,
748
+ reaction_id: reactionId,
749
+ },
750
+ );
751
+
752
+ if (dislikeCommentRes.status === 204) {
753
+ logger.s(`You unlike the comment.`);
754
+ } else {
755
+ logger.e(`Failed to unlike the comment.`);
756
+ return;
757
+ }
758
+ } else {
759
+ logger.e("Reaction ID is not provided.");
760
+ return;
761
+ }
762
+ }
763
+
764
+ let isLocalComment = false;
765
+ let targetComment = comments.find(
766
+ (comment) => comment.id === commentId,
767
+ );
768
+ if (!targetComment) {
769
+ targetComment = localComments.find(
770
+ (comment) => comment.id === commentId,
771
+ );
772
+ isLocalComment = true;
773
+ }
774
+ if (targetComment) {
775
+ const username = (user as UserType).login;
776
+
777
+ const prevHeartCount = targetComment.reactions?.heart ?? 0;
778
+ const newHeartCount = like ? prevHeartCount + 1 : prevHeartCount - 1;
779
+
780
+ const prevHeartNodes = targetComment.reactionsHeart?.nodes ?? [];
781
+ const newHeartNodes = like
782
+ ? prevHeartNodes.concat([
783
+ {
784
+ databaseId: createdReactionId,
785
+ user: {
786
+ login: username,
787
+ },
788
+ },
789
+ ])
790
+ : prevHeartNodes.filter(
791
+ (node) => node.databaseId !== deletedReactionId,
792
+ );
793
+
794
+ targetComment.reactions = {
795
+ heart: newHeartCount,
796
+ };
797
+ targetComment.reactionsHeart = {
798
+ totalCount: newHeartCount,
799
+ viewerHasReacted: like,
800
+ nodes: newHeartNodes,
801
+ };
802
+ }
803
+ if (isLocalComment) {
804
+ setLocalComments((prev) => [...(prev ?? [])]);
805
+ } else {
806
+ setComments((prev) => [...(prev ?? [])]);
807
+ }
808
+ },
809
+ {
810
+ manual: true,
811
+ ready: !!owner && !!repo && !!user,
812
+ },
813
+ );
814
+
815
+ const initialized = useMemo(
816
+ () =>
817
+ !getAccessTokenLoading &&
818
+ !getUserLoading &&
819
+ !createIssueLoading &&
820
+ !getIssueLoading,
821
+ [
822
+ createIssueLoading,
823
+ getAccessTokenLoading,
824
+ getIssueLoading,
825
+ getUserLoading,
826
+ ],
827
+ );
828
+
829
+ const issueCreated = useMemo(
830
+ () => initialized && !!issue,
831
+ [initialized, issue],
832
+ );
833
+
834
+ const hidePopup = useCallback((e: MouseEvent) => {
835
+ const target = e.target as HTMLElement;
836
+ if (target && hasClassInParent(target, "gt-user", "gt-popup")) {
837
+ return;
838
+ }
839
+ document.removeEventListener("click", hidePopup);
840
+ setShowPopup(false);
841
+ }, []);
842
+
843
+ const onShowOrHidePopup: React.MouseEventHandler<HTMLDivElement> = (e) => {
844
+ e.preventDefault();
845
+ e.stopPropagation();
846
+
847
+ setShowPopup((visible) => {
848
+ if (visible) {
849
+ document.removeEventListener("click", hidePopup);
850
+ } else {
851
+ document.addEventListener("click", hidePopup);
852
+ }
853
+ return !visible;
854
+ });
855
+ };
856
+
857
+ const onLogin = () => {
858
+ const url = getAuthorizeUrl(clientID);
859
+ window.location.href = url;
860
+ };
861
+
862
+ const onLogout = () => {
863
+ setAccessToken(undefined);
864
+ localStorage.removeItem(ACCESS_TOKEN_KEY);
865
+ setUser(undefined);
866
+ };
867
+
868
+ const onCommentInputFocus: React.FocusEventHandler<HTMLTextAreaElement> = (
869
+ e,
870
+ ) => {
871
+ if (!distractionFreeMode) return e.preventDefault();
872
+ setIsInputFocused(true);
873
+ };
874
+
875
+ const onCommentInputBlur: React.FocusEventHandler<HTMLTextAreaElement> = (
876
+ e,
877
+ ) => {
878
+ if (!distractionFreeMode) return e.preventDefault();
879
+ setIsInputFocused(false);
880
+ };
881
+
882
+ const onCommentInputKeyDown: React.KeyboardEventHandler<
883
+ HTMLTextAreaElement
884
+ > = (e) => {
885
+ if (enableHotKey && (e.metaKey || e.ctrlKey) && e.keyCode === 13) {
886
+ runCreateIssueComment();
887
+ }
888
+ };
889
+
890
+ const onCommentInputPreview: React.MouseEventHandler<
891
+ HTMLButtonElement
892
+ > = () => {
893
+ if (isPreviewComment) {
894
+ setIsPreviewComment(false);
895
+ cancelGetCommentHtml();
896
+ } else {
897
+ setIsPreviewComment(true);
898
+ runGetCommentHtml();
899
+ }
900
+ };
901
+
902
+ const onReplyComment: CommentProps["onReply"] = (repliedComment) => {
903
+ const { body: repliedCommentBody = "", user: repliedCommentUser } =
904
+ repliedComment;
905
+ let repliedCommentBodyArray = repliedCommentBody.split("\n");
906
+ const repliedCommentUsername = repliedCommentUser?.login;
907
+
908
+ if (repliedCommentUsername) {
909
+ repliedCommentBodyArray.unshift(`@${repliedCommentUsername}`);
910
+ }
911
+ repliedCommentBodyArray = repliedCommentBodyArray.map(
912
+ (text) => `> ${text}`,
913
+ );
914
+
915
+ if (inputComment) {
916
+ repliedCommentBodyArray.unshift("", "");
917
+ }
918
+
919
+ repliedCommentBodyArray.push("", "");
920
+
921
+ const newComment = `${inputComment}${repliedCommentBodyArray.join("\n")}`;
922
+ setInputComment(newComment);
923
+ textareaRef.current?.focus();
924
+ };
925
+
926
+ if (!isModernBrowser) {
927
+ logger.e(
928
+ `Gitalk React can only be rendered well in modern browser that supports CSS variables and ES2020.`,
929
+ `If you have compatibility requirements, please consider using the original project which is compatible with older browsers: https://github.com/gitalk/gitalk`,
930
+ );
931
+ return null;
932
+ }
933
+
934
+ if (!(clientID && clientSecret)) {
935
+ logger.e(
936
+ `You must specify the \`clientId\` and \`clientSecret\` of Github APP`,
937
+ );
938
+ return null;
939
+ }
940
+
941
+ if (!(repo && owner)) {
942
+ logger.e(`You must specify the \`owner\` and \`repo\` of Github`);
943
+ return null;
944
+ }
945
+
946
+ if (!(Array.isArray(admin) && admin.length > 0)) {
947
+ logger.e(`You must specify the \`admin\` for the Github repository`);
948
+ return null;
949
+ }
950
+
951
+ const renderInitializing = () => {
952
+ return (
953
+ <div className="gt-initing">
954
+ <i className="gt-loader" />
955
+ <p className="gt-initing-text">{polyglot.t("init")}</p>
956
+ </div>
957
+ );
958
+ };
959
+
960
+ const renderIssueNotInitialized = () => {
961
+ return (
962
+ <div className="gt-no-init" key="no-init">
963
+ <p
964
+ dangerouslySetInnerHTML={{
965
+ __html: polyglot.t("no-found-related", {
966
+ link: `<a href="https://github.com/${owner}/${repo}/issues" target="_blank" rel="noopener noreferrer">Issues</a>`,
967
+ }),
968
+ }}
969
+ />
970
+ <p>
971
+ {polyglot.t("please-contact", {
972
+ user: admin.map((u) => `@${u}`).join(" "),
973
+ })}
974
+ </p>
975
+ {isAdmin ? (
976
+ <p>
977
+ <Button
978
+ onClick={runCreateIssue}
979
+ isLoading={createIssueLoading}
980
+ text={polyglot.t("init-issue")}
981
+ />
982
+ </p>
983
+ ) : null}
984
+ {!user && (
985
+ <Button
986
+ className="gt-btn-login"
987
+ onClick={onLogin}
988
+ text={polyglot.t("login-with-github")}
989
+ />
990
+ )}
991
+ </div>
992
+ );
993
+ };
994
+
995
+ const renderHeader = () => {
996
+ return (
997
+ <div className="gt-header" key="header">
998
+ {user ? (
999
+ <Avatar
1000
+ className="gt-header-avatar"
1001
+ src={user.avatar_url}
1002
+ alt={user.login}
1003
+ href={user.html_url}
1004
+ />
1005
+ ) : (
1006
+ <a className="gt-avatar-github" onClick={onLogin}>
1007
+ <Svg className="gt-ico-github" icon={Github} />
1008
+ </a>
1009
+ )}
1010
+ <div className="gt-header-comment">
1011
+ <textarea
1012
+ ref={textareaRef}
1013
+ className="gt-header-textarea"
1014
+ style={{ display: isPreviewComment ? "none" : undefined }}
1015
+ value={inputComment}
1016
+ onChange={(e) => setInputComment(e.target.value)}
1017
+ onFocus={onCommentInputFocus}
1018
+ onBlur={onCommentInputBlur}
1019
+ onKeyDown={onCommentInputKeyDown}
1020
+ placeholder={polyglot.t("leave-a-comment")}
1021
+ />
1022
+ <div
1023
+ className="gt-header-preview markdown-body"
1024
+ style={{ display: isPreviewComment ? undefined : "none" }}
1025
+ dangerouslySetInnerHTML={{
1026
+ __html: commentHtml,
1027
+ }}
1028
+ />
1029
+ <div className="gt-header-controls">
1030
+ <a
1031
+ className="gt-header-controls-tip"
1032
+ href="https://guides.github.com/features/mastering-markdown/"
1033
+ target="_blank"
1034
+ rel="noopener noreferrer"
1035
+ >
1036
+ <Svg
1037
+ className="gt-ico-tip"
1038
+ icon={Tip}
1039
+ text={polyglot.t("support-markdown")}
1040
+ />
1041
+ </a>
1042
+
1043
+ <Button
1044
+ className="gt-btn-preview gt-btn--secondary"
1045
+ onClick={onCommentInputPreview}
1046
+ text={
1047
+ isPreviewComment ? polyglot.t("edit") : polyglot.t("preview")
1048
+ }
1049
+ isLoading={getCommentHtmlLoading}
1050
+ disabled={false}
1051
+ />
1052
+
1053
+ {user ? (
1054
+ <Button
1055
+ className="gt-btn-public"
1056
+ onClick={runCreateIssueComment}
1057
+ text={polyglot.t("comment")}
1058
+ isLoading={createIssueCommentLoading}
1059
+ disabled={createIssueCommentLoading || !inputComment}
1060
+ />
1061
+ ) : (
1062
+ <Button
1063
+ className="gt-btn-login"
1064
+ onClick={onLogin}
1065
+ text={polyglot.t("login-with-github")}
1066
+ />
1067
+ )}
1068
+ </div>
1069
+ </div>
1070
+ </div>
1071
+ );
1072
+ };
1073
+
1074
+ // Why forwardRef? https://www.npmjs.com/package/react-flip-move#usage-with-functional-components
1075
+ const CommentWithForwardedRef = forwardRef<
1076
+ HTMLDivElement,
1077
+ { comment: CommentType }
1078
+ >(({ comment }, ref) => {
1079
+ const {
1080
+ id: commentId,
1081
+ user: commentAuthor,
1082
+ reactionsHeart: commentReactionsHeart,
1083
+ } = comment;
1084
+
1085
+ const commentAuthorName = commentAuthor?.login;
1086
+ const isAuthor =
1087
+ !!user && !!commentAuthorName && user.login === commentAuthorName;
1088
+ const isAdmin =
1089
+ !!commentAuthorName &&
1090
+ !!admin.find(
1091
+ (username) =>
1092
+ username.toLowerCase() === commentAuthorName.toLowerCase(),
1093
+ );
1094
+ const heartReactionId = commentReactionsHeart?.nodes.find(
1095
+ (node) => node.user.login === user?.login,
1096
+ )?.databaseId;
1097
+
1098
+ return (
1099
+ <div ref={ref}>
1100
+ <Comment
1101
+ comment={comment}
1102
+ isAuthor={isAuthor}
1103
+ isAdmin={isAdmin}
1104
+ onReply={onReplyComment}
1105
+ onLike={(like) => {
1106
+ runLikeOrDislikeComment(like, commentId, heartReactionId);
1107
+ }}
1108
+ likeLoading={likeOrDislikeCommentLoading}
1109
+ />
1110
+ </div>
1111
+ );
1112
+ });
1113
+
1114
+ const renderCommentList = () => {
1115
+ return (
1116
+ <div className="gt-comments" key="comments">
1117
+ <FlipMove {...flipMoveOptions}>
1118
+ {allComments.map((comment) => (
1119
+ <CommentWithForwardedRef key={comment.id} comment={comment} />
1120
+ ))}
1121
+ </FlipMove>
1122
+ {!allCommentsCount && (
1123
+ <p className="gt-comments-null">
1124
+ {polyglot.t("first-comment-person")}
1125
+ </p>
1126
+ )}
1127
+ {!commentsLoaded && allCommentsCount ? (
1128
+ <div className="gt-comments-controls">
1129
+ <Button
1130
+ className="gt-btn-loadmore"
1131
+ onClick={() => setCommentsPage((prev) => prev + 1)}
1132
+ isLoading={getCommentsLoading}
1133
+ text={polyglot.t("load-more")}
1134
+ />
1135
+ </div>
1136
+ ) : null}
1137
+ </div>
1138
+ );
1139
+ };
1140
+
1141
+ const renderMeta = () => {
1142
+ const isDesc = commentsPagerDirection === "last";
1143
+
1144
+ return (
1145
+ <div className="gt-meta" key="meta">
1146
+ <span
1147
+ className="gt-counts"
1148
+ dangerouslySetInnerHTML={{
1149
+ __html: polyglot.t("counts", {
1150
+ counts: `<a class="gt-link gt-link-counts" href="${issue?.html_url}" target="_blank" rel="noopener noreferrer">${allCommentsCount}</a>`,
1151
+ smart_count: allCommentsCount,
1152
+ }),
1153
+ }}
1154
+ />
1155
+ {showPopup && (
1156
+ <div className="gt-popup">
1157
+ {user
1158
+ ? [
1159
+ <Action
1160
+ key={"sort-asc"}
1161
+ className={`gt-action-sortasc${!isDesc ? " is--active" : ""}`}
1162
+ onClick={() => setCommentsPagerDirection("first")}
1163
+ text={polyglot.t("sort-asc")}
1164
+ />,
1165
+ <Action
1166
+ key={"sort-desc"}
1167
+ className={`gt-action-sortdesc${isDesc ? " is--active" : ""}`}
1168
+ onClick={() => setCommentsPagerDirection("last")}
1169
+ text={polyglot.t("sort-desc")}
1170
+ />,
1171
+ ]
1172
+ : null}
1173
+ {user ? (
1174
+ <Action
1175
+ className="gt-action-logout"
1176
+ onClick={onLogout}
1177
+ text={polyglot.t("logout")}
1178
+ />
1179
+ ) : (
1180
+ <a className="gt-action gt-action-login" onClick={onLogin}>
1181
+ {polyglot.t("login-with-github")}
1182
+ </a>
1183
+ )}
1184
+ <div className="gt-copyright">
1185
+ <a
1186
+ className="gt-link gt-link-project"
1187
+ href={HOMEPAGE}
1188
+ target="_blank"
1189
+ rel="noopener noreferrer"
1190
+ >
1191
+ GitalkR
1192
+ </a>
1193
+ <span className="gt-version">{VERSION}</span>
1194
+ </div>
1195
+ </div>
1196
+ )}
1197
+ <div className="gt-user">
1198
+ <div
1199
+ className={`gt-user-inner${showPopup ? " is--poping" : ""}`}
1200
+ onClick={onShowOrHidePopup}
1201
+ >
1202
+ <span className="gt-user-name">
1203
+ {user?.login ?? polyglot.t("anonymous")}
1204
+ </span>
1205
+ <Svg className="gt-ico-arrdown" icon={ArrowDown} />
1206
+ </div>
1207
+ </div>
1208
+ </div>
1209
+ );
1210
+ };
1211
+
1212
+ return (
1213
+ <I18nContext.Provider
1214
+ value={{ language, polyglot, dateFnsLocaleMap: DATE_FNS_LOCALE_MAP }}
1215
+ >
1216
+ <div
1217
+ className={`gt-container ${isInputFocused ? "gt-input-focused" : ""} ${className}`}
1218
+ {...restProps}
1219
+ >
1220
+ {alert && <div className="gt-error">{alert}</div>}
1221
+ {initialized
1222
+ ? issueCreated
1223
+ ? [renderMeta(), renderHeader(), renderCommentList()]
1224
+ : renderIssueNotInitialized()
1225
+ : renderInitializing()}
1226
+ </div>
1227
+ </I18nContext.Provider>
1228
+ );
1229
+ };
1230
+
1231
+ export default Gitalk;