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.
- package/LICENSE +9 -0
- package/README-zh-CN.md +257 -0
- package/README.md +257 -0
- package/dist/gitalk-dark.css +1 -0
- package/dist/gitalk-light.css +1 -0
- package/dist/gitalk.d.ts +445 -0
- package/dist/gitalk.js +12560 -0
- package/dist/gitalk.umd.cjs +121 -0
- package/lib/assets/arrow-down.svg +1 -0
- package/lib/assets/edit.svg +3 -0
- package/lib/assets/github.svg +3 -0
- package/lib/assets/heart-filled.svg +3 -0
- package/lib/assets/heart.svg +3 -0
- package/lib/assets/reply.svg +3 -0
- package/lib/assets/tip.svg +8 -0
- package/lib/components/action.tsx +21 -0
- package/lib/components/avatar.tsx +42 -0
- package/lib/components/button.tsx +35 -0
- package/lib/components/comment.tsx +153 -0
- package/lib/components/svg.tsx +29 -0
- package/lib/constants/index.ts +43 -0
- package/lib/contexts/I18nContext.ts +18 -0
- package/lib/gitalk.tsx +1231 -0
- package/lib/i18n/de.json +20 -0
- package/lib/i18n/en.json +20 -0
- package/lib/i18n/es-ES.json +20 -0
- package/lib/i18n/fa.json +20 -0
- package/lib/i18n/fr.json +20 -0
- package/lib/i18n/index.ts +40 -0
- package/lib/i18n/ja.json +20 -0
- package/lib/i18n/ko.json +20 -0
- package/lib/i18n/pl.json +21 -0
- package/lib/i18n/ru.json +20 -0
- package/lib/i18n/zh-CN.json +20 -0
- package/lib/i18n/zh-TW.json +20 -0
- package/lib/interfaces/index.ts +30 -0
- package/lib/services/graphql/comment.ts +85 -0
- package/lib/services/request.ts +24 -0
- package/lib/services/user.ts +40 -0
- package/lib/themes/base.scss +592 -0
- package/lib/themes/gitalk-dark.scss +24 -0
- package/lib/themes/gitalk-light.scss +24 -0
- package/lib/utils/compatibility.ts +35 -0
- package/lib/utils/dom.ts +15 -0
- package/lib/utils/logger.ts +56 -0
- package/lib/utils/query.ts +19 -0
- 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;
|