comment fixing

This commit is contained in:
Michael Freno
2026-01-06 13:40:32 -05:00
parent 1f661f4f89
commit 133800f2e3
7 changed files with 296 additions and 64 deletions

1
.gitignore vendored
View File

@@ -20,6 +20,7 @@ app.config.timestamp_*.js
*.launch *.launch
.settings/ .settings/
tasks tasks
main.go
# Temp # Temp
gitignore gitignore

View File

@@ -429,7 +429,7 @@ export function LeftBar() {
tabindex="-1" tabindex="-1"
ref={ref} ref={ref}
aria-label="Main navigation" aria-label="Main navigation"
class="border-r-overlay2 bg-base fixed z-9999 h-dvh border-r-2 transition-transform duration-500 ease-out" class="border-r-overlay2 bg-base fixed z-200 h-dvh border-r-2 transition-transform duration-500 ease-out"
classList={{ classList={{
"-translate-x-full": !leftBarVisible(), "-translate-x-full": !leftBarVisible(),
"translate-x-0": leftBarVisible() "translate-x-0": leftBarVisible()

View File

@@ -76,7 +76,6 @@ export default function CommentBlock(props: CommentBlockProps) {
const deleteCommentTrigger = async (e: MouseEvent) => { const deleteCommentTrigger = async (e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
console.log("Delete comment");
setDeletionLoading(true); setDeletionLoading(true);
const user = userData(); const user = userData();

View File

@@ -49,7 +49,7 @@ export default function CommentDeletionPrompt(
open={props.isOpen} open={props.isOpen}
onClose={props.onClose} onClose={props.onClose}
title="Comment Deletion" title="Comment Deletion"
class="bg-red brightness-110" class="bg-crust brightness-110"
> >
<div class="bg-surface0 mx-auto w-3/4 rounded px-6 py-4"> <div class="bg-surface0 mx-auto w-3/4 rounded px-6 py-4">
<div class="flex overflow-x-auto overflow-y-hidden select-text"> <div class="flex overflow-x-auto overflow-y-hidden select-text">
@@ -99,9 +99,7 @@ export default function CommentDeletionPrompt(
checked={adminDeleteChecked()} checked={adminDeleteChecked()}
onChange={handleAdminDeleteCheckbox} onChange={handleAdminDeleteCheckbox}
/> />
<div class="my-auto px-2 text-sm font-normal"> <div class="my-auto px-2 text-sm font-normal">Admin Delete?</div>
Confirm Admin Delete?
</div>
</div> </div>
</div> </div>
<div class="flex w-full justify-center"> <div class="flex w-full justify-center">
@@ -112,9 +110,7 @@ export default function CommentDeletionPrompt(
checked={fullDeleteChecked()} checked={fullDeleteChecked()}
onChange={handleFullDeleteCheckbox} onChange={handleFullDeleteCheckbox}
/> />
<div class="my-auto px-2 text-sm font-normal"> <div class="my-auto px-2 text-sm font-normal">Database Delete?</div>
Confirm Full Delete (removal from database)?
</div>
</div> </div>
</div> </div>
</Show> </Show>

View File

@@ -18,6 +18,9 @@ import { env } from "~/env/client";
const MAX_RETRIES = 12; const MAX_RETRIES = 12;
const RETRY_INTERVAL = 5000; const RETRY_INTERVAL = 5000;
const OPERATION_TIMEOUT = 10000; // 10 seconds timeout for operations
type ConnectionState = "disconnected" | "connecting" | "connected" | "error";
export default function CommentSectionWrapper( export default function CommentSectionWrapper(
props: CommentSectionWrapperProps props: CommentSectionWrapperProps
@@ -55,43 +58,106 @@ export default function CommentSectionWrapper(
] = createSignal<string | undefined>(undefined); ] = createSignal<string | undefined>(undefined);
const [commentBodyForModification, setCommentBodyForModification] = const [commentBodyForModification, setCommentBodyForModification] =
createSignal<string>(""); createSignal<string>("");
const [operationError, setOperationError] = createSignal<string>("");
let userCommentMap: Map<UserPublicData, number[]> = props.userCommentMap; const [connectionState, setConnectionState] =
createSignal<ConnectionState>("disconnected");
const [userCommentMap, setUserCommentMap] = createSignal<
Map<UserPublicData, number[]>
>(props.userCommentMap);
let deletePromptRef: HTMLDivElement | undefined; let deletePromptRef: HTMLDivElement | undefined;
let modificationPromptRef: HTMLDivElement | undefined; let modificationPromptRef: HTMLDivElement | undefined;
let retryCount = 0; let retryCount = 0;
let socket: WebSocket | undefined; let socket: WebSocket | undefined;
let commentSubmitTimeoutId: number | undefined;
let editCommentTimeoutId: number | undefined;
let deleteCommentTimeoutId: number | undefined;
let reconnectTimeoutId: number | undefined;
let isMounted = true;
let intentionalDisconnect = false;
createEffect(() => { createEffect(() => {
const connect = () => { const connect = () => {
if (socket) return; // Don't connect if not mounted or intentionally disconnected
if (!isMounted || intentionalDisconnect) {
console.log(
"[WebSocket] Skipping connection: component unmounted or intentional disconnect"
);
return;
}
if (socket) {
console.log("[WebSocket] Socket already exists");
return;
}
if (retryCount > MAX_RETRIES) { if (retryCount > MAX_RETRIES) {
console.error("Max retries exceeded!"); console.error("[WebSocket] Max retries exceeded!");
setConnectionState("error");
return;
}
// Validate we have required data before connecting
if (!props.id) {
console.warn("[WebSocket] No post ID available, skipping connection");
return; return;
} }
const websocketUrl = env.VITE_WEBSOCKET; const websocketUrl = env.VITE_WEBSOCKET;
if (!websocketUrl) { if (!websocketUrl) {
console.error("VITE_WEBSOCKET environment variable not set"); console.error(
"[WebSocket] VITE_WEBSOCKET environment variable not set"
);
setConnectionState("error");
return; return;
} }
console.log(
`[WebSocket] Connecting... (attempt ${retryCount + 1}/${MAX_RETRIES + 1})`
);
setConnectionState("connecting");
const newSocket = new WebSocket(websocketUrl); const newSocket = new WebSocket(websocketUrl);
newSocket.onopen = () => { newSocket.onopen = () => {
console.log("[WebSocket] Connected successfully");
setConnectionState("connected");
updateChannel(); updateChannel();
retryCount = 0; retryCount = 0;
}; };
newSocket.onclose = () => { newSocket.onclose = (event) => {
retryCount += 1; console.log(
`[WebSocket] Connection closed (code: ${event.code}, reason: ${event.reason || "none"})`
);
socket = undefined; socket = undefined;
setTimeout(connect, RETRY_INTERVAL); setConnectionState("disconnected");
// Only retry if still mounted and not intentional disconnect
if (isMounted && !intentionalDisconnect && retryCount <= MAX_RETRIES) {
retryCount += 1;
console.log(
`[WebSocket] Scheduling reconnect in ${RETRY_INTERVAL}ms (attempt ${retryCount}/${MAX_RETRIES + 1})`
);
reconnectTimeoutId = window.setTimeout(connect, RETRY_INTERVAL);
} else {
console.log("[WebSocket] Not reconnecting:", {
isMounted,
intentionalDisconnect,
retryCount
});
}
};
newSocket.onerror = (error) => {
console.error("[WebSocket] Connection error:", error);
setConnectionState("error");
}; };
newSocket.onmessage = (messageEvent) => { newSocket.onmessage = (messageEvent) => {
try { try {
const parsed = JSON.parse(messageEvent.data) as WebSocketBroadcast; const parsed = JSON.parse(messageEvent.data) as WebSocketBroadcast;
console.log("[WebSocket] Message received:", parsed.action);
switch (parsed.action) { switch (parsed.action) {
case "commentCreationBroadcast": case "commentCreationBroadcast":
createCommentHandler(parsed); createCommentHandler(parsed);
@@ -106,10 +172,11 @@ export default function CommentSectionWrapper(
commentReactionHandler(parsed); commentReactionHandler(parsed);
break; break;
default: default:
console.log("[WebSocket] Unknown action:", parsed.action);
break; break;
} }
} catch (e) { } catch (e) {
console.error(e); console.error("[WebSocket] Error parsing message:", e);
} }
}; };
@@ -119,20 +186,58 @@ export default function CommentSectionWrapper(
connect(); connect();
onCleanup(() => { onCleanup(() => {
if (socket?.readyState === WebSocket.OPEN) { console.log("[WebSocket] Component cleanup starting");
socket.close(); isMounted = false;
socket = undefined; intentionalDisconnect = true;
// Clear reconnect timeout
if (reconnectTimeoutId) {
clearTimeout(reconnectTimeoutId);
reconnectTimeoutId = undefined;
} }
// Send disconnect message if connected
if (socket?.readyState === WebSocket.OPEN) {
try {
socket.send(
JSON.stringify({
action: "disconnect",
postType: "blog",
postID: props.id,
invokerID: props.currentUserID
})
);
console.log("[WebSocket] Disconnect message sent");
} catch (error) {
console.error("[WebSocket] Error sending disconnect message:", error);
}
socket.close(1000, "Component unmounted");
} else if (socket) {
socket.close();
}
socket = undefined;
setConnectionState("disconnected");
// Clear operation timeouts
if (commentSubmitTimeoutId) clearTimeout(commentSubmitTimeoutId);
if (editCommentTimeoutId) clearTimeout(editCommentTimeoutId);
if (deleteCommentTimeoutId) clearTimeout(deleteCommentTimeoutId);
console.log("[WebSocket] Component cleanup complete");
}); });
}); });
const updateChannel = () => { const updateChannel = () => {
if (!socket || socket.readyState !== WebSocket.OPEN) { if (!socket || socket.readyState !== WebSocket.OPEN) {
console.warn("[WebSocket] Cannot update channel: socket not ready");
return; return;
} }
if (!props.currentUserID || !props.id) { if (!props.currentUserID || !props.id) {
console.warn("Cannot update channel: missing userID or postID"); console.warn(
"[WebSocket] Cannot update channel: missing userID or postID"
);
return; return;
} }
@@ -142,24 +247,38 @@ export default function CommentSectionWrapper(
action: "channelUpdate", action: "channelUpdate",
postType: "blog", postType: "blog",
postID: props.id, postID: props.id,
invoker_id: props.currentUserID invokerID: props.currentUserID
}) })
); );
console.log(`[WebSocket] Channel updated for post ${props.id}`);
} catch (error) { } catch (error) {
console.error("Error sending channel update:", error); console.error("[WebSocket] Error sending channel update:", error);
} }
}; };
const newComment = async (commentBody: string, parentCommentID?: number) => { const newComment = async (commentBody: string, parentCommentID?: number) => {
setCommentSubmitLoading(true); setCommentSubmitLoading(true);
// Clear any existing timeout
if (commentSubmitTimeoutId) {
clearTimeout(commentSubmitTimeoutId);
}
// Set timeout to clear loading state
commentSubmitTimeoutId = window.setTimeout(() => {
console.warn("Comment submission timed out");
setCommentSubmitLoading(false);
setOperationError("Comment submission timed out. Please try again.");
}, OPERATION_TIMEOUT);
if (!props.currentUserID) { if (!props.currentUserID) {
console.warn("Cannot create comment: user not authenticated"); console.warn("Cannot create comment: user not authenticated");
clearTimeout(commentSubmitTimeoutId);
setCommentSubmitLoading(false); setCommentSubmitLoading(false);
return; return;
} }
if (commentBody && socket) { if (commentBody && socket && socket.readyState === WebSocket.OPEN) {
try { try {
socket.send( socket.send(
JSON.stringify({ JSON.stringify({
@@ -173,9 +292,11 @@ export default function CommentSectionWrapper(
); );
} catch (error) { } catch (error) {
console.error("Error sending comment creation:", error); console.error("Error sending comment creation:", error);
clearTimeout(commentSubmitTimeoutId);
await fallbackCommentCreation(commentBody, parentCommentID); await fallbackCommentCreation(commentBody, parentCommentID);
} }
} else { } else {
clearTimeout(commentSubmitTimeoutId);
await fallbackCommentCreation(commentBody, parentCommentID); await fallbackCommentCreation(commentBody, parentCommentID);
} }
}; };
@@ -205,9 +326,15 @@ export default function CommentSectionWrapper(
commenterID: props.currentUserID, commenterID: props.currentUserID,
commentParent: parentCommentID commentParent: parentCommentID
}); });
} else {
throw new Error("Failed to create comment");
} }
} catch (error) { } catch (error) {
console.error("Error in fallback comment creation:", error); console.error("Error in fallback comment creation:", error);
setOperationError("Failed to post comment. Please try again.");
if (commentSubmitTimeoutId) {
clearTimeout(commentSubmitTimeoutId);
}
setCommentSubmitLoading(false); setCommentSubmitLoading(false);
} }
}; };
@@ -215,17 +342,45 @@ export default function CommentSectionWrapper(
const createCommentHandler = async ( const createCommentHandler = async (
data: WebSocketBroadcast | BackupResponse data: WebSocketBroadcast | BackupResponse
) => { ) => {
// Clear timeout since we received response
if (commentSubmitTimeoutId) {
clearTimeout(commentSubmitTimeoutId);
}
setOperationError("");
const body = data.commentBody; const body = data.commentBody;
const commenterID = data.commenterID; const commenterID = data.commenterID;
const parentCommentID = data.commentParent; const parentCommentID = data.commentParent;
const id = data.commentID; const id = data.commentID;
console.log("[createCommentHandler] Received data:", {
body,
commenterID,
parentCommentID,
id
});
if (body && commenterID && parentCommentID !== undefined && id) { if (body && commenterID && parentCommentID !== undefined && id) {
const domain = env.VITE_DOMAIN; try {
const res = await fetch( console.log(
`${domain}/api/database/user/public-data/${commenterID}` "[createCommentHandler] Fetching user data for:",
commenterID
); );
const userData = (await res.json()) as UserPublicData; const userData = await api.database.getUserPublicData.query({
id: commenterID
});
console.log("[createCommentHandler] User data response:", userData);
if (!userData) {
console.error(
"Failed to fetch user data for commenter:",
commenterID,
"- Comment will not be displayed in UI but is saved in database"
);
setCommentSubmitLoading(false);
return;
}
const comment_date = getSQLFormattedDate(); const comment_date = getSQLFormattedDate();
const newComment: Comment = { const newComment: Comment = {
@@ -246,7 +401,7 @@ export default function CommentSectionWrapper(
} }
setAllComments((prevComments) => [...(prevComments || []), newComment]); setAllComments((prevComments) => [...(prevComments || []), newComment]);
const existingIDs = Array.from(userCommentMap.entries()).find( const existingIDs = Array.from(userCommentMap().entries()).find(
([key, _]) => ([key, _]) =>
key.email === userData.email && key.email === userData.email &&
key.display_name === userData.display_name && key.display_name === userData.display_name &&
@@ -255,9 +410,16 @@ export default function CommentSectionWrapper(
if (existingIDs) { if (existingIDs) {
const [key, ids] = existingIDs; const [key, ids] = existingIDs;
userCommentMap.set(key, [...ids, id]); const newMap = new Map(userCommentMap());
newMap.set(key, [...ids, id]);
setUserCommentMap(newMap);
} else { } else {
userCommentMap.set(userData, [id]); const newMap = new Map(userCommentMap());
newMap.set(userData, [id]);
setUserCommentMap(newMap);
}
} catch (error) {
console.error("Error fetching user data:", error);
} }
} }
setCommentSubmitLoading(false); setCommentSubmitLoading(false);
@@ -266,13 +428,26 @@ export default function CommentSectionWrapper(
const editComment = async (body: string, comment_id: number) => { const editComment = async (body: string, comment_id: number) => {
setCommentEditLoading(true); setCommentEditLoading(true);
// Clear any existing timeout
if (editCommentTimeoutId) {
clearTimeout(editCommentTimeoutId);
}
// Set timeout to clear loading state
editCommentTimeoutId = window.setTimeout(() => {
console.warn("Comment edit timed out");
setCommentEditLoading(false);
setOperationError("Comment edit timed out. Please try again.");
}, OPERATION_TIMEOUT);
if (!props.currentUserID) { if (!props.currentUserID) {
console.warn("Cannot edit comment: user not authenticated"); console.warn("Cannot edit comment: user not authenticated");
clearTimeout(editCommentTimeoutId);
setCommentEditLoading(false); setCommentEditLoading(false);
return; return;
} }
if (socket) { if (socket && socket.readyState === WebSocket.OPEN) {
try { try {
socket.send( socket.send(
JSON.stringify({ JSON.stringify({
@@ -286,12 +461,25 @@ export default function CommentSectionWrapper(
); );
} catch (error) { } catch (error) {
console.error("Error sending comment update:", error); console.error("Error sending comment update:", error);
setOperationError("Failed to edit comment. Please try again.");
clearTimeout(editCommentTimeoutId);
setCommentEditLoading(false); setCommentEditLoading(false);
} }
} else {
console.warn("WebSocket not available for edit, operation canceled");
setOperationError("Unable to edit comment. Please refresh the page.");
clearTimeout(editCommentTimeoutId);
setCommentEditLoading(false);
} }
}; };
const editCommentHandler = (data: WebSocketBroadcast) => { const editCommentHandler = (data: WebSocketBroadcast) => {
// Clear timeout since we received response
if (editCommentTimeoutId) {
clearTimeout(editCommentTimeoutId);
}
setOperationError("");
setAllComments((prev) => setAllComments((prev) =>
prev.map((comment) => { prev.map((comment) => {
if (comment.id === data.commentID) { if (comment.id === data.commentID) {
@@ -339,10 +527,23 @@ export default function CommentSectionWrapper(
setCommentDeletionLoading(true); setCommentDeletionLoading(true);
// Clear any existing timeout
if (deleteCommentTimeoutId) {
clearTimeout(deleteCommentTimeoutId);
}
// Set timeout to clear loading state
deleteCommentTimeoutId = window.setTimeout(() => {
console.warn("Comment deletion timed out");
setCommentDeletionLoading(false);
setOperationError("Comment deletion timed out. Please try again.");
}, OPERATION_TIMEOUT);
if (!props.currentUserID) { if (!props.currentUserID) {
console.warn( console.warn(
"[deleteComment] Cannot delete comment: user not authenticated" "[deleteComment] Cannot delete comment: user not authenticated"
); );
clearTimeout(deleteCommentTimeoutId);
setCommentDeletionLoading(false); setCommentDeletionLoading(false);
return; return;
} }
@@ -365,12 +566,14 @@ export default function CommentSectionWrapper(
"[deleteComment] WebSocket error, falling back to HTTP:", "[deleteComment] WebSocket error, falling back to HTTP:",
error error
); );
clearTimeout(deleteCommentTimeoutId);
await fallbackCommentDeletion(commentID, commenterID, deletionType); await fallbackCommentDeletion(commentID, commenterID, deletionType);
} }
} else { } else {
console.log( console.log(
"[deleteComment] WebSocket not available, using HTTP fallback" "[deleteComment] WebSocket not available, using HTTP fallback"
); );
clearTimeout(deleteCommentTimeoutId);
await fallbackCommentDeletion(commentID, commenterID, deletionType); await fallbackCommentDeletion(commentID, commenterID, deletionType);
} }
}; };
@@ -404,11 +607,21 @@ export default function CommentSectionWrapper(
}); });
} catch (error) { } catch (error) {
console.error("[fallbackCommentDeletion] Error:", error); console.error("[fallbackCommentDeletion] Error:", error);
setOperationError("Failed to delete comment. Please try again.");
if (deleteCommentTimeoutId) {
clearTimeout(deleteCommentTimeoutId);
}
setCommentDeletionLoading(false); setCommentDeletionLoading(false);
} }
}; };
const deleteCommentHandler = (data: WebSocketBroadcast) => { const deleteCommentHandler = (data: WebSocketBroadcast) => {
// Clear timeout since we received response
if (deleteCommentTimeoutId) {
clearTimeout(deleteCommentTimeoutId);
}
setOperationError("");
if (data.commentBody) { if (data.commentBody) {
// Soft delete (replace body with deletion message) // Soft delete (replace body with deletion message)
setAllComments((prev) => setAllComments((prev) =>
@@ -620,6 +833,27 @@ export default function CommentSectionWrapper(
return ( return (
<> <>
{/* Connection status indicator (dev mode only) */}
<Show when={import.meta.env.DEV && connectionState() !== "connected"}>
<div class="mx-auto mb-2 w-3/4 text-center text-xs italic opacity-60">
<Show when={connectionState() === "connecting"}>
<span> Connecting to live updates...</span>
</Show>
<Show when={connectionState() === "disconnected"}>
<span>📡 Live updates disconnected</span>
</Show>
<Show when={connectionState() === "error"}>
<span class="text-red"> Connection error</span>
</Show>
</div>
</Show>
<Show when={operationError()}>
<div class="bg-red/20 border-red text-red mx-auto mb-4 w-3/4 rounded-lg border px-4 py-3 text-center">
{operationError()}
</div>
</Show>
<CommentSection <CommentSection
privilegeLevel={props.privilegeLevel} privilegeLevel={props.privilegeLevel}
allComments={allComments()} allComments={allComments()}
@@ -627,7 +861,7 @@ export default function CommentSectionWrapper(
postID={props.id} postID={props.id}
reactionMap={currentReactionMap()} reactionMap={currentReactionMap()}
currentUserID={props.currentUserID} currentUserID={props.currentUserID}
userCommentMap={userCommentMap} userCommentMap={userCommentMap()}
newComment={newComment} newComment={newComment}
commentSubmitLoading={commentSubmitLoading()} commentSubmitLoading={commentSubmitLoading()}
toggleModification={toggleModification} toggleModification={toggleModification}

View File

@@ -26,7 +26,7 @@ export default function EditCommentModal(props: EditCommentModalProps) {
open={props.isOpen} open={props.isOpen}
onClose={props.onClose} onClose={props.onClose}
title="Edit Comment" title="Edit Comment"
class="bg-surface1 w-11/12 max-w-none sm:w-4/5 md:w-2/3" class="bg-crust w-11/12 max-w-none sm:w-4/5 md:w-2/3"
> >
<form onSubmit={editCommentWrapper}> <form onSubmit={editCommentWrapper}>
<div class="textarea-group home"> <div class="textarea-group home">

View File

@@ -31,20 +31,22 @@ export default function Modal(props: ModalProps) {
}; };
onMount(() => { onMount(() => {
if (props.open) { if (props.open && typeof document !== "undefined") {
document.addEventListener("keydown", handleEscapeKey); document.addEventListener("keydown", handleEscapeKey);
} }
}); });
onCleanup(() => { onCleanup(() => {
if (typeof document !== "undefined") {
document.removeEventListener("keydown", handleEscapeKey); document.removeEventListener("keydown", handleEscapeKey);
}
}); });
return ( return (
<Show when={props.open}> <Show when={props.open}>
<Portal> <Portal>
<div <div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" class="fixed inset-0 z-500 flex items-center justify-center bg-black/50"
onClick={handleBackdropClick} onClick={handleBackdropClick}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"