"use client"; import React, { useMemo, useState } from "react"; import useSWR from "swr"; import { AtUri } from "@atproto/syntax"; import { isDocumentOwner } from "src/utils/isDocumentOwner"; import { usePathname } from "next/navigation"; import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; import { useIdentityData } from "components/IdentityProvider"; import { useToaster } from "components/Toast"; import { useActionToast } from "components/useActionToast"; import { usePostEditLink } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/usePostEditLink"; import { askQuestion, getDocumentQuestions, setQuestionState, withdrawQuestion, type QuestionError, } from "actions/questions"; import type { DocumentQuestion, DocumentQuestions as DocumentQuestionsData, QuestionState, } from "src/documentQuestions"; import type { QuestionsAudience } from "src/questionsAudience"; import { useDocumentEvents } from "src/documentEvents/useDocumentEvents"; import { ComposerPlaceholder } from "components/FacetedTextComposer"; import { QuestionComposer, QuestionComposerPreview, QuestionsList, QuestionsModeration, SignInToAsk, } from "./QuestionsView"; import { PublishedAnswer } from "./PublishedAnswer"; import type { FacetedText } from "components/FacetedTextComposer"; import { AnswerEditor, useHasAnswerDraft, useStartAnswer, } from "./AnswerEditor"; const questionErrorMessages: Partial> = { not_found: "We couldn't find that post.", empty: "Write a question first.", questions_closed: "This post isn't taking questions.", not_follows: "Only people the author follows can ask here.", not_follower: "Only the author's followers can ask here.", }; // Questions on a published document, for whoever is looking: the document's // author answers them, everyone else reads the answered ones and can ask // their own. `editor` is set by the questions block in the editor, where // the author answers inline; on the published page the author is sent to // the editor instead. `initialQuestions` is the public list a cached page // was rendered with; a signed-in viewer's own view (unanswered questions, // their own pending ones) replaces it once their identity is known. export function DocumentQuestions(props: { documentUri: string; // Key of the questions block this is (see resolveInteractionBlock): only // its questions show, and new ones are asked through it block: string | null; showPending?: boolean; // The page was opened at this block autoFocus?: boolean; initialQuestions?: DocumentQuestion[]; buttonText?: string; audience?: QuestionsAudience; editor?: { // The questions block entity, whose answer drafts hang off it blockEntity: string; // The page the block is on page: string; // The editable button text action: React.ReactNode; // The viewer's questions as loaded with the page, shown until their // identity resolves and the first fetch replaces it initial?: DocumentQuestionsData; }; }) { let { identity, identityPending } = useIdentityData(); let viewer = identity?.atp_did ?? null; // An anonymous reader of a cached page has nothing to add to the public // list it was rendered with, until something happens to it. let [changed, setChanged] = useState(false); let needsFetch = !identityPending && (!!viewer || !props.initialQuestions || changed); let { data, mutate } = useSWR( needsFetch ? ["document_questions", props.documentUri, viewer] : null, () => getDocumentQuestions(props.documentUri), { fallbackData: props.editor?.initial ?? (props.initialQuestions && { isAuthor: false, questions: props.initialQuestions, }), }, ); useDocumentEvents(props.documentUri, ["question", "answer"], () => { setChanged(true); mutate(); }); // Questions whose inline answer editor is open (editor only), and whether // opening it started the draft let [editing, setEditing] = useState>({}); let closeEditor = (uri: string) => setEditing(({ [uri]: _, ...rest }) => rest); let run = useActionToast(questionErrorMessages); let [busy, setBusy] = useState(false); let questions = (data?.questions ?? []).filter( (q) => q.block === props.block, ); let setState = async (question: string, state: QuestionState) => { setBusy(true); let ok = await run(() => setQuestionState({ subject: props.documentUri, question, state }), ); if (ok) await mutate( (current) => current && { ...current, questions: current.questions.map((q) => q.uri === question ? { ...q, state: state === "pending" && q.answer ? "accepted" : state, } : q, ), }, { revalidate: false }, ); setBusy(false); }; let toaster = useToaster(); let pathname = usePathname(); let redirectRoute = useMemo(() => { if (typeof window === "undefined") return; return new URL(pathname, window.location.origin).toString(); }, [pathname]); let withdraw = async (uri: string) => { if (await run(() => withdrawQuestion(uri))) await mutate( (current) => current && { ...current, questions: current.questions.filter((q) => q.uri !== uri), }, { revalidate: false }, ); }; if (props.editor) { let editor = props.editor; let preview = ( ); if (!data) return preview; let isAuthor = data.isAuthor; return ( editing[q.uri] ? ( { await mutate(); closeEditor(q.uri); }} onClose={() => closeEditor(q.uri)} /> ) : ( publishedAnswer(q, showHandle) ) } renderControl={(q) => isAuthor && !editing[q.uri] && ( setEditing((e) => ({ ...e, [q.uri]: { fresh } })) } /> ) } /> ); } if (!!viewer && isDocumentOwner(props.documentUri, viewer)) return ( ); if (!data) return null; let ask = async (text: FacetedText) => { let result = await run(() => askQuestion({ subject: props.documentUri, block: props.block, ...text, }), ); if (!result) return false; await mutate(); toaster({ content: "Question sent.", type: "success" }); return true; }; return ( ) : viewer ? ( ) : ( ) } /> ); } const publishedAnswer = (q: DocumentQuestion, showHandle?: boolean) => q.answer ? ( ) : null; // The author on their published post: answers are written in the editor. function PublishedAuthorView(props: { documentUri: string; questions: DocumentQuestion[]; busy: boolean; onSetState: (uri: string, state: QuestionState) => void; }) { let editLink = usePostEditLink( props.documentUri, new AtUri(props.documentUri).host, ); return ( !q.answer && editLink && ( window.location.assign(editLink)} > Answer in editor ) } /> ); } // Opens the inline editor, starting a draft when the question has none. function EditorAnswerControl(props: { question: DocumentQuestion; blockEntity: string; onOpen: (fresh: boolean) => void; }) { let hasDraft = useHasAnswerDraft(props.blockEntity, props.question.uri); let start = useStartAnswer(props.blockEntity); return ( { if (!hasDraft) await start(props.question.uri); props.onOpen(!hasDraft); }} > {hasDraft ? props.question.answer ? "Edit answer" : "Continue answer" : props.question.answer ? "Write a new answer" : "Answer"} ); }