diff --git a/README.md b/README.md index e8935c2b1..0d86108fc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # [giscus][giscus] -A comments widget built on [GitHub Discussions][discussions]. Let visitors sign in with GitHub and leave comments on your website! Heavily inspired by [utterances][utterances]. +A comments system powered by [GitHub Discussions][discussions]. Let visitors leave comments and reactions on your website via GitHub! Heavily inspired by [utterances][utterances]. - [Open source][repo]. 🌏 - No tracking, no ads, always free. 📡 🚫 @@ -15,7 +15,7 @@ A comments widget built on [GitHub Discussions][discussions]. Let visitors sign ## how it works -When giscus loads, the [GitHub Discussions search API][search-api] is used to find the Discussion associated with the page based on a chosen mapping (URL, `pathname`, ``, etc.). If a matching discussion cannot be found, the giscus bot will automatically create a discussion the first time someone comments. +When giscus loads, the [GitHub Discussions search API][search-api] is used to find the Discussion associated with the page based on a chosen mapping (URL, `pathname`, `<title>`, etc.). If a matching discussion cannot be found, the giscus bot will automatically create a discussion the first time someone leaves a comment or reaction. To comment, visitors must authorize the [giscus app][giscus-app] to [post on their behalf][authorization] using the GitHub OAuth flow. Alternatively, visitors can comment on the GitHub Discussion directly. You can moderate the comments on GitHub. diff --git a/client.ts b/client.ts index f8da35a01..f6aa0ea2f 100644 --- a/client.ts +++ b/client.ts @@ -42,6 +42,7 @@ const ogDescriptionMeta = document.querySelector( params.origin = location.href; params.session = session; params.theme = attributes.theme; +params.reactionsEnabled = attributes.reactionsEnabled || '1'; params.repo = attributes.repo; params.repoId = attributes.repoId; params.categoryId = attributes.categoryId; diff --git a/components/CommentBox.tsx b/components/CommentBox.tsx index b9fd5e625..a5c2f0227 100644 --- a/components/CommentBox.tsx +++ b/components/CommentBox.tsx @@ -62,7 +62,7 @@ export default function CommentBox({ }, []); const handleClick = useCallback(async () => { - if (isSubmitting) return; + if (isSubmitting || (!discussionId && !onDiscussionCreateRequest)) return; setIsSubmitting(true); const id = discussionId ? discussionId : await onDiscussionCreateRequest(); diff --git a/components/Configuration.tsx b/components/Configuration.tsx index 7732b1a53..e0a6dceaa 100644 --- a/components/Configuration.tsx +++ b/components/Configuration.tsx @@ -1,7 +1,6 @@ import { CheckIcon, ClippyIcon, SyncIcon, XIcon } from '@primer/octicons-react'; -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { handleClipboardCopy } from '../lib/adapter'; -import { ThemeContext } from '../lib/context'; import { useDebounce } from '../lib/hooks'; import { ICategory } from '../lib/types/adapter'; import { themeOptions } from '../lib/variables'; @@ -93,7 +92,17 @@ function ClipboardCopy() { ); } -export default function Configuration() { +interface DirectConfig { + theme: string; + reactionsEnabled: boolean; +} + +interface ConfigurationProps { + directConfig: DirectConfig; + onDirectConfigChange: (key: keyof DirectConfig, value: DirectConfig[keyof DirectConfig]) => void; +} + +export default function Configuration({ directConfig, onDirectConfigChange }: ConfigurationProps) { const [repository, setRepository] = useState(''); const [repositoryId, setRepositoryId] = useState(''); const [categoryId, setCategoryId] = useState(''); @@ -101,13 +110,7 @@ export default function Configuration() { const [categories, setCategories] = useState<ICategory[]>([]); const [mapping, setMapping] = useState('pathname'); const [term, setTerm] = useState(''); - const [theme, setTheme] = useState('light'); const dRepository = useDebounce(repository); - const { setTheme: setGlobalTheme } = useContext(ThemeContext); - - useEffect(() => { - setGlobalTheme(theme); - }, [setGlobalTheme, theme]); useEffect(() => { setError(false); @@ -154,47 +157,45 @@ export default function Configuration() { settings tab of the repository. </li> </ol> - <fieldset className="mx-4"> - <div> - <label htmlFor="repository" className="block font-semibold"> - repository: - </label> - <input - id="repository" - value={repository} - onChange={(event) => setRepository(event.target.value)} - type="text" - className="my-2 px-[12px] py-[5px] min-w-[75%] sm:min-w-[50%] form-control border rounded-md placeholder-gray-500" - placeholder="owner/repo" - /> + <fieldset> + <label htmlFor="repository" className="block font-semibold"> + repository: + </label> + <input + id="repository" + value={repository} + onChange={(event) => setRepository(event.target.value)} + type="text" + className="my-2 px-[12px] py-[5px] min-w-[75%] sm:min-w-[50%] form-control border rounded-md placeholder-gray-500" + placeholder="owner/repo" + /> - {error || (repositoryId && !categories.length) ? ( - <> - <XIcon className="inline-block ml-2 color-text-danger" /> - <p className="text-xs color-text-danger"> - Cannot use giscus in this repository. Make sure all of the above criteria has been - met. - </p> - </> - ) : repositoryId && categories.length ? ( - <> - <CheckIcon className="inline-block ml-2 color-text-success" /> - <p className="text-xs color-text-success"> - Success! This repository meets all of the above criteria. - </p> - </> - ) : ( - <> - {!error && !repositoryId && dRepository ? ( - <SyncIcon className="inline-block ml-2 animate-spin" /> - ) : null} - <p className="text-xs color-text-secondary"> - A <strong>public</strong> GitHub repository. This is where the discussions will be - linked to. - </p> - </> - )} - </div> + {error || (repositoryId && !categories.length) ? ( + <> + <XIcon className="inline-block ml-2 color-text-danger" /> + <p className="text-xs color-text-danger"> + Cannot use giscus in this repository. Make sure all of the above criteria has been + met. + </p> + </> + ) : repositoryId && categories.length ? ( + <> + <CheckIcon className="inline-block ml-2 color-text-success" /> + <p className="text-xs color-text-success"> + Success! This repository meets all of the above criteria. + </p> + </> + ) : ( + <> + {!error && !repositoryId && dRepository ? ( + <SyncIcon className="inline-block ml-2 animate-spin" /> + ) : null} + <p className="text-xs color-text-secondary"> + A <strong>public</strong> GitHub repository. This is where the discussions will be + linked to. + </p> + </> + )} </fieldset> <h3>Discussion Category</h3> @@ -221,9 +222,9 @@ export default function Configuration() { <h3>Page ↔️ Discussions Mapping</h3> <p>Choose the mapping between the embedding page and the embedded discussion.</p> - <fieldset className="mx-4"> + <fieldset> {mappingOptions.map(({ value, label, description }) => ( - <div key={value} className="flex mt-4 first:mt-0"> + <div key={value} className="mt-4 first:mt-0 form-checkbox"> <input id={value} className="mt-[3.5px]" @@ -236,28 +237,44 @@ export default function Configuration() { setMapping(event.target.value); }} /> - <div className="w-full ml-2"> - <label className="cursor-pointer" htmlFor={value}> - <strong>{label}</strong> - <p className="mb-0 text-xs color-text-secondary">{description}</p> - </label> - {['specific', 'number'].includes(mapping) && mapping === value ? ( - <input - id="term" - value={term} - onChange={(event) => setTerm(event.target.value)} - type={mapping === 'number' ? 'number' : 'text'} - className="px-[12px] py-[5px] mt-4 form-control border rounded-md placeholder-gray-500 min-w-[75%] sm:min-w-[50%]" - placeholder={ - mapping === 'number' ? 'Enter discussion number here' : 'Enter term here' - } - /> - ) : null} - </div> + <label className="cursor-pointer" htmlFor={value}> + <strong>{label}</strong> + </label> + <p className="mb-0 text-xs color-text-secondary">{description}</p> + {['specific', 'number'].includes(mapping) && mapping === value ? ( + <input + id="term" + value={term} + onChange={(event) => setTerm(event.target.value)} + type={mapping === 'number' ? 'number' : 'text'} + className="px-[12px] py-[5px] mt-4 form-control border rounded-md placeholder-gray-500 min-w-[75%] sm:min-w-[50%]" + placeholder={ + mapping === 'number' ? 'Enter discussion number here' : 'Enter term here' + } + /> + ) : null} </div> ))} </fieldset> + <h3>Features</h3> + <p>Choose whether specific features should be enabled.</p> + <div className="form-checkbox"> + <input + type="checkbox" + id="reactionsEnabled" + checked={directConfig.reactionsEnabled} + value={`${+directConfig.reactionsEnabled}`} + onChange={(event) => onDirectConfigChange('reactionsEnabled', event.target.checked)} + ></input> + <label htmlFor="reactionsEnabled"> + <strong>Enable reactions for the main post</strong> + </label> + <p className="mb-0 text-xs color-text-secondary"> + The reactions for the {`discussion's`} main post will be shown before the comments. + </p> + </div> + <h3>Theme</h3> <p> Choose a theme that matches your website. {`Can't`} find one that does?{' '} @@ -273,8 +290,8 @@ export default function Configuration() { <select name="theme" id="theme" - value={theme} - onChange={(event) => setTheme(event.target.value)} + value={directConfig.theme} + onChange={(event) => onDirectConfigChange('theme', event.target.value)} className="px-[12px] py-[5px] pr-6 border rounded-md appearance-none bg-no-repeat form-control form-select color-border-primary color-bg-primary" > {themeOptions.map(({ label, value }) => ( @@ -315,8 +332,11 @@ export default function Configuration() { {'"\n '} </> ) : null} + <span className="pl-c1">data-reactions-enabled</span>={'"'} + <span className="pl-s">{Number(directConfig.reactionsEnabled)}</span> + {'"\n '} <span className="pl-c1">data-theme</span>={'"'} - <span className="pl-s">{theme}</span> + <span className="pl-s">{directConfig.theme}</span> {'"\n '} <span className="pl-c1">crossorigin</span>={'"'} <span className="pl-s">anonymous</span> diff --git a/components/Giscus.tsx b/components/Giscus.tsx index 86ebcb6c1..026f6d803 100644 --- a/components/Giscus.tsx +++ b/components/Giscus.tsx @@ -1,17 +1,26 @@ -import { useContext } from 'react'; +import { useCallback, useContext } from 'react'; import { AuthContext } from '../lib/context'; +import { Reactions, updateDiscussionReaction } from '../lib/reactions'; import { useDiscussions } from '../services/giscus/discussions'; import Comment from './Comment'; import CommentBox from './CommentBox'; +import ReactButtons from './ReactButtons'; interface IGiscusProps { repo: string; term?: string; number?: number; + reactionsEnabled: boolean; onDiscussionCreateRequest?: () => Promise<string>; } -export default function Giscus({ repo, term, number, onDiscussionCreateRequest }: IGiscusProps) { +export default function Giscus({ + repo, + term, + number, + reactionsEnabled, + onDiscussionCreateRequest, +}: IGiscusProps) { const { token } = useContext(AuthContext); const query = { repo, term, number }; @@ -65,11 +74,20 @@ export default function Giscus({ repo, term, number, onDiscussionCreateRequest } return newData; }); + const updateReactions = useCallback( + (reaction: Reactions, promise: Promise<unknown>) => + backData + ? backMutators.updateDiscussion([updateDiscussionReaction(backData, reaction)], promise) + : promise.then(() => backMutators.mutate()), + [backData, backMutators], + ); + const numHidden = backData?.discussion?.totalCommentCount - backData?.discussion?.comments?.length - frontData?.reduce((prev, g) => prev + g.discussion.comments?.length, 0); + const totalReactionCount = backData?.discussion?.reactionCount; const totalCommentCount = backData?.discussion?.totalCommentCount; const totalReplyCount = backData?.discussion?.totalReplyCount + @@ -84,15 +102,43 @@ export default function Giscus({ repo, term, number, onDiscussionCreateRequest } const isNotFound = error?.status === 404; const isLocked = backData?.discussion?.locked; - const shouldShowReplyCount = !error && !isNotFound && !isLoading && totalReplyCount > 0; const shouldShowBranding = !!backData?.discussion?.url; + const shouldShowReplyCount = !error && !isNotFound && !isLoading && totalReplyCount > 0; const shouldShowCommentBox = !isLoading && !isLocked && (!error || (isNotFound && !number)); + const shouldCreateDiscussion = isNotFound && !number; return ( <div className="w-full color-text-primary"> + {reactionsEnabled && !isLoading ? ( + <div className="flex flex-col justify-center flex-auto mb-3 dmd:mb-1"> + <h4 className="font-semibold text-center"> + {shouldCreateDiscussion && !totalReactionCount ? ( + '0 reactions' + ) : ( + <a + href={backData?.discussion?.url} + target="_blank" + rel="noreferrer noopener nofollow" + className="color-text-primary" + > + {totalReactionCount || 0} reaction + {totalReactionCount !== 1 ? 's' : ''} + </a> + )} + </h4> + <div className="flex justify-center flex-auto mt-2 text-sm"> + <ReactButtons + subjectId={backData?.discussion?.id} + reactionGroups={backData?.discussion?.reactions} + onReact={updateReactions} + onDiscussionCreateRequest={onDiscussionCreateRequest} + /> + </div> + </div> + ) : null} <div className="flex items-center flex-auto pb-2"> <h4 className="mr-2 font-semibold"> - {isNotFound && !number && !totalCommentCount ? ( + {shouldCreateDiscussion && !totalCommentCount ? ( '0 comments' ) : error && !backData ? ( `An error occurred${error?.message ? `: ${error.message}` : ''}.` diff --git a/components/ReactButtons.tsx b/components/ReactButtons.tsx index df4eda5c8..8b17028ad 100644 --- a/components/ReactButtons.tsx +++ b/components/ReactButtons.tsx @@ -8,10 +8,36 @@ import { Reactions } from '../lib/reactions'; import { toggleReaction } from '../services/github/toggleReaction'; interface IReactButtonsProps { - reactionGroups: IReactionGroups; - subjectId: string; + reactionGroups?: IReactionGroups; + subjectId?: string; onReact: (content: Reactions, promise: Promise<unknown>) => void; variant?: 'groupsOnly' | 'popoverOnly' | 'all'; + onDiscussionCreateRequest?: () => Promise<string>; +} + +function PopupInfo({ + isLoggedIn, + isLoading, + current, + loginUrl, +}: { + isLoggedIn: boolean; + isLoading: boolean; + current: string; + loginUrl: string; +}) { + if (isLoading) return <>Please wait...</>; + if (isLoggedIn) return <>{current || 'Pick your reaction'}</>; + return ( + <> + <Link href={loginUrl}> + <a className="color-text-link" target="_top"> + Sign in + </a> + </Link>{' '} + to add your reaction. + </> + ); } export default function ReactButtons({ @@ -19,8 +45,10 @@ export default function ReactButtons({ subjectId, onReact, variant = 'all', + onDiscussionCreateRequest, }: IReactButtonsProps) { const [current, setCurrent] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); const [ref, isOpen, setIsOpen] = useComponentVisible<HTMLDivElement>(false); const { token, origin } = useContext(AuthContext); const loginUrl = getLoginUrl(origin); @@ -28,15 +56,58 @@ export default function ReactButtons({ const togglePopover = useCallback(() => setIsOpen(!isOpen), [isOpen, setIsOpen]); const react = useCallback( - (content: Reactions) => { + async (content: Reactions) => { + if (isSubmitting || (!subjectId && !onDiscussionCreateRequest)) return; + setIsSubmitting(!subjectId); + + const id = subjectId ? subjectId : await onDiscussionCreateRequest(); + onReact( content, - toggleReaction({ content, subjectId }, token, reactionGroups[content].viewerHasReacted), + toggleReaction( + { content, subjectId: id }, + token, + !!reactionGroups?.[content]?.viewerHasReacted, + ).then(() => setIsSubmitting(false)), ); }, - [onReact, reactionGroups, subjectId, token], + [isSubmitting, onDiscussionCreateRequest, onReact, reactionGroups, subjectId, token], + ); + + const createReactionButton = useCallback( + ([value, { count, viewerHasReacted }]: [ + keyof IReactionGroups, + typeof reactionGroups[keyof IReactionGroups], + ]) => ( + <button + key={value} + className={`px-2 mb-1 dmd:mb-4 mr-2 border leading-[26px] color-border-primary rounded-md${ + viewerHasReacted ? ' color-bg-info' : '' + }${!token ? ' cursor-not-allowed' : ''}`} + disabled={!token} + title={ + token + ? `${count} ${count === 1 ? 'person' : 'people'} reacted with ${Reactions[ + value + ].name.toLowerCase()} emoji` + : 'You must be signed in to add reactions.' + } + onClick={() => react(value as Reactions)} + > + <span className="inline-block w-4 h-4 mr-2">{Reactions[value].emoji}</span> + <span className="text-xs color-text-link">{count}</span> + </button> + ), + [react, token], ); + const directReactionButtons = + variant !== 'popoverOnly' + ? Object.entries(reactionGroups || {}) + .filter(([, { count }]) => count > 0) + .map(createReactionButton) + : []; + return ( <> {variant !== 'groupsOnly' ? ( @@ -44,9 +115,9 @@ export default function ReactButtons({ <button className={`px-3 py-[3px] Link--secondary${ variant !== 'popoverOnly' - ? ' mb-4 mr-4 border rounded-md color-bg-tertiary color-border-primary' + ? ' mb-1 dmd:mb-4 border rounded-md color-bg-tertiary color-border-primary' : '' - }`} + }${directReactionButtons.length > 0 ? ' mr-4' : ''}`} onClick={togglePopover} > <SmileyIcon size={18} /> @@ -57,18 +128,12 @@ export default function ReactButtons({ } ease-in-out duration-100 origin-center transform transition z-20 w-[146px] color-text-secondary color-bg-overlay border rounded top-10 color-border-primary`} > <p className="m-2"> - {token ? ( - current || 'Pick your reaction' - ) : ( - <> - <Link href={loginUrl}> - <a className="color-text-link" target="_top"> - Sign in - </a> - </Link>{' '} - to add your reaction. - </> - )} + <PopupInfo + isLoading={isSubmitting} + isLoggedIn={!!token} + loginUrl={loginUrl} + current={current} + /> </p> <div className="my-2 border-t color-border-primary" /> <div className="m-2"> @@ -76,11 +141,11 @@ export default function ReactButtons({ <button key={key} type="button" - className={`w-8 h-8 mr-[-1px] mt-[-1px] rounded-none gsc-emoji-button ${ - reactionGroups[key].viewerHasReacted - ? 'border color-bg-info color-border-tertiary' + className={`w-8 h-8 mr-[-1px] mt-[-1px] rounded-none gsc-emoji-button${ + reactionGroups?.[key]?.viewerHasReacted + ? ' border color-bg-info color-border-tertiary' : '' - }`} + }${!token ? ' cursor-not-allowed' : ''}`} onClick={() => { react(key as Reactions); togglePopover(); @@ -100,26 +165,7 @@ export default function ReactButtons({ ) : null} {variant !== 'popoverOnly' ? ( - <div className="flex flex-wrap"> - {Object.entries(reactionGroups).map(([value, { count, viewerHasReacted }]) => - count > 0 ? ( - <button - key={value} - className={`px-2 mb-1 md:mb-4 mr-2 border leading-[26px] color-border-primary rounded-md${ - viewerHasReacted ? ' color-bg-info' : '' - }`} - disabled={!token} - title={`${count} ${count === 1 ? 'person' : 'people'} reacted with ${Reactions[ - value - ].name.toLowerCase()} emoji`} - onClick={() => react(value as Reactions)} - > - <span className="inline-block w-4 h-4 mr-2">{Reactions[value].emoji}</span> - <span className="text-xs color-text-link">{count}</span> - </button> - ) : null, - )} - </div> + <div className="flex flex-wrap">{directReactionButtons}</div> ) : null} </> ); diff --git a/components/Widget.tsx b/components/Widget.tsx index 840fe3540..72954143c 100644 --- a/components/Widget.tsx +++ b/components/Widget.tsx @@ -13,6 +13,7 @@ interface IWidgetProps { repoId: string; categoryId: string; description?: string; + reactionsEnabled?: boolean; } function getSession(router: NextRouter) { @@ -34,6 +35,7 @@ export default function Widget({ repoId, categoryId, description, + reactionsEnabled = true, }: IWidgetProps) { const router = useRouter(); const isMounted = useIsMounted(); @@ -67,6 +69,7 @@ export default function Widget({ repo={repo} term={term} number={number} + reactionsEnabled={reactionsEnabled} onDiscussionCreateRequest={handleDiscussionCreateRequest} /> </AuthContext.Provider> diff --git a/lib/adapter.ts b/lib/adapter.ts index be644f736..2fe9f23b9 100644 --- a/lib/adapter.ts +++ b/lib/adapter.ts @@ -72,6 +72,8 @@ export function adaptDiscussion({ const { comments: { pageInfo, totalCount: totalCommentCount, ...commentsData }, + reactions: { totalCount: reactionCount }, + reactionGroups, ...rest } = discussion; @@ -80,6 +82,7 @@ export function adaptDiscussion({ 0, ); + const reactions = adaptReactionGroups(reactionGroups); const comments = commentsData.nodes.map(adaptComment); return { @@ -88,6 +91,8 @@ export function adaptDiscussion({ totalCommentCount, totalReplyCount, pageInfo, + reactionCount, + reactions, comments, ...rest, }, diff --git a/lib/reactions.ts b/lib/reactions.ts index 174413e7f..4532f16ac 100644 --- a/lib/reactions.ts +++ b/lib/reactions.ts @@ -1,4 +1,4 @@ -import { IComment, IReply } from './types/adapter'; +import { IComment, IGiscussion, IReactionGroups, IReply } from './types/adapter'; export const Reactions = { THUMBS_UP: { name: '+1', emoji: '👍' }, @@ -13,20 +13,39 @@ export const Reactions = { export type Reactions = keyof typeof Reactions; +function updateReactionGroups(reactionGroups: IReactionGroups, reaction: Reactions) { + const diff = reactionGroups[reaction].viewerHasReacted ? -1 : 1; + return [ + { + ...reactionGroups, + [reaction]: { + count: reactionGroups[reaction].count + diff, + viewerHasReacted: !reactionGroups[reaction].viewerHasReacted, + }, + }, + diff, + ] as [IReactionGroups, number]; +} + +export function updateDiscussionReaction(page: IGiscussion, reaction: Reactions) { + const [newReactions, diff] = updateReactionGroups(page.discussion.reactions, reaction); + return { + ...page, + discussion: { + ...page.discussion, + reactionCount: page.discussion.reactionCount + diff, + reactions: newReactions, + }, + } as IGiscussion; +} + export function updateCommentReaction<T extends IComment | IReply = IComment>( comment: T, reaction: Reactions, ) { + const [newReactions] = updateReactionGroups(comment.reactions, reaction); return { ...comment, - reactions: { - ...comment.reactions, - [reaction]: { - count: comment.reactions[reaction].viewerHasReacted - ? comment.reactions[reaction].count - 1 - : comment.reactions[reaction].count + 1, - viewerHasReacted: !comment.reactions[reaction].viewerHasReacted, - }, - }, + reactions: newReactions, } as T; } diff --git a/lib/types/adapter.ts b/lib/types/adapter.ts index d56031311..b23828a1f 100644 --- a/lib/types/adapter.ts +++ b/lib/types/adapter.ts @@ -56,6 +56,8 @@ export interface IGiscussion { repository: { nameWithOwner: string; }; + reactionCount: number; + reactions: IReactionGroups; comments: IComment[]; }; } diff --git a/lib/types/github.ts b/lib/types/github.ts index 3324f7f50..410907b77 100644 --- a/lib/types/github.ts +++ b/lib/types/github.ts @@ -63,6 +63,10 @@ export interface GRepositoryDiscussion { repository: { nameWithOwner: string; }; + reactions: { + totalCount: number; + }; + reactionGroups: GReactionGroup[]; comments: { totalCount: number; pageInfo: { diff --git a/pages/index.tsx b/pages/index.tsx index 49973f04c..147fccf5a 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -9,7 +9,7 @@ import { renderMarkdown } from '../services/github/markdown'; import { getAppAccessToken } from '../services/github/getAppAccessToken'; import { useIsMounted } from '../lib/hooks'; import Configuration from '../components/Configuration'; -import { useContext } from 'react'; +import { ComponentProps, useContext, useEffect, useState } from 'react'; import { ThemeContext } from '../lib/context'; export const getStaticProps = async () => { @@ -36,10 +36,24 @@ interface HomeProps { contentAfter: string; } +type DirectConfig = ComponentProps<typeof Configuration>['directConfig']; +type DirectConfigHandler = ComponentProps<typeof Configuration>['onDirectConfigChange']; + export default function Home({ contentBefore, contentAfter }: HomeProps) { const isMounted = useIsMounted(); const router = useRouter(); - const { theme } = useContext(ThemeContext); + const { theme, setTheme } = useContext(ThemeContext); + const [directConfig, setDirectConfig] = useState<DirectConfig>({ + theme: 'light', + reactionsEnabled: true, + }); + + const handleDirectConfigChange: DirectConfigHandler = (key, value) => + setDirectConfig({ ...directConfig, [key]: value }); + + useEffect(() => { + setTheme(directConfig.theme); + }, [setTheme, directConfig.theme]); const comment: IComment = { author: { @@ -73,7 +87,10 @@ export default function Home({ contentBefore, contentAfter }: HomeProps) { {isMounted ? ( <> <Comment comment={comment}> - <Configuration /> + <Configuration + directConfig={directConfig} + onDirectConfigChange={handleDirectConfigChange} + /> <div className="p-4 pt-0 markdown" dangerouslySetInnerHTML={{ __html: contentAfter }} @@ -99,7 +116,8 @@ export default function Home({ contentBefore, contentAfter }: HomeProps) { data-category-id="MDE4OkRpc2N1c3Npb25DYXRlZ29yeTMyNzk2NTc1" data-mapping="specific" data-term="Welcome to giscus!" - data-theme={theme} + data-theme={directConfig.theme} + data-reactions-enabled={`${+directConfig.reactionsEnabled}`} ></script> </Head> ) : null} diff --git a/pages/widget.tsx b/pages/widget.tsx index 926f5e6ec..d594ba52f 100644 --- a/pages/widget.tsx +++ b/pages/widget.tsx @@ -14,6 +14,7 @@ export default function Home() { const repoId = router.query.repoId as string; const categoryId = router.query.categoryId as string; const description = router.query.description as string; + const reactionsEnabled = Boolean(+router.query.reactionsEnabled); return ( <> @@ -29,6 +30,7 @@ export default function Home() { repoId={repoId} categoryId={categoryId} description={description} + reactionsEnabled={reactionsEnabled} /> </main> diff --git a/services/giscus/discussions.ts b/services/giscus/discussions.ts index 859cceb56..506b66dab 100644 --- a/services/giscus/discussions.ts +++ b/services/giscus/discussions.ts @@ -83,6 +83,12 @@ export function useDiscussions( [data, mutate], ); + const updateDiscussion = useCallback( + (newDiscussions: IGiscussion[], promise?: Promise<unknown>) => + mutate(newDiscussions, !promise) && promise?.then(() => mutate()), + [mutate], + ); + const updateComment = useCallback( (newComment: IComment, promise?: Promise<unknown>) => mutate( @@ -132,6 +138,13 @@ export function useDiscussions( isValidating, isLoading: !error && !data, isError: !!error, - mutators: { addNewComment, addNewReply, updateComment, updateReply }, + mutators: { + addNewComment, + addNewReply, + updateDiscussion, + updateComment, + updateReply, + mutate, + }, }; } diff --git a/services/github/getDiscussion.ts b/services/github/getDiscussion.ts index bdd8122b8..8c5596bba 100644 --- a/services/github/getDiscussion.ts +++ b/services/github/getDiscussion.ts @@ -11,6 +11,16 @@ const DISCUSSION_QUERY = ` repository { nameWithOwner } + reactions { + totalCount + } + reactionGroups { + content + users { + totalCount + } + viewerHasReacted + } comments(first: $first last: $last after: $after before: $before) { totalCount pageInfo { diff --git a/styles/base.css b/styles/base.css index 5e8fd4577..e058dbd9c 100644 --- a/styles/base.css +++ b/styles/base.css @@ -323,6 +323,15 @@ img.emoji { background-position: right 4px center; } +.form-checkbox { + @apply pl-5 my-[15px] align-middle; +} + +.form-checkbox input[type='checkbox'], +.form-checkbox input[type='radio'] { + @apply float-left mt-1 -ml-5 align-middle; +} + /* Giscus-specific styles */ .gsc-tl-line { diff --git a/tailwind.config.js b/tailwind.config.js index f401a8ca5..6e7f97028 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -4,6 +4,15 @@ module.exports = { darkMode: 'class', theme: { extend: { + screens: { + // To use device width instead of min-width. + // Might be useful because min-width will take the iframe's width instead. + dsm: { raw: '(min-device-width: 640px)' }, + dmd: { raw: '(min-device-width: 768px)' }, + dlg: { raw: '(min-device-width: 1024px)' }, + dxl: { raw: '(min-device-width: 1280px)' }, + d2xl: { raw: '(min-device-width: 1536px)' }, + }, fontFamily: { sans: [ '-apple-system',