mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-27 15:56:21 +02:00
293 lines
8.3 KiB
TypeScript
293 lines
8.3 KiB
TypeScript
"use client";
|
|
import { cn } from "@rallly/ui";
|
|
import { Badge } from "@rallly/ui/badge";
|
|
import { Button } from "@rallly/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@rallly/ui/card";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@rallly/ui/dropdown-menu";
|
|
import { Icon } from "@rallly/ui/icon";
|
|
import { Textarea } from "@rallly/ui/textarea";
|
|
import dayjs from "dayjs";
|
|
import {
|
|
MessageSquareOffIcon,
|
|
MoreHorizontalIcon,
|
|
TrashIcon,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "next-i18next";
|
|
import * as React from "react";
|
|
import { Controller, useForm } from "react-hook-form";
|
|
|
|
import { useParticipants } from "@/components/participants-provider";
|
|
import { Trans } from "@/components/trans";
|
|
import { usePermissions } from "@/contexts/permissions";
|
|
import { usePoll } from "@/contexts/poll";
|
|
import { useRole } from "@/contexts/role";
|
|
import { usePostHog } from "@/utils/posthog";
|
|
import { trpc } from "@/utils/trpc/client";
|
|
|
|
import { requiredString } from "../../utils/form-validation";
|
|
import NameInput from "../name-input";
|
|
import TruncatedLinkify from "../poll/truncated-linkify";
|
|
import UserAvatar from "../poll/user-avatar";
|
|
import { useUser } from "../user-provider";
|
|
|
|
interface CommentForm {
|
|
authorName: string;
|
|
content: string;
|
|
}
|
|
|
|
function NewCommentForm({
|
|
onSubmit,
|
|
onCancel,
|
|
}: {
|
|
onSubmit?: () => void;
|
|
onCancel?: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const poll = usePoll();
|
|
const { user } = useUser();
|
|
const { participants } = useParticipants();
|
|
|
|
const authorName = React.useMemo(() => {
|
|
if (user.isGuest) {
|
|
const participant = participants.find((p) => p.userId === user.id);
|
|
return participant?.name ?? "";
|
|
} else {
|
|
return user.name;
|
|
}
|
|
}, [user, participants]);
|
|
|
|
const pollId = poll.id;
|
|
|
|
const posthog = usePostHog();
|
|
|
|
const queryClient = trpc.useUtils();
|
|
|
|
const addComment = trpc.polls.comments.add.useMutation({
|
|
onSuccess: () => {
|
|
queryClient.polls.comments.invalidate();
|
|
posthog?.capture("created comment");
|
|
},
|
|
});
|
|
|
|
const session = useUser();
|
|
|
|
const { register, reset, control, handleSubmit, formState } =
|
|
useForm<CommentForm>({
|
|
defaultValues: {
|
|
authorName,
|
|
content: "",
|
|
},
|
|
});
|
|
|
|
return (
|
|
<form
|
|
className="w-full space-y-2.5"
|
|
onSubmit={handleSubmit(async ({ authorName, content }) => {
|
|
await addComment.mutateAsync({ authorName, content, pollId });
|
|
reset({ authorName, content: "" });
|
|
onSubmit?.();
|
|
})}
|
|
>
|
|
<div>
|
|
<Textarea
|
|
id="comment"
|
|
className="w-full"
|
|
autoFocus={true}
|
|
placeholder={t("commentPlaceholder")}
|
|
{...register("content", { validate: requiredString })}
|
|
/>
|
|
</div>
|
|
<div
|
|
className={cn("mb-2", {
|
|
hidden: !user.isGuest,
|
|
})}
|
|
>
|
|
<Controller
|
|
name="authorName"
|
|
key={session.user?.id}
|
|
control={control}
|
|
rules={{ validate: requiredString }}
|
|
render={({ field }) => (
|
|
<NameInput error={!!formState.errors.authorName} {...field} />
|
|
)}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2.5">
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
loading={formState.isSubmitting}
|
|
>
|
|
<Trans defaults="Add Comment" i18nKey="addComment" />
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
reset();
|
|
onCancel?.();
|
|
}}
|
|
>
|
|
{t("cancel")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function DiscussionInner() {
|
|
const { t } = useTranslation();
|
|
const poll = usePoll();
|
|
|
|
const pollId = poll.id;
|
|
|
|
const { data: comments } = trpc.polls.comments.list.useQuery(
|
|
{ pollId },
|
|
{
|
|
staleTime: 1000 * 5,
|
|
},
|
|
);
|
|
const posthog = usePostHog();
|
|
|
|
const queryClient = trpc.useUtils();
|
|
|
|
const deleteComment = trpc.polls.comments.delete.useMutation({
|
|
onMutate: ({ commentId }) => {
|
|
queryClient.polls.comments.list.setData(
|
|
{ pollId },
|
|
(existingComments = []) => {
|
|
return [...existingComments].filter(({ id }) => id !== commentId);
|
|
},
|
|
);
|
|
},
|
|
onSuccess: () => {
|
|
posthog?.capture("deleted comment");
|
|
},
|
|
});
|
|
|
|
const session = useUser();
|
|
|
|
const [isWriting, setIsWriting] = React.useState(false);
|
|
const role = useRole();
|
|
const { isUser } = usePermissions();
|
|
|
|
if (!comments) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>
|
|
{t("comments")}
|
|
<Badge>{comments.length}</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
{comments.length ? (
|
|
<CardContent className="border-b">
|
|
<div className="space-y-4">
|
|
{comments.map((comment) => {
|
|
const canDelete =
|
|
role === "admin" || (comment.userId && isUser(comment.userId));
|
|
|
|
return (
|
|
<div className="" key={comment.id}>
|
|
<div data-testid="comment">
|
|
<div className="mb-1 flex items-center space-x-2">
|
|
<UserAvatar
|
|
name={comment.authorName}
|
|
showName={true}
|
|
isYou={session.ownsObject(comment)}
|
|
/>
|
|
<div className="flex items-center gap-2 text-sm ">
|
|
<div className="text-gray-500">
|
|
{dayjs(comment.createdAt).fromNow()}
|
|
</div>
|
|
{canDelete && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild={true}>
|
|
<button className="hover:text-foreground text-gray-500">
|
|
<MoreHorizontalIcon className="size-4" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuItem
|
|
className="text-destructive"
|
|
onSelect={() => {
|
|
deleteComment.mutate({
|
|
commentId: comment.id,
|
|
});
|
|
}}
|
|
>
|
|
<TrashIcon className="size-4" />
|
|
<Trans i18nKey="delete" />
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="ml-0.5 w-fit whitespace-pre-wrap pl-7 text-sm leading-relaxed">
|
|
<TruncatedLinkify>{comment.content}</TruncatedLinkify>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
) : null}
|
|
{!poll.event ? (
|
|
<CardFooter className="border-t-0">
|
|
{isWriting ? (
|
|
<NewCommentForm
|
|
onSubmit={() => {
|
|
setIsWriting(false);
|
|
}}
|
|
onCancel={() => {
|
|
setIsWriting(false);
|
|
}}
|
|
/>
|
|
) : (
|
|
<button
|
|
className="border-input text-muted-foreground flex w-full rounded border bg-transparent px-2 py-2 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
|
|
onClick={() => setIsWriting(true)}
|
|
>
|
|
<Trans
|
|
i18nKey="commentPlaceholder"
|
|
defaults="Leave a comment on this poll (visible to everyone)"
|
|
/>
|
|
</button>
|
|
)}
|
|
</CardFooter>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default function Discussion() {
|
|
const poll = usePoll();
|
|
if (poll.disableComments) {
|
|
return (
|
|
<p className="text-muted-foreground rounded-lg bg-gray-100 p-4 text-center text-sm">
|
|
<Icon>
|
|
<MessageSquareOffIcon className="mr-2 inline-block" />
|
|
</Icon>
|
|
<Trans
|
|
i18nKey="commentsDisabled"
|
|
defaults="Comments have been disabled"
|
|
/>
|
|
</p>
|
|
);
|
|
}
|
|
return <DiscussionInner />;
|
|
}
|