Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
9.9 kB · 314 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315"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<Record<QuestionError["type"], string>> = { 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<Record<string, { fresh: boolean }>>({}); let closeEditor = (uri: string) => setEditing(({ [uri]: _, ...rest }) => rest); let run = useActionToast<QuestionError>(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 = ( <QuestionComposerPreview action={editor.action} audience={props.audience} showPending={props.showPending} /> ); if (!data) return preview; let isAuthor = data.isAuthor; return ( <QuestionsModeration questions={questions} header={preview} busy={busy} onSetState={isAuthor ? setState : undefined} renderAnswer={(q, showHandle) => editing[q.uri] ? ( <AnswerEditor blockEntity={editor.blockEntity} question={q.uri} page={editor.page} published={!!q.answer} discardOnClose={editing[q.uri].fresh} onPublished={async () => { await mutate(); closeEditor(q.uri); }} onClose={() => closeEditor(q.uri)} /> ) : ( publishedAnswer(q, showHandle) ) } renderControl={(q) => isAuthor && !editing[q.uri] && ( <EditorAnswerControl question={q} blockEntity={editor.blockEntity} onOpen={(fresh) => setEditing((e) => ({ ...e, [q.uri]: { fresh } })) } /> ) } /> ); } if (!!viewer && isDocumentOwner(props.documentUri, viewer)) return ( <PublishedAuthorView documentUri={props.documentUri} questions={questions} busy={busy} onSetState={setState} /> ); 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 ( <QuestionsList questions={questions} onWithdraw={withdraw} renderAnswer={publishedAnswer} action={ identityPending ? ( <ComposerPlaceholder className="min-h-20!" /> ) : viewer ? ( <QuestionComposer draftKey={`question:${props.documentUri}${props.block ? `:${props.block}` : ""}`} submitLabel={props.buttonText} audience={props.audience} showPending={props.showPending} autoFocus={props.autoFocus} onSubmit={ask} /> ) : ( <SignInToAsk linkAccount={!!identity} redirectRoute={redirectRoute} /> ) } /> );}
const publishedAnswer = (q: DocumentQuestion, showHandle?: boolean) => q.answer ? ( <PublishedAnswer answer={q.answer} showHandle={showHandle} /> ) : 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 ( <QuestionsModeration questions={props.questions} busy={props.busy} onSetState={props.onSetState} renderAnswer={publishedAnswer} renderControl={(q) => !q.answer && editLink && ( <ButtonSecondary compact onClick={() => window.location.assign(editLink)} > Answer in editor </ButtonSecondary> ) } /> );}
// 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 ( <ButtonPrimary compact onClick={async () => { 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"} </ButtonPrimary> );}