373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import {
|
|
createSignal,
|
|
createEffect,
|
|
For,
|
|
Show,
|
|
onMount,
|
|
onCleanup
|
|
} from "solid-js";
|
|
import { useLocation } from "@solidjs/router";
|
|
import type {
|
|
CommentBlockProps,
|
|
CommentReaction,
|
|
UserPublicData
|
|
} from "~/types/comment";
|
|
import { createWindowWidth } from "~/lib/resize-utils";
|
|
import UserDefaultImage from "~/components/icons/UserDefaultImage";
|
|
import ReplyIcon from "~/components/icons/ReplyIcon";
|
|
import TrashIcon from "~/components/icons/TrashIcon";
|
|
import EditIcon from "~/components/icons/EditIcon";
|
|
import ThumbsUpEmoji from "~/components/icons/emojis/ThumbsUp";
|
|
import LoadingSpinner from "~/components/LoadingSpinner";
|
|
import CommentInputBlock from "./CommentInputBlock";
|
|
import ReactionBar from "./ReactionBar";
|
|
|
|
export default function CommentBlock(props: CommentBlockProps) {
|
|
const location = useLocation();
|
|
|
|
// State signals
|
|
const [commentCollapsed, setCommentCollapsed] = createSignal(false);
|
|
const [showingReactionOptions, setShowingReactionOptions] =
|
|
createSignal(false);
|
|
const [replyBoxShowing, setReplyBoxShowing] = createSignal(false);
|
|
const [toggleHeight, setToggleHeight] = createSignal(0);
|
|
const [reactions, setReactions] = createSignal<CommentReaction[]>([]);
|
|
const windowWidth = createWindowWidth(200);
|
|
const [deletionLoading, setDeletionLoading] = createSignal(false);
|
|
const [userData, setUserData] = createSignal<UserPublicData | null>(null);
|
|
|
|
// Refs
|
|
let containerRef: HTMLDivElement | undefined;
|
|
let commentInputRef: HTMLDivElement | undefined;
|
|
|
|
// Auto-collapse at level 4+
|
|
createEffect(() => {
|
|
setCommentCollapsed(props.level >= 4);
|
|
});
|
|
|
|
// Find user data from comment map
|
|
createEffect(() => {
|
|
if (props.userCommentMap) {
|
|
props.userCommentMap.forEach((commentIds, user) => {
|
|
if (commentIds.includes(props.comment.id)) {
|
|
setUserData(user);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Update toggle height based on container size
|
|
createEffect(() => {
|
|
if (containerRef) {
|
|
const correction = showingReactionOptions() ? 80 : 48;
|
|
setToggleHeight(containerRef.clientHeight + correction);
|
|
}
|
|
// Trigger on these dependencies
|
|
windowWidth();
|
|
showingReactionOptions();
|
|
});
|
|
|
|
// Update reactions from map
|
|
createEffect(() => {
|
|
setReactions(props.reactionMap.get(props.comment.id) || []);
|
|
});
|
|
|
|
// Event handlers
|
|
const collapseCommentToggle = (e: MouseEvent) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
setCommentCollapsed(!commentCollapsed());
|
|
};
|
|
|
|
const showingReactionOptionsToggle = (e: MouseEvent) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
setShowingReactionOptions(!showingReactionOptions());
|
|
};
|
|
|
|
const toggleCommentReplyBox = (e: MouseEvent) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
setReplyBoxShowing(!replyBoxShowing());
|
|
};
|
|
|
|
const deleteCommentTrigger = async (e: MouseEvent) => {
|
|
e.stopPropagation();
|
|
setDeletionLoading(true);
|
|
const user = userData();
|
|
props.toggleModification(
|
|
props.comment.id,
|
|
props.comment.commenter_id,
|
|
props.comment.body,
|
|
"delete",
|
|
user?.image,
|
|
user?.email,
|
|
user?.display_name
|
|
);
|
|
setDeletionLoading(false);
|
|
};
|
|
|
|
const editCommentTrigger = (e: MouseEvent) => {
|
|
e.stopPropagation();
|
|
const user = userData();
|
|
props.toggleModification(
|
|
props.comment.id,
|
|
props.comment.commenter_id,
|
|
props.comment.body,
|
|
"edit",
|
|
user?.image,
|
|
user?.email,
|
|
user?.display_name
|
|
);
|
|
};
|
|
|
|
// Computed values
|
|
const upvoteCount = () =>
|
|
reactions().filter((r) => r.type === "upVote").length;
|
|
|
|
const downvoteCount = () =>
|
|
reactions().filter((r) => r.type === "downVote").length;
|
|
|
|
const hasUpvoted = () =>
|
|
reactions().some(
|
|
(r) => r.type === "upVote" && r.user_id === props.currentUserID
|
|
);
|
|
|
|
const hasDownvoted = () =>
|
|
reactions().some(
|
|
(r) => r.type === "downVote" && r.user_id === props.currentUserID
|
|
);
|
|
|
|
const canDelete = () =>
|
|
props.currentUserID === props.comment.commenter_id ||
|
|
props.privilegeLevel === "admin";
|
|
|
|
const canEdit = () => props.currentUserID === props.comment.commenter_id;
|
|
|
|
const isAnonymous = () => props.privilegeLevel === "anonymous";
|
|
|
|
const replyIconColor = () => "var(--color-peach)";
|
|
|
|
return (
|
|
<>
|
|
{/* Collapsed state */}
|
|
<Show when={commentCollapsed()}>
|
|
<button
|
|
onClick={collapseCommentToggle}
|
|
class="ml-5 w-full px-2 lg:w-3/4"
|
|
>
|
|
<div class="border-text my-auto mt-1 mr-2 h-8 border-l-2" />
|
|
</button>
|
|
</Show>
|
|
|
|
{/* Expanded state */}
|
|
<Show when={!commentCollapsed()}>
|
|
<div class="z-500 transition-all duration-300 ease-in-out">
|
|
<div class="my-4 flex w-full overflow-x-hidden overflow-y-hidden lg:w-3/4">
|
|
{/* Vote buttons column */}
|
|
<div
|
|
class="flex flex-col justify-between"
|
|
style={{ height: `${toggleHeight()}px` }}
|
|
>
|
|
{/* Upvote */}
|
|
<button
|
|
onClick={() =>
|
|
props.commentReaction("upVote", props.comment.id)
|
|
}
|
|
>
|
|
<div
|
|
class={`h-5 w-5 ${
|
|
hasUpvoted()
|
|
? "fill-green"
|
|
: `fill-text hover:fill-green ${
|
|
isAnonymous() ? "tooltip z-50" : ""
|
|
}`
|
|
}`}
|
|
>
|
|
<ThumbsUpEmoji />
|
|
<Show when={isAnonymous()}>
|
|
<div class="tooltip-text -ml-16 w-32 text-white">
|
|
You must be logged in
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Vote count */}
|
|
<div class="mx-auto">{upvoteCount() - downvoteCount()}</div>
|
|
|
|
{/* Downvote */}
|
|
<button
|
|
onClick={() =>
|
|
props.commentReaction("downVote", props.comment.id)
|
|
}
|
|
>
|
|
<div
|
|
class={`h-5 w-5 ${
|
|
hasDownvoted()
|
|
? "fill-red"
|
|
: `fill-text hover:fill-red ${
|
|
isAnonymous() ? "tooltip z-50" : ""
|
|
}`
|
|
}`}
|
|
>
|
|
<div class="rotate-180">
|
|
<ThumbsUpEmoji />
|
|
</div>
|
|
<Show when={isAnonymous()}>
|
|
<div class="tooltip-text -ml-16 w-32">
|
|
You must be logged in
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Collapse toggle line */}
|
|
<button onClick={collapseCommentToggle} class="z-0 px-2">
|
|
<div
|
|
class="border-text border-l-2 transition-all duration-300 ease-in-out"
|
|
style={{ height: `${toggleHeight()}px` }}
|
|
/>
|
|
</button>
|
|
|
|
{/* Comment content */}
|
|
<div
|
|
class="w-3/4"
|
|
onClick={showingReactionOptionsToggle}
|
|
id={props.comment.id.toString()}
|
|
>
|
|
<div
|
|
ref={containerRef}
|
|
class="overflow-x-hidden overflow-y-hidden select-text"
|
|
>
|
|
<div class="max-w-[90%] md:max-w-[75%]">
|
|
{props.comment.body}
|
|
</div>
|
|
<Show when={props.comment.edited}>
|
|
<div class="pb-0.5 text-xs italic">Edited</div>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* User info */}
|
|
<div class="flex pl-2">
|
|
<Show
|
|
when={userData()?.image}
|
|
fallback={
|
|
<UserDefaultImage strokeWidth={1} height={24} width={24} />
|
|
}
|
|
>
|
|
<img
|
|
src={userData()!.image}
|
|
height={24}
|
|
width={24}
|
|
alt="user-image"
|
|
class="h-6 w-6 rounded-full object-cover object-center"
|
|
/>
|
|
</Show>
|
|
<div class="px-1">
|
|
{userData()?.display_name || userData()?.email || "[removed]"}
|
|
</div>
|
|
|
|
{/* Delete button */}
|
|
<Show when={canDelete()}>
|
|
<button onClick={deleteCommentTrigger}>
|
|
<Show
|
|
when={!deletionLoading()}
|
|
fallback={<LoadingSpinner height={24} width={24} />}
|
|
>
|
|
<TrashIcon
|
|
height={24}
|
|
width={24}
|
|
stroke="var(--color-red)"
|
|
strokeWidth={1.5}
|
|
/>
|
|
</Show>
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Edit and Reply buttons */}
|
|
<div class="absolute flex">
|
|
<Show when={canEdit()}>
|
|
<button onClick={editCommentTrigger} class="px-2">
|
|
<EditIcon strokeWidth={1} height={24} width={24} />
|
|
</button>
|
|
</Show>
|
|
<button onClick={toggleCommentReplyBox} class="z-30">
|
|
<ReplyIcon color={replyIconColor()} height={24} width={24} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Reaction bar */}
|
|
<div
|
|
class={`${
|
|
showingReactionOptions() || reactions().length > 0
|
|
? ""
|
|
: "opacity-0"
|
|
} ml-16`}
|
|
>
|
|
<ReactionBar
|
|
commentID={props.comment.id}
|
|
currentUserID={props.currentUserID}
|
|
reactions={reactions()}
|
|
showingReactionOptions={showingReactionOptions()}
|
|
privilegeLevel={props.privilegeLevel}
|
|
commentReaction={props.commentReaction}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reply box */}
|
|
<Show when={replyBoxShowing()}>
|
|
<div
|
|
ref={commentInputRef}
|
|
class="fade-in lg:w-2/3"
|
|
style={{ "margin-left": `${-24 * props.recursionCount}px` }}
|
|
>
|
|
<CommentInputBlock
|
|
isReply={true}
|
|
privilegeLevel={props.privilegeLevel}
|
|
parent_id={props.comment.id}
|
|
post_id={props.projectID}
|
|
currentUserID={props.currentUserID}
|
|
socket={props.socket}
|
|
newComment={props.newComment}
|
|
commentSubmitLoading={props.commentSubmitLoading}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Recursive child comments */}
|
|
<div class="pl-2 sm:pl-4 md:pl-8 lg:pl-12">
|
|
<For each={props.child_comments}>
|
|
{(childComment) => (
|
|
<CommentBlock
|
|
comment={childComment}
|
|
projectID={props.projectID}
|
|
recursionCount={1}
|
|
allComments={props.allComments}
|
|
child_comments={props.allComments?.filter(
|
|
(comment) => comment.parent_comment_id === childComment.id
|
|
)}
|
|
privilegeLevel={props.privilegeLevel}
|
|
currentUserID={props.currentUserID}
|
|
reactionMap={props.reactionMap}
|
|
level={props.level + 1}
|
|
socket={props.socket}
|
|
userCommentMap={props.userCommentMap}
|
|
toggleModification={props.toggleModification}
|
|
newComment={props.newComment}
|
|
commentSubmitLoading={props.commentSubmitLoading}
|
|
commentReaction={props.commentReaction}
|
|
/>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</>
|
|
);
|
|
}
|