gitalk-react 1.0.0-beta.5 → 1.0.0-beta.7

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.
@@ -0,0 +1,164 @@
1
+ import { useRequest } from "ahooks";
2
+ import type { Octokit } from "octokit";
3
+ import React, { forwardRef, useContext, useRef, useState } from "react";
4
+
5
+ import Github from "../assets/github.svg?raw";
6
+ import Tip from "../assets/tip.svg?raw";
7
+ import I18nContext from "../contexts/I18nContext";
8
+ import type { User } from "../interfaces";
9
+ import Avatar from "./avatar";
10
+ import Button from "./button";
11
+ import Svg from "./svg";
12
+
13
+ interface CommentTextareaProps
14
+ extends React.DetailedHTMLProps<
15
+ React.TextareaHTMLAttributes<HTMLTextAreaElement>,
16
+ HTMLTextAreaElement
17
+ > {
18
+ value: string;
19
+ octokit: Octokit;
20
+ user?: User;
21
+ onLogin: () => void;
22
+ onCreateComment: (comment: string) => Promise<void>;
23
+ createCommentLoading: boolean;
24
+ onPreviewError: (error: unknown) => void;
25
+ }
26
+
27
+ const CommentTextarea = forwardRef<HTMLTextAreaElement, CommentTextareaProps>(
28
+ (props, ref) => {
29
+ const {
30
+ value: inputComment,
31
+ octokit,
32
+ user,
33
+ onLogin,
34
+ onCreateComment,
35
+ createCommentLoading,
36
+ onPreviewError,
37
+ ...restProps
38
+ } = props;
39
+
40
+ const { polyglot } = useContext(I18nContext);
41
+
42
+ const [isPreviewComment, setIsPreviewComment] = useState<boolean>(false);
43
+
44
+ const prevInputCommentRef = useRef<string>();
45
+
46
+ const {
47
+ data: commentHtml = "",
48
+ loading: getCommentHtmlLoading,
49
+ run: runGetCommentHtml,
50
+ } = useRequest(
51
+ async (): Promise<string> => {
52
+ if (prevInputCommentRef.current === inputComment) return commentHtml;
53
+
54
+ const getPreviewedHtmlRes = await octokit.request("POST /markdown", {
55
+ text: inputComment,
56
+ });
57
+
58
+ if (getPreviewedHtmlRes.status === 200) {
59
+ prevInputCommentRef.current = inputComment;
60
+
61
+ const _commentHtml = getPreviewedHtmlRes.data;
62
+ return _commentHtml;
63
+ } else {
64
+ onPreviewError(getPreviewedHtmlRes);
65
+ return "";
66
+ }
67
+ },
68
+ {
69
+ manual: true,
70
+ },
71
+ );
72
+
73
+ const onCommentInputPreview: React.MouseEventHandler<
74
+ HTMLButtonElement
75
+ > = () => {
76
+ if (isPreviewComment) {
77
+ setIsPreviewComment(false);
78
+ } else {
79
+ setIsPreviewComment(true);
80
+ runGetCommentHtml();
81
+ }
82
+ };
83
+
84
+ return (
85
+ <div className="gt-header" key="header">
86
+ {user ? (
87
+ <Avatar
88
+ className="gt-header-avatar"
89
+ src={user.avatar_url}
90
+ alt={user.login}
91
+ href={user.html_url}
92
+ />
93
+ ) : (
94
+ <a className="gt-avatar-github" onClick={onLogin}>
95
+ <Svg className="gt-ico-github" icon={Github} />
96
+ </a>
97
+ )}
98
+ <div className="gt-header-comment">
99
+ <textarea
100
+ {...restProps}
101
+ ref={ref}
102
+ value={inputComment}
103
+ className="gt-header-textarea"
104
+ style={{ display: isPreviewComment ? "none" : undefined }}
105
+ />
106
+ <div
107
+ className="gt-header-preview markdown-body"
108
+ style={{ display: isPreviewComment ? undefined : "none" }}
109
+ dangerouslySetInnerHTML={{
110
+ __html: getCommentHtmlLoading
111
+ ? "<span>Loading preview...</span>"
112
+ : commentHtml,
113
+ }}
114
+ />
115
+ <div className="gt-header-controls">
116
+ <a
117
+ className="gt-header-controls-tip"
118
+ href="https://guides.github.com/features/mastering-markdown/"
119
+ target="_blank"
120
+ rel="noopener noreferrer"
121
+ >
122
+ <Svg
123
+ className="gt-ico-tip"
124
+ icon={Tip}
125
+ text={polyglot.t("support-markdown")}
126
+ />
127
+ </a>
128
+
129
+ <Button
130
+ className="gt-btn-preview gt-btn--secondary"
131
+ onClick={onCommentInputPreview}
132
+ text={
133
+ isPreviewComment ? polyglot.t("edit") : polyglot.t("preview")
134
+ }
135
+ isLoading={getCommentHtmlLoading}
136
+ disabled={false}
137
+ />
138
+
139
+ {user ? (
140
+ <Button
141
+ className="gt-btn-public"
142
+ onClick={async () => {
143
+ await onCreateComment(inputComment);
144
+ setIsPreviewComment(false);
145
+ }}
146
+ text={polyglot.t("comment")}
147
+ isLoading={createCommentLoading}
148
+ disabled={createCommentLoading || !inputComment}
149
+ />
150
+ ) : (
151
+ <Button
152
+ className="gt-btn-login"
153
+ onClick={onLogin}
154
+ text={polyglot.t("login-with-github")}
155
+ />
156
+ )}
157
+ </div>
158
+ </div>
159
+ </div>
160
+ );
161
+ },
162
+ );
163
+
164
+ export default CommentTextarea;
@@ -1,12 +1,13 @@
1
1
  import { formatDistanceToNow, parseISO } from "date-fns";
2
- import React, { useContext, useEffect, useMemo, useRef } from "react";
2
+ import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
3
3
 
4
+ import ArrowDown from "../assets/arrow-down.svg?raw";
4
5
  import Edit from "../assets/edit.svg?raw";
5
6
  import Heart from "../assets/heart.svg?raw";
6
7
  import HeartFilled from "../assets/heart-filled.svg?raw";
7
8
  import Reply from "../assets/reply.svg?raw";
8
9
  import I18nContext from "../contexts/I18nContext";
9
- import type { Comment as CommentType } from "../interfaces";
10
+ import type { Comment as CommentType, GitalkProps } from "../interfaces";
10
11
  import Avatar from "./avatar";
11
12
  import Svg from "./svg";
12
13
 
@@ -21,6 +22,7 @@ export interface CommentProps
21
22
  onReply: (comment: CommentType) => void;
22
23
  onLike: (like: boolean, comment: CommentType) => void;
23
24
  likeLoading: boolean;
25
+ collapsedHeight?: GitalkProps["collapsedHeight"];
24
26
  }
25
27
 
26
28
  const Comment: React.FC<CommentProps> = ({
@@ -30,11 +32,14 @@ const Comment: React.FC<CommentProps> = ({
30
32
  onReply,
31
33
  onLike,
32
34
  likeLoading,
35
+ collapsedHeight,
33
36
  className = "",
34
37
  ...restProps
35
38
  }) => {
36
39
  const ref = useRef<HTMLDivElement>(null);
37
40
 
41
+ const [collapsed, setCollapsed] = useState<boolean>(false);
42
+
38
43
  const { language, polyglot, dateFnsLocaleMap } = useContext(I18nContext);
39
44
 
40
45
  const { body_html, created_at, user, reactionsHeart, html_url } = comment;
@@ -72,6 +77,17 @@ const Comment: React.FC<CommentProps> = ({
72
77
  return () => {};
73
78
  }, []);
74
79
 
80
+ useEffect(() => {
81
+ const commentElement = ref.current;
82
+
83
+ if (commentElement && collapsedHeight) {
84
+ const commentElementHeight = commentElement.clientHeight;
85
+ if (commentElementHeight > collapsedHeight) {
86
+ setCollapsed(true);
87
+ }
88
+ }
89
+ }, [collapsedHeight]);
90
+
75
91
  return (
76
92
  <div
77
93
  ref={ref}
@@ -85,7 +101,12 @@ const Comment: React.FC<CommentProps> = ({
85
101
  href={user?.html_url}
86
102
  />
87
103
 
88
- <div className="gt-comment-content">
104
+ <div
105
+ className="gt-comment-content"
106
+ style={
107
+ collapsed ? { maxHeight: collapsedHeight, overflow: "hidden" } : {}
108
+ }
109
+ >
89
110
  <div className="gt-comment-header">
90
111
  <a
91
112
  className="gt-comment-username"
@@ -96,12 +117,15 @@ const Comment: React.FC<CommentProps> = ({
96
117
  {user?.login}
97
118
  </a>
98
119
  <div className="gt-comment-date" title={created_at}>
99
- {polyglot.t("commented") +
100
- " " +
101
- formatDistanceToNow(parseISO(created_at), {
120
+ <span className="gt-comment-date__prefix">
121
+ {polyglot.t("commented")}
122
+ </span>
123
+ <span className="gt-comment-date__time">
124
+ {formatDistanceToNow(parseISO(created_at), {
102
125
  addSuffix: true,
103
126
  locale: dateFnsLocaleMap[language],
104
127
  })}
128
+ </span>
105
129
  </div>
106
130
  <div className="gt-comment-actions">
107
131
  <a
@@ -145,6 +169,14 @@ const Comment: React.FC<CommentProps> = ({
145
169
  __html: body_html ?? "",
146
170
  }}
147
171
  />
172
+ {collapsed && (
173
+ <div
174
+ className="gt-comment-collapse"
175
+ onClick={() => setCollapsed(false)}
176
+ >
177
+ <Svg className="gt-ico-collapse" icon={ArrowDown} />
178
+ </div>
179
+ )}
148
180
  </div>
149
181
  </div>
150
182
  );
@@ -0,0 +1,106 @@
1
+ import React, { forwardRef, useContext } from "react";
2
+ import FlipMove from "react-flip-move";
3
+
4
+ import I18nContext from "../contexts/I18nContext";
5
+ import type { Comment as CommentType, GitalkProps, User } from "../interfaces";
6
+ import Button from "./button";
7
+ import Comment, { type CommentProps } from "./comment";
8
+
9
+ interface CommentWithForwardedRefProps
10
+ extends Pick<
11
+ CommentProps,
12
+ "comment" | "onReply" | "likeLoading" | "collapsedHeight"
13
+ > {
14
+ onLike: (like: boolean, commentId: number, heartReactionId?: number) => void;
15
+ user?: User;
16
+ admin: GitalkProps["admin"];
17
+ }
18
+
19
+ // Why forwardRef? https://www.npmjs.com/package/react-flip-move#usage-with-functional-components
20
+ const CommentWithForwardedRef = forwardRef<
21
+ HTMLDivElement,
22
+ CommentWithForwardedRefProps
23
+ >(({ comment, user, admin, onLike, ...restProps }, ref) => {
24
+ const {
25
+ id: commentId,
26
+ user: commentAuthor,
27
+ reactionsHeart: commentReactionsHeart,
28
+ } = comment;
29
+
30
+ const commentAuthorName = commentAuthor?.login;
31
+ const isAuthor =
32
+ !!user && !!commentAuthorName && user.login === commentAuthorName;
33
+ const isAdmin =
34
+ !!commentAuthorName &&
35
+ !!admin.find(
36
+ (username) => username.toLowerCase() === commentAuthorName.toLowerCase(),
37
+ );
38
+ const heartReactionId = commentReactionsHeart?.nodes.find(
39
+ (node) => node.user.login === user?.login,
40
+ )?.databaseId;
41
+
42
+ return (
43
+ <div ref={ref}>
44
+ <Comment
45
+ {...restProps}
46
+ comment={comment}
47
+ isAuthor={isAuthor}
48
+ isAdmin={isAdmin}
49
+ onLike={(like) => {
50
+ onLike(like, commentId, heartReactionId);
51
+ }}
52
+ />
53
+ </div>
54
+ );
55
+ });
56
+
57
+ interface CommentsListProps
58
+ extends Pick<GitalkProps, "flipMoveOptions">,
59
+ Omit<CommentWithForwardedRefProps, "comment"> {
60
+ comments: CommentType[];
61
+ commentsCount: number;
62
+ onGetComments: () => void;
63
+ getCommentsLoading: boolean;
64
+ }
65
+
66
+ const CommentsList: React.FC<CommentsListProps> = (props) => {
67
+ const {
68
+ comments,
69
+ commentsCount,
70
+ onGetComments,
71
+ getCommentsLoading,
72
+ flipMoveOptions,
73
+ ...restCommentProps
74
+ } = props;
75
+
76
+ const { polyglot } = useContext(I18nContext);
77
+
78
+ return (
79
+ <div className="gt-comments" key="comments">
80
+ <FlipMove {...flipMoveOptions}>
81
+ {comments.map((comment) => (
82
+ <CommentWithForwardedRef
83
+ {...restCommentProps}
84
+ key={comment.id}
85
+ comment={comment}
86
+ />
87
+ ))}
88
+ </FlipMove>
89
+ {!commentsCount && (
90
+ <p className="gt-comments-null">{polyglot.t("first-comment-person")}</p>
91
+ )}
92
+ {commentsCount > comments.length ? (
93
+ <div className="gt-comments-controls">
94
+ <Button
95
+ className="gt-btn-loadmore"
96
+ onClick={onGetComments}
97
+ isLoading={getCommentsLoading}
98
+ text={polyglot.t("load-more")}
99
+ />
100
+ </div>
101
+ ) : null}
102
+ </div>
103
+ );
104
+ };
105
+
106
+ export default CommentsList;
@@ -0,0 +1,129 @@
1
+ import React, { useCallback, useContext, useState } from "react";
2
+
3
+ import ArrowDown from "../assets/arrow-down.svg?raw";
4
+ import { HOMEPAGE, VERSION } from "../constants";
5
+ import I18nContext from "../contexts/I18nContext";
6
+ import type { GitalkProps, Issue, User } from "../interfaces";
7
+ import { hasClassInParent } from "../utils/dom";
8
+ import Action from "./action";
9
+ import Svg from "./svg";
10
+
11
+ interface MetaProps {
12
+ issue?: Issue;
13
+ user?: User;
14
+ commentsCount: number;
15
+ pagerDirection: GitalkProps["pagerDirection"];
16
+ onPagerDirectionChange: (
17
+ direction: NonNullable<GitalkProps["pagerDirection"]>,
18
+ ) => void;
19
+ onLogin: () => void;
20
+ onLogout: () => void;
21
+ }
22
+
23
+ const Meta: React.FC<MetaProps> = (props) => {
24
+ const {
25
+ issue,
26
+ user,
27
+ commentsCount,
28
+ pagerDirection,
29
+ onPagerDirectionChange,
30
+ onLogin,
31
+ onLogout,
32
+ } = props;
33
+
34
+ const { polyglot } = useContext(I18nContext);
35
+
36
+ const [showPopup, setShowPopup] = useState<boolean>(false);
37
+
38
+ const hidePopup = useCallback((e: MouseEvent) => {
39
+ const target = e.target as HTMLElement;
40
+ if (target && hasClassInParent(target, "gt-user", "gt-popup")) {
41
+ return;
42
+ }
43
+ document.removeEventListener("click", hidePopup);
44
+ setShowPopup(false);
45
+ }, []);
46
+
47
+ const onShowOrHidePopup: React.MouseEventHandler<HTMLDivElement> = (e) => {
48
+ e.preventDefault();
49
+ e.stopPropagation();
50
+
51
+ setShowPopup((visible) => {
52
+ if (visible) {
53
+ document.removeEventListener("click", hidePopup);
54
+ } else {
55
+ document.addEventListener("click", hidePopup);
56
+ }
57
+ return !visible;
58
+ });
59
+ };
60
+
61
+ return (
62
+ <div className="gt-meta" key="meta">
63
+ <span
64
+ className="gt-counts"
65
+ dangerouslySetInnerHTML={{
66
+ __html: polyglot.t("counts", {
67
+ counts: `<a class="gt-link gt-link-counts" href="${issue?.html_url}" target="_blank" rel="noopener noreferrer">${commentsCount}</a>`,
68
+ smart_count: commentsCount,
69
+ }),
70
+ }}
71
+ />
72
+ {showPopup && (
73
+ <div className="gt-popup">
74
+ {user
75
+ ? [
76
+ <Action
77
+ key="sort-asc"
78
+ className={`gt-action-sortasc${pagerDirection === "first" ? " is--active" : ""}`}
79
+ onClick={() => onPagerDirectionChange("first")}
80
+ text={polyglot.t("sort-asc")}
81
+ />,
82
+ <Action
83
+ key="sort-desc"
84
+ className={`gt-action-sortdesc${pagerDirection === "last" ? " is--active" : ""}`}
85
+ onClick={() => onPagerDirectionChange("last")}
86
+ text={polyglot.t("sort-desc")}
87
+ />,
88
+ ]
89
+ : null}
90
+ {user ? (
91
+ <Action
92
+ className="gt-action-logout"
93
+ onClick={onLogout}
94
+ text={polyglot.t("logout")}
95
+ />
96
+ ) : (
97
+ <a className="gt-action gt-action-login" onClick={onLogin}>
98
+ {polyglot.t("login-with-github")}
99
+ </a>
100
+ )}
101
+ <div className="gt-copyright">
102
+ <a
103
+ className="gt-link gt-link-project"
104
+ href={HOMEPAGE}
105
+ target="_blank"
106
+ rel="noopener noreferrer"
107
+ >
108
+ GitalkR
109
+ </a>
110
+ <span className="gt-version">{VERSION}</span>
111
+ </div>
112
+ </div>
113
+ )}
114
+ <div className="gt-user">
115
+ <div
116
+ className={`gt-user-inner${showPopup ? " is--poping" : ""}`}
117
+ onClick={onShowOrHidePopup}
118
+ >
119
+ <span className="gt-user-name">
120
+ {user?.login ?? polyglot.t("anonymous")}
121
+ </span>
122
+ <Svg className="gt-ico-arrdown" icon={ArrowDown} />
123
+ </div>
124
+ </div>
125
+ </div>
126
+ );
127
+ };
128
+
129
+ export default Meta;
@@ -14,14 +14,22 @@ import {
14
14
  } from "date-fns/locale";
15
15
 
16
16
  import packageJson from "../../package.json";
17
- import { type GitalkProps } from "../gitalk";
18
17
  import { type Lang } from "../i18n";
18
+ import type { GitalkProps } from "../interfaces";
19
19
 
20
20
  export const VERSION = packageJson.version;
21
21
  export const HOMEPAGE = packageJson.homepage;
22
22
 
23
23
  export const DEFAULT_LANG: Lang = "en";
24
24
  export const DEFAULT_LABELS = ["Gitalk"];
25
+ export const DEFAULT_FLIP_MOVE_OPTIONS: GitalkProps["flipMoveOptions"] = {
26
+ staggerDelayBy: 150,
27
+ appearAnimation: "accordionVertical",
28
+ enterAnimation: "accordionVertical",
29
+ leaveAnimation: "accordionVertical",
30
+ };
31
+ export const DEFAULT_PROXY =
32
+ "https://cors-anywhere.azm.workers.dev/https://github.com/login/oauth/access_token";
25
33
  export const DEFAULT_AVATAR =
26
34
  "https://cdn.jsdelivr.net/npm/gitalk@1/src/assets/icon/github.svg";
27
35
  export const DEFAULT_USER: GitalkProps["defaultUser"] = {
@@ -5,11 +5,13 @@ import { createContext } from "react";
5
5
  import { DATE_FNS_LOCALE_MAP, DEFAULT_LANG } from "../constants";
6
6
  import i18n, { type Lang } from "../i18n";
7
7
 
8
- export const I18nContext = createContext<{
8
+ export interface I18nContextValue {
9
9
  language: Lang;
10
10
  polyglot: Polyglot;
11
11
  dateFnsLocaleMap: Record<string, Locale>;
12
- }>({
12
+ }
13
+
14
+ export const I18nContext = createContext<I18nContextValue>({
13
15
  language: DEFAULT_LANG,
14
16
  polyglot: i18n(DEFAULT_LANG),
15
17
  dateFnsLocaleMap: DATE_FNS_LOCALE_MAP,