From d7c317ab02e8edf64d7e5e311bab97d104e19320 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 20 Apr 2026 03:01:46 +0100 Subject: [PATCH 1/6] feat: implement reply targeting in forums with inline reply functionality --- .../src/pages/public/travel-advice/Forums.tsx | 53 ++++- .../travel-advice/forums/ThreadDetail.tsx | 218 +++++++++++------- 2 files changed, 189 insertions(+), 82 deletions(-) diff --git a/frontend/src/pages/public/travel-advice/Forums.tsx b/frontend/src/pages/public/travel-advice/Forums.tsx index e2bec56..61826fc 100644 --- a/frontend/src/pages/public/travel-advice/Forums.tsx +++ b/frontend/src/pages/public/travel-advice/Forums.tsx @@ -37,8 +37,13 @@ const Forums = () => { const [repliesTotalPages, setRepliesTotalPages] = useState(1); const [repliesLoading, setRepliesLoading] = useState(false); const [replyContent, setReplyContent] = useState(""); + const [replyTarget, setReplyTarget] = useState<{ replyId: string; username: string } | null>(null); const [error, setError] = useState(null); + const stripLeadingMention = (value: string) => value.replace(/^@\S+\s+/, ""); + + const getMentionPrefix = (username: string) => `@${username} `; + const { t } = useTranslation(); const canPost = Boolean(token && profile); @@ -70,6 +75,7 @@ const Forums = () => { setSelectedThread(null); setReplies([]); setRepliesPage(1); + setReplyTarget(null); return; } @@ -159,6 +165,7 @@ const Forums = () => { setSelectedThread(null); setReplies([]); setRepliesPage(1); + setReplyTarget(null); navigate(localizePath("/travel-advice/forums", locale), { replace: true }); } } @@ -222,7 +229,10 @@ const Forums = () => { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ content: trimmedContent }), + body: JSON.stringify({ + content: trimmedContent, + parent_reply_id: replyTarget?.replyId ?? null, + }), } ); @@ -231,6 +241,7 @@ const Forums = () => { } setReplyContent(""); + setReplyTarget(null); setRepliesPage(1); await fetchReplies(selectedThread.id, 1); } catch (err) { @@ -344,6 +355,9 @@ const Forums = () => { } setReplies((previous) => previous.filter((reply) => reply.id !== replyId)); + setReplyTarget((previous) => + previous && previous.replyId === replyId ? null : previous, + ); await fetchReplies(selectedThread.id, repliesPage); } catch (err) { console.error("Error deleting reply:", err); @@ -351,6 +365,38 @@ const Forums = () => { } }; + const handleReplyToReply = (reply: Reply) => { + const mentionName = (reply.username ?? "Anonymous").trim() || "Anonymous"; + const mentionText = getMentionPrefix(mentionName); + + setReplyTarget({ replyId: reply.id, username: mentionName }); + setReplyContent((previous) => { + const withoutMention = stripLeadingMention(previous); + return `${mentionText}${withoutMention}`; + }); + }; + + const handleReplyContentChange = (value: string) => { + if (!replyTarget) { + setReplyContent(value); + return; + } + + const prefix = getMentionPrefix(replyTarget.username); + if (value.startsWith(prefix)) { + setReplyContent(value); + return; + } + + const withoutMention = stripLeadingMention(value); + setReplyContent(`${prefix}${withoutMention}`); + }; + + const handleReplyTargetClear = () => { + setReplyTarget(null); + setReplyContent((previous) => stripLeadingMention(previous)); + }; + return ( {selectedThread ? ( @@ -364,8 +410,11 @@ const Forums = () => { canPost={canPost} currentUserId={currentUserId} replyContent={replyContent} - onReplyContentChange={setReplyContent} + onReplyContentChange={handleReplyContentChange} onCreateReply={handleCreateReply} + replyTarget={replyTarget} + onReplyTargetClear={handleReplyTargetClear} + onReplyToReply={handleReplyToReply} onThreadUpdate={handleUpdateThread} onThreadDelete={handleDeleteThread} onReplyDelete={handleDeleteReply} diff --git a/frontend/src/pages/public/travel-advice/forums/ThreadDetail.tsx b/frontend/src/pages/public/travel-advice/forums/ThreadDetail.tsx index c175c3c..9850ccb 100644 --- a/frontend/src/pages/public/travel-advice/forums/ThreadDetail.tsx +++ b/frontend/src/pages/public/travel-advice/forums/ThreadDetail.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { Fragment, useState } from "react"; import type { FormEvent } from "react"; import { Link, useLocation } from "react-router-dom"; import { FaCheck, FaEdit, FaTimes, FaTrash } from "react-icons/fa"; @@ -20,6 +20,9 @@ type ThreadDetailProps = { replyContent: string; onReplyContentChange: (content: string) => void; onCreateReply: (event: FormEvent) => void; + replyTarget: { replyId: string; username: string } | null; + onReplyTargetClear: () => void; + onReplyToReply: (reply: Reply) => void; onThreadUpdate: ( threadId: string, nextTitle: string, @@ -42,6 +45,9 @@ const ThreadDetail = ({ replyContent, onReplyContentChange, onCreateReply, + replyTarget, + onReplyTargetClear, + onReplyToReply, onThreadUpdate, onThreadDelete, onReplyDelete, @@ -235,20 +241,30 @@ const ThreadDetail = ({ )} {canPost && ( -
-