diff --git a/src/components/Questions/QuestionsTable.tsx b/src/components/Questions/QuestionsTable.tsx index d02bc3c99bb2..813a9137bf1b 100644 --- a/src/components/Questions/QuestionsTable.tsx +++ b/src/components/Questions/QuestionsTable.tsx @@ -104,13 +104,7 @@ const Row = ({ } = question const latestAuthor = replies?.data?.[replies.data.length - 1]?.attributes?.profile || profile - const isOP = profile?.data?.id === user?.profile?.id - const numReplies = - replies?.data?.filter((reply) => - reply.attributes.profile?.data?.id === Number(process.env.GATSBY_AI_PROFILE_ID) - ? isModerator || reply.attributes.helpful || isOP - : true - ).length || 0 + const numReplies = replies?.data?.length || 0 const { ref, inView } = useInView({ threshold: 0, diff --git a/src/components/Squeak/components/Markdown.tsx b/src/components/Squeak/components/Markdown.tsx index 5defc6d95cd6..627a63d90682 100644 --- a/src/components/Squeak/components/Markdown.tsx +++ b/src/components/Squeak/components/Markdown.tsx @@ -6,6 +6,15 @@ import { ZoomImage } from 'components/ZoomImage' import { TransformImage } from 'react-markdown/lib/ast-to-react' import remarkGfm from 'remark-gfm' +const replaceMentions = (body: string) => { + return body.replace(/@([a-zA-Z0-9-]+\/[0-9]+|max)/g, (match, username) => { + if (username === 'max') { + return `[${match}](/community/profiles/${process.env.GATSBY_AI_PROFILE_ID})` + } + return `[${match}](/community/profiles/${username.split('/')[1]})` + }) +} + export const Markdown = ({ children, transformImageUri, @@ -23,7 +32,9 @@ export const Markdown = ({ remarkPlugins={[remarkGfm]} transformImageUri={transformImageUri} rehypePlugins={[rehypeSanitize]} - className={`flex-1 !text-sm overflow-hidden text-ellipsis mr-1 !pb-0 text-primary/75 dark:text-primary-dark/75 font-normal ${regularText ? '' : 'question-content community-post-markdown'}`} + className={`flex-1 !text-sm overflow-hidden text-ellipsis mr-1 !pb-0 text-primary/75 dark:text-primary-dark/75 font-normal ${ + regularText ? '' : 'question-content community-post-markdown' + }`} components={{ pre: ({ children }) => { return ( @@ -57,7 +68,7 @@ export const Markdown = ({ img: ZoomImage, }} > - {children} + {replaceMentions(children)} ) } diff --git a/src/components/Squeak/components/Question.tsx b/src/components/Squeak/components/Question.tsx index 8fead51cb0e1..fe431fa9ab99 100644 --- a/src/components/Squeak/components/Question.tsx +++ b/src/components/Squeak/components/Question.tsx @@ -228,36 +228,38 @@ const DeleteButton = ({ questionID }: { questionID: number }) => { const MaxReply = ({ children }: { children: React.ReactNode }) => { return ( -
  • - ( -
    - Max AI is our resident AI assistant. Double-check responses for accuracy. -
    - )} - placement="top" + ) } @@ -270,12 +272,7 @@ const Loading = () => { ) } -const AskMax = ({ question, refresh, manual }: { question: any; refresh: () => void; manual?: boolean }) => { - const [confident, setConfident] = useState(false) - const [loading, setLoading] = useState(true) - const alreadyAsked = useMemo(() => question?.attributes?.askedMax, []) - const { getJwt } = useUser() - +const AskMaxLoading = () => { const messages = [ 'This usually takes less than 30 seconds.', 'Searching docs, tutorials, GitHub issues, blogs, community answers...', @@ -288,76 +285,38 @@ const AskMax = ({ question, refresh, manual }: { question: any; refresh: () => v const [fadeState, setFadeState] = useState('in') useEffect(() => { - if (loading) { - const intervalId = setInterval(() => { - setFadeState('out') - setTimeout(() => { - setCurrentMessageIndex((prevIndex) => (prevIndex + 1) % messages.length) - setFadeState('in') - }, 500) // Wait for fade out before changing message - }, 5000) - - return () => clearInterval(intervalId) - } - }, [loading]) - - useEffect(() => { - const askMax = async () => { - try { - const response = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/ask-max`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await getJwt()}`, - }, - body: JSON.stringify({ - question, - manual, - }), - }).then((res) => res.json()) - setConfident(response.confident) - setLoading(false) - refresh() - } catch (error) { - console.error(error) - } - } - if (!alreadyAsked) { - askMax() - } + const intervalId = setInterval(() => { + setFadeState('out') + setTimeout(() => { + setCurrentMessageIndex((prevIndex) => (prevIndex + 1) % messages.length) + setFadeState('in') + }, 500) // Wait for fade out before changing message + }, 5000) + + return () => clearInterval(intervalId) }, []) - return !alreadyAsked && (loading || !confident) ? ( - - ) : null + return ( + +
    +
    + +
    +
    +

    + Hang tight, checking to see if we can find an answer for you... +

    +

    + {messages[currentMessageIndex]} +

    +
    +
    +
    + ) } const AskMaxButton = ({ onClick, askedMax }: { askedMax: boolean; onClick: () => void }) => { @@ -383,11 +342,63 @@ const AskMaxButton = ({ onClick, askedMax }: { askedMax: boolean; onClick: () => ) } +const AskMax = ({ + question, + refresh, + manual = false, + withContext = false, +}: { + question: any + refresh: () => void + manual?: boolean + withContext?: boolean +}) => { + const [loading, setLoading] = useState(true) + const [confident, setConfident] = useState(false) + const { getJwt } = useUser() + + useEffect(() => { + const askMax = async () => { + try { + const response = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/ask-max`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${await getJwt()}`, + }, + body: JSON.stringify({ + question, + manual, + withContext, + }), + }).then((res) => res.json()) + setConfident(response.confident) + setLoading(false) + refresh() + } catch (error) { + console.error(error) + } + } + askMax() + window.history.replaceState({ ...window.history.state, askMax: false }, '') + }, []) + + return loading ? ( + + ) : !confident ? ( + +
    +

    Dang, we couldn’t find anything this time. A community member will hopefully respond soon!

    +
    +
    + ) : null +} + export const Question = (props: QuestionProps) => { - const { id, question, showSlug, buttonText, showActions = true, askMax } = props + const { id, question, showSlug, buttonText, showActions = true, ...other } = props const [expanded, setExpanded] = useState(props.expanded || false) const { user, notifications, setNotifications } = useUser() - const [manualAskMax, setManualAskMax] = useState(false) + const [maxQuestions, setMaxQuestions] = useState(other.askMax ? [{ manual: false, withContext: false }] : []) useEffect(() => { if ( @@ -431,6 +442,12 @@ export const Question = (props: QuestionProps) => { return
    Question not found
    } + const handleReply = async (_values, _formData, data) => { + if (data.askMax) { + setMaxQuestions([...maxQuestions, { manual: false, withContext: true }]) + } + } + const archived = questionData?.attributes.archived const slugs = questionData?.attributes?.slugs const escalated = questionData?.attributes.escalated @@ -494,7 +511,9 @@ export const Question = (props: QuestionProps) => { setManualAskMax(true)} + onClick={() => + setMaxQuestions([...maxQuestions, { manual: true, withContext: true }]) + } askedMax={questionData?.attributes.askedMax} /> @@ -532,10 +551,18 @@ export const Question = (props: QuestionProps) => {

    )} - {(askMax || manualAskMax) && ( - - )} + {maxQuestions.map((question, index) => { + return ( + + ) + })}
    { buttonText={buttonText} formType="reply" reply={reply} + onSubmit={handleReply} />
    diff --git a/src/components/Squeak/components/QuestionForm.tsx b/src/components/Squeak/components/QuestionForm.tsx index 4e4064c941a7..8012e4f691cb 100644 --- a/src/components/Squeak/components/QuestionForm.tsx +++ b/src/components/Squeak/components/QuestionForm.tsx @@ -128,6 +128,7 @@ function QuestionFormMain({ initialValues, showTopicSelector, disclaimer = true, + formType, }: QuestionFormMainProps) { const posthog = usePostHog() const { user, logout } = useUser() @@ -213,6 +214,7 @@ function QuestionFormMain({ setFieldValue={setFieldValue} initialValue={initialValues?.body} values={values} + mentions={formType === 'reply'} /> Promise onSubmit?: (values: any, formType: string) => void @@ -389,7 +391,7 @@ export const QuestionForm = ({ } if (formType === 'reply' && questionId) { - await reply(transformedValues.body) + data = await reply(transformedValues.body) } setLoading(false) @@ -428,6 +430,7 @@ export const QuestionForm = ({ loading={loading} onSubmit={handleMessageSubmit} showTopicSelector={showTopicSelector} + formType={formType} /> ), auth: ( diff --git a/src/components/Squeak/components/Replies.tsx b/src/components/Squeak/components/Replies.tsx index ad15e3357d5f..279f78b1f13c 100644 --- a/src/components/Squeak/components/Replies.tsx +++ b/src/components/Squeak/components/Replies.tsx @@ -20,19 +20,12 @@ type RepliesProps = { } export const Replies = ({ expanded, setExpanded }: RepliesProps) => { - const { user, isModerator } = useUser() + const { user } = useUser() const { - question: { replies: initialReplies, resolvedBy, profile }, + question: { replies, resolvedBy, profile }, } = useContext(CurrentQuestionContext) const isOP = profile?.data?.id === user?.profile?.id - const replies = { - data: initialReplies?.data?.filter((reply) => - reply.attributes.profile?.data?.id === Number(process.env.GATSBY_AI_PROFILE_ID) - ? isModerator || reply.attributes.helpful || isOP - : true - ), - } return replies && replies.data.length > 0 ? (
      diff --git a/src/components/Squeak/components/Reply.tsx b/src/components/Squeak/components/Reply.tsx index 5a06050a2baa..4d0a2fdfccdb 100644 --- a/src/components/Squeak/components/Reply.tsx +++ b/src/components/Squeak/components/Reply.tsx @@ -71,30 +71,7 @@ const AIDisclaimerMod = ({ opName, replyID, mutate }) => { ) } -const feedbackOptions = [ - { - label: 'Yes, mark as solution', - helpful: true, - }, - { - label: "Not the answer I'm looking for", - helpful: false, - }, - { - label: 'I think this is a bug', - helpful: false, - }, - { - label: 'My question is more nuanced', - helpful: false, - }, - { - label: 'Answer is wrong', - helpful: false, - }, -] - -const AIDisclaimer = ({ replyID, mutate, topic, isAuthor, confidence }) => { +const AIDisclaimer = ({ replyID, mutate, topic, confidence, resolvable }) => { const posthog = usePostHog() const { getJwt } = useUser() const { handleResolve } = useContext(CurrentQuestionContext) @@ -127,7 +104,9 @@ const AIDisclaimer = ({ replyID, mutate, topic, isAuthor, confidence }) => { }, }) - await handleResolve(helpful, replyID) + if (resolvable) { + await handleResolve(helpful, replyID) + } mutate() } catch (error) { @@ -135,6 +114,29 @@ const AIDisclaimer = ({ replyID, mutate, topic, isAuthor, confidence }) => { } } + const feedbackOptions = [ + { + label: resolvable ? 'Yes, mark as solution' : 'Yes, this was helpful', + helpful: true, + }, + { + label: "Not the answer I'm looking for", + helpful: false, + }, + { + label: 'I think this is a bug', + helpful: false, + }, + { + label: 'My question is more nuanced', + helpful: false, + }, + { + label: 'Answer is wrong', + helpful: false, + }, + ] + return (
      {helpful === null ? ( @@ -145,7 +147,7 @@ const AIDisclaimer = ({ replyID, mutate, topic, isAuthor, confidence }) => { ) : helpful ? ( <>

      - Great to hear! This answer has been marked as the solution. + Great to hear! Thanks for helping us improve.

      Response generated by{' '} @@ -302,6 +304,7 @@ export default function Reply({ reply, badgeText }: ReplyProps) {
      {profile.data.id === Number(process.env.GATSBY_AI_PROFILE_ID) && helpful === null && + (isModerator || isAuthor) && (isModerator && !publishedAt ? ( ))}
      {reply?.attributes?.helpful === false && (
      - This answer was marked as unhelpful and is only - visible to you. + This answer was marked as unhelpful.
      )} {body} diff --git a/src/components/Squeak/components/RichText.tsx b/src/components/Squeak/components/RichText.tsx index a50093bc6679..efc50f3afc0a 100644 --- a/src/components/Squeak/components/RichText.tsx +++ b/src/components/Squeak/components/RichText.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, useEffect, useRef, useState, useCallback } from 'react' +import React, { ChangeEvent, useEffect, useRef, useState, useCallback, useContext, useMemo } from 'react' import MarkdownLogo from './MarkdownLogo' import { useDropzone } from 'react-dropzone' import Spinner from 'components/Spinner' @@ -7,6 +7,12 @@ import slugify from 'slugify' import { Edit } from 'components/Icons' import Tooltip from 'components/Tooltip' import { isURL } from 'lib/utils' +import { CurrentQuestionContext } from './Question' +import Avatar from './Avatar' +import { AnimatePresence, motion } from 'framer-motion' +import { IconFeatures, IconX } from '@posthog/icons' +import { graphql, useStaticQuery } from 'gatsby' +import groupBy from 'lodash.groupby' const buttons = [ { @@ -78,6 +84,142 @@ const buttons = [ }, ] +const MentionProfile = ({ profile, onSelect, selectionStart, index, focused }) => { + const { firstName, lastName, avatar, gravatarURL } = profile.attributes + const name = [firstName, lastName].filter(Boolean).join(' ') + const isAI = profile.id === Number(process.env.GATSBY_AI_PROFILE_ID) + + return ( +
    • + +
    • + ) +} + +const MentionProfiles = ({ onSelect, onClose, body, ...other }) => { + const { staffProfiles } = useStaticQuery(graphql` + { + staffProfiles: allSqueakProfile(sort: { fields: firstName }) { + nodes { + avatar { + url + } + firstName + lastName + squeakId + } + } + } + `) + const currentQuestion = useContext(CurrentQuestionContext) ?? {} + const replies = currentQuestion?.question?.replies + const selectionStart = useMemo(() => other.selectionStart, []) + const search = body.substring(selectionStart).split(' ')[0].replace('@', '') + const mentionProfiles = [ + { attributes: { profile: { data: currentQuestion?.question?.profile?.data } } }, + ...replies?.data, + ...staffProfiles.nodes + .filter((node) => node.squeakId === Number(process.env.GATSBY_AI_PROFILE_ID)) + .map((node) => ({ + attributes: { + profile: { + data: { + id: node.squeakId, + attributes: { ...node, avatar: { data: { attributes: { url: node.avatar?.url } } } }, + }, + }, + }, + })), + ] + .map((reply) => reply?.attributes?.profile?.data) + .filter((profile, index, self) => { + const { firstName, lastName } = profile.attributes + const name = [firstName, lastName].filter(Boolean).join(' ') + return ( + profile && + self.findIndex((p) => p?.id === profile.id) === index && + name.toLowerCase().includes(search.toLowerCase()) + ) + }) + const grouped = groupBy(mentionProfiles, (profile) => + staffProfiles.nodes.some((node) => node.squeakId === profile.id) ? 'Staff' : 'In this thread' + ) + const listRef = useRef(null) + const [focused, setFocused] = useState(0) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setFocused((prev) => (prev + 1) % mentionProfiles.length) + } + if (e.key === 'ArrowUp') { + e.preventDefault() + setFocused((prev) => (prev - 1 + mentionProfiles.length) % mentionProfiles.length) + } + if (e.key === 'Tab' || e.key === 'Enter') { + e.preventDefault() + onSelect?.(mentionProfiles[focused], selectionStart) + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [focused, search]) + + return ( + + +
        + {mentionProfiles.map((profile, index) => ( + + ))} +
      +
      + ) +} + export default function RichText({ initialValue = '', setFieldValue, @@ -87,12 +229,15 @@ export default function RichText({ maxLength = 2000, preview = true, label = '', + mentions = false, }: any) { const textarea = useRef(null) const [value, setValue] = useState(initialValue) const [cursor, setCursor] = useState(null) const [imageLoading, setImageLoading] = useState(false) const [showPreview, setShowPreview] = useState(false) + const [showMentionProfiles, setShowMentionProfiles] = useState(false) + const mentionProfilesRef = useRef(null) const onDrop = useCallback( async (acceptedFiles) => { @@ -119,7 +264,7 @@ export default function RichText({ accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'], 'image/gif': ['.gif'] }, }) - const replaceSelection = (selectionStart?: number, selectionEnd?: number, text = '') => { + const replaceSelection = (selectionStart?: number, selectionEnd?: number, text = '', value: string) => { return value.substring(0, selectionStart) + text + value.substring(selectionEnd, value.length) } @@ -139,7 +284,7 @@ export default function RichText({ const { selectionStart, selectionEnd, selectedText } = getTextSelection() textarea?.current?.focus() - setValue(replaceSelection(selectionStart, selectionEnd, replaceWith(selectedText))) + setValue((prevValue) => replaceSelection(selectionStart, selectionEnd, replaceWith(selectedText), prevValue)) setCursor(cursor) } @@ -151,7 +296,9 @@ export default function RichText({ const { selectionStart, selectionEnd, selectedText } = getTextSelection() if (selectedText) { textarea?.current?.focus() - setValue(replaceSelection(selectionStart, selectionEnd, `[${selectedText}](${url})`)) + setValue((prevValue) => + replaceSelection(selectionStart, selectionEnd, `[${selectedText}](${url})`, prevValue) + ) } } @@ -188,167 +335,225 @@ export default function RichText({ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && onSubmit) { onSubmit() } + if (e.key === '@' && e.shiftKey) { + setShowMentionProfiles(true) + } + } + + const handleContainerClick = (e) => { + if (!e.target.contains(mentionProfilesRef.current)) { + setShowMentionProfiles(false) + } + } + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' || e.key === ' ') { + setShowMentionProfiles(false) + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, []) + + const handleProfileSelect = (profile, selectionStart) => { + const { selectionEnd } = getTextSelection() + const mention = + profile.id === Number(process.env.GATSBY_AI_PROFILE_ID) + ? `@max ` + : `@${profile.attributes.firstName.trim().toLowerCase()}/${profile.id} ` + setValue((prevValue) => replaceSelection(selectionStart, selectionEnd, mention, prevValue)) + setShowMentionProfiles(false) + textarea.current?.focus() } return (
      - - {showPreview ? ( -
      - { - const objectURL = values.images.find( - (image) => image.fakeImagePath === fakeImagePath - )?.objectURL - return objectURL || fakeImagePath - }} - > - {value} - -
      - ) : ( -
      -