Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 221 additions & 20 deletions src/components/CommentSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { parseMentions, notifyMention } from "@/lib/notifications";
import RelativeTime from "@/components/ui/RelativeTime";
import { ALLOWED_EMOJIS, type AllowedEmoji } from "@/lib/commentStore";

interface Comment {
id: string;
Expand All @@ -12,12 +13,21 @@ interface Comment {
timestamp: number;
}

/** Per-comment reaction state stored in localStorage. */
interface ReactionState {
/** Aggregate counts: emoji β†’ count */
counts: Record<string, number>;
/** Emojis this user has actively reacted with */
myReactions: AllowedEmoji[];
}

interface Props {
invoiceId: string;
walletAddress: string;
}

const STORAGE_KEY = "stellarsplit_comments";
const REACTIONS_STORAGE_KEY = "stellarsplit_reactions";

function loadComments(invoiceId: string, walletAddress: string): Comment[] {
if (typeof window === "undefined") return [];
Expand Down Expand Up @@ -48,6 +58,19 @@ function deleteComment(id: string) {
);
}

function loadReactionsMap(): Record<string, ReactionState> {
if (typeof window === "undefined") return {};
try {
return JSON.parse(localStorage.getItem(REACTIONS_STORAGE_KEY) ?? "{}");
} catch {
return {};
}
}

function saveReactionsMap(map: Record<string, ReactionState>) {
localStorage.setItem(REACTIONS_STORAGE_KEY, JSON.stringify(map));
}

/** Stellar address pattern β€” must match parseMentions regex. */
const MENTION_SPLIT_RE = /(\bG[A-Z0-9]{55}\b)/g;
const MENTION_TEST_RE = /^G[A-Z0-9]{55}$/;
Expand All @@ -73,18 +96,82 @@ export function renderCommentText(text: string): React.ReactNode[] {
);
}

/** Compact emoji picker that appears when the "+" button is clicked. */
function EmojiPicker({
commentId,
myReactions,
onToggle,
onClose,
}: {
commentId: string;
myReactions: AllowedEmoji[];
onToggle: (emoji: AllowedEmoji) => void;
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);

// Close when clicking outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);

return (
<div
ref={ref}
role="dialog"
aria-label="Pick a reaction"
className="absolute z-50 bottom-full mb-1 left-0 flex gap-1 bg-gray-800 border border-gray-700 rounded-xl px-2 py-1.5 shadow-lg"
>
{ALLOWED_EMOJIS.map((emoji) => {
const isActive = myReactions.includes(emoji);
return (
<button
key={emoji}
type="button"
aria-pressed={isActive}
aria-label={`React with ${emoji}`}
onClick={() => {
onToggle(emoji);
onClose();
}}
className={`text-lg rounded-lg w-9 h-9 flex items-center justify-center transition-colors ${
isActive
? "bg-indigo-600/40 ring-1 ring-indigo-500"
: "hover:bg-gray-700"
}`}
>
{emoji}
</button>
);
})}
</div>
);
}

/**
* CommentSection β€” off-chain per-invoice notes stored in localStorage.
* Only shows comments belonging to the connected wallet address.
* Supports @G... mention chips and fires browser notifications to mentioned addresses.
* Supports @G... mention chips, browser notifications, and emoji reactions.
*
* Reactions are persisted locally and also synced to the server via
* POST /api/invoices/[id]/comments/[commentId]/reactions when available.
*/
export default function CommentSection({ invoiceId, walletAddress }: Props) {
const [comments, setComments] = useState<Comment[]>([]);
const [text, setText] = useState("");
const [reactionsMap, setReactionsMap] = useState<Record<string, ReactionState>>({});
const [openPickerFor, setOpenPickerFor] = useState<string | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);

useEffect(() => {
setComments(loadComments(invoiceId, walletAddress));
setReactionsMap(loadReactionsMap());
}, [invoiceId, walletAddress]);

const handleSubmit = (e: React.FormEvent) => {
Expand Down Expand Up @@ -115,6 +202,58 @@ export default function CommentSection({ invoiceId, walletAddress }: Props) {
setComments((prev) => prev.filter((c) => c.id !== id));
};

/**
* Toggle an emoji reaction for a comment.
* Updates local state + localStorage, and attempts to sync to the server.
*/
const handleToggleReaction = async (commentId: string, emoji: AllowedEmoji) => {
const current = reactionsMap[commentId] ?? { counts: {}, myReactions: [] };
const isActive = current.myReactions.includes(emoji);

// Optimistic local update
const newMyReactions: AllowedEmoji[] = isActive
? current.myReactions.filter((e) => e !== emoji)
: [...current.myReactions, emoji];

const newCounts = { ...current.counts };
const prevCount = newCounts[emoji] ?? 0;
newCounts[emoji] = isActive ? Math.max(0, prevCount - 1) : prevCount + 1;

const updated: ReactionState = { counts: newCounts, myReactions: newMyReactions };
const newMap = { ...reactionsMap, [commentId]: updated };

setReactionsMap(newMap);
saveReactionsMap(newMap);

// Best-effort server sync (fire-and-forget; server is source of truth for
// multi-user scenarios but localStorage keeps single-user UX snappy).
try {
const res = await fetch(
`/api/invoices/${invoiceId}/comments/${commentId}/reactions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emoji, reactorId: walletAddress }),
}
);
if (res.ok) {
const data: { counts: Record<string, number>; active: boolean } = await res.json();
// Reconcile server counts
const reconciled: ReactionState = {
counts: data.counts as Record<string, number>,
myReactions: data.active
? [...new Set([...newMyReactions, emoji])]
: newMyReactions.filter((e) => e !== emoji),
};
const reconciledMap = { ...newMap, [commentId]: reconciled };
setReactionsMap(reconciledMap);
saveReactionsMap(reconciledMap);
}
} catch {
// Network unavailable β€” local state is already updated, which is fine.
}
};

return (
<section className="mb-8">
<h2 className="text-lg font-semibold mb-3">Notes</h2>
Expand All @@ -123,26 +262,88 @@ export default function CommentSection({ invoiceId, walletAddress }: Props) {
<p className="text-sm text-gray-400 mb-3">No notes yet.</p>
) : (
<ul className="flex flex-col gap-2 mb-4">
{comments.map((c) => (
<li
key={c.id}
className="flex items-start justify-between gap-3 bg-gray-900 rounded-lg px-4 py-3 text-sm"
>
<div className="flex-1 min-w-0">
<p className="text-gray-200 break-words">{renderCommentText(c.text)}</p>
<p className="text-xs text-gray-500 mt-1">
<RelativeTime iso={new Date(c.timestamp).toISOString()} />
</p>
</div>
<button
onClick={() => handleDelete(c.id)}
aria-label="Delete note"
className="flex-shrink-0 min-h-11 min-w-11 text-gray-600 hover:text-red-400 transition-colors text-xs focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
{comments.map((c) => {
const reactionState = reactionsMap[c.id] ?? { counts: {}, myReactions: [] };
const hasReactions = Object.values(reactionState.counts).some((n) => n > 0);

return (
<li
key={c.id}
className="flex items-start justify-between gap-3 bg-gray-900 rounded-lg px-4 py-3 text-sm"
>
βœ•
</button>
</li>
))}
<div className="flex-1 min-w-0">
<p className="text-gray-200 break-words">{renderCommentText(c.text)}</p>
<p className="text-xs text-gray-500 mt-1">
<RelativeTime iso={new Date(c.timestamp).toISOString()} />
</p>

{/* Reaction chips */}
{hasReactions && (
<div
className="flex items-center gap-1 flex-wrap mt-2"
role="group"
aria-label="Reactions"
>
{ALLOWED_EMOJIS.map((emoji) => {
const count = reactionState.counts[emoji] ?? 0;
if (count === 0) return null;
const isActive = reactionState.myReactions.includes(emoji);
return (
<button
key={emoji}
type="button"
aria-pressed={isActive}
aria-label={`${emoji} reaction (${count}). Click to ${isActive ? "remove" : "add"}`}
onClick={() => handleToggleReaction(c.id, emoji)}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium border transition-colors ${
isActive
? "bg-indigo-600/30 text-indigo-200 border-indigo-500"
: "bg-gray-800 text-gray-400 border-gray-700 hover:bg-gray-700"
}`}
>
<span aria-hidden="true">{emoji}</span>
<span>{count}</span>
</button>
);
})}
</div>
)}

{/* Add reaction row */}
<div className="relative mt-2 inline-block">
<button
type="button"
aria-label="Add reaction"
aria-haspopup="dialog"
aria-expanded={openPickerFor === c.id}
onClick={() =>
setOpenPickerFor((prev) => (prev === c.id ? null : c.id))
}
className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-gray-800 border border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-gray-200 transition-colors text-sm"
>
+
</button>
{openPickerFor === c.id && (
<EmojiPicker
commentId={c.id}
myReactions={reactionState.myReactions}
onToggle={(emoji) => handleToggleReaction(c.id, emoji)}
onClose={() => setOpenPickerFor(null)}
/>
)}
</div>
</div>

<button
onClick={() => handleDelete(c.id)}
aria-label="Delete note"
className="flex-shrink-0 min-h-11 min-w-11 text-gray-600 hover:text-red-400 transition-colors text-xs focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
>
βœ•
</button>
</li>
);
})}
</ul>
)}

Expand Down
Loading