'use client' import { useEffect, useId, useRef, useState, type FormEvent } from 'react' import { cn } from '../../lib/cn' import { plural } from '../../lib/format' import { relativeTime } from '../../lib/time' import { Badge } from '../Badge' import { Button } from '../Button' import { Checkbox } from '../Checkbox' import { IconButton } from '../IconButton' import { InlineMessage } from '../InlineMessage' import { Menu } from '../Menu' import { SegmentedControl } from '../SegmentedControl' import { Text } from '../Text' import { Textarea } from '../Textarea' import { ChevronUpIcon } from '../internal/icons' import type { IconComponent } from '../../lib/types' export type QuestionQueueSort = 'top' | 'newest' export type QuestionQueueAction = 'answer' | 'reopen' | 'pin' | 'unpin' | 'hide' export interface QuestionQueueQuestion { id: string text: string /** Who asked. Omit for an anonymous question. */ author?: string votes: number /** Whether the person looking has upvoted it. */ voted?: boolean answered?: boolean /** Pinned questions sit above the rest — the one being answered now. */ pinned?: boolean /** Hidden by a host. Left out of the list. */ hidden?: boolean createdAt: Date } export interface QuestionQueueProps { questions: QuestionQueueQuestion[] /** Submit a question. Return a promise to show progress; reject with a message to show it. */ onAsk: (text: string, options: { anonymous: boolean }) => void | Promise /** Upvote toggled. `voted` is the new state. */ onVote: (id: string, voted: boolean) => void /** Show the moderation menu on every question. */ isHost?: boolean /** A host acted on a question. */ onModerate?: (id: string, action: QuestionQueueAction) => void /** Order (controlled). */ sort?: QuestionQueueSort /** Initial order when uncontrolled. */ defaultSort?: QuestionQueueSort onSortChange?: (sort: QuestionQueueSort) => void /** Longest question accepted. */ maxLength?: number /** Allow asking without a name. */ allowAnonymous?: boolean /** Merged last, so it wins. */ className?: string } const snippet = (text: string) => (text.length > 60 ? `${text.slice(0, 57)}…` : text) const DotsIcon: IconComponent = ({ size = 16, className }) => ( ) /** * Live Q&A for a talk, an all-hands or a webinar: ask, upvote, answer. * * The order is what makes it work. Pinned questions — the one on stage right * now — come first, open questions follow by votes or by time, and answered * ones sink to the bottom still visible, so nobody asks the same thing again. * Votes are toggles with a pressed state rather than one-way counters, because * a mis-tap has to be undoable. * * New questions arriving from other people are announced politely, batched * into one sentence when several land together, and never for the reader’s * own submission — they already know. Moderation lives in one menu per * question and only for hosts, so the audience view stays a list of questions. */ export function QuestionQueue({ questions, onAsk, onVote, isHost = false, onModerate, sort: controlledSort, defaultSort = 'top', onSortChange, maxLength = 280, allowAnonymous = true, className, }: QuestionQueueProps) { const fieldId = useId() const countId = useId() const anonymousId = useId() const [text, setText] = useState('') const [anonymous, setAnonymous] = useState(false) const [sending, setSending] = useState(false) const [error, setError] = useState(null) const [uncontrolledSort, setUncontrolledSort] = useState(defaultSort) const sort = controlledSort ?? uncontrolledSort const [announcement, setAnnouncement] = useState('') const headingRef = useRef(null) const seen = useRef | null>(null) const ownText = useRef(null) useEffect(() => { const ids = new Set(questions.map((question) => question.id)) if (seen.current === null) { seen.current = ids return } const fresh = questions.filter( (question) => !seen.current!.has(question.id) && !question.hidden && question.text !== ownText.current, ) seen.current = ids if (fresh.length === 1) setAnnouncement(`New question: ${fresh[0]!.text}`) else if (fresh.length > 1) setAnnouncement(plural(fresh.length, 'new question')) }, [questions]) const setSort = (next: QuestionQueueSort) => { if (controlledSort === undefined) setUncontrolledSort(next) onSortChange?.(next) } const ordered = questions .filter((question) => !question.hidden) .sort((a, b) => { const rank = (question: QuestionQueueQuestion) => (question.pinned ? 0 : question.answered ? 2 : 1) if (rank(a) !== rank(b)) return rank(a) - rank(b) if (sort === 'top' && a.votes !== b.votes) return b.votes - a.votes return b.createdAt.getTime() - a.createdAt.getTime() }) const submit = async (event: FormEvent) => { event.preventDefault() const question = text.trim() if (!question) { setError('Write a question first.') return } setSending(true) setError(null) ownText.current = question try { await onAsk(question, { anonymous: allowAnonymous && anonymous }) setText('') setAnnouncement('Your question was posted.') } catch (reason) { setError(reason instanceof Error ? reason.message : 'The question could not be posted.') } finally { setSending(false) } } const moderate = (question: QuestionQueueQuestion, action: QuestionQueueAction) => { onModerate?.(question.id, action) if (action !== 'hide') return setAnnouncement('Question hidden.') // The question and its menu trigger are gone, so focus goes to the list heading instead of the page. window.setTimeout(() => headingRef.current?.focus(), 0) } const left = maxLength - text.length return (
void submit(event)} noValidate className="flex flex-col gap-2">