feat: @mentions in comments

Parse @username in comment content, notify mentioned users (dedup
against recipe-author/parent-author who are already notified via
comment/reply), and render @username as a link to their profile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-03 22:16:06 +02:00
parent 73a71d28cd
commit a51ba85253
5 changed files with 52 additions and 5 deletions
@@ -1,12 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, comments, users, userBlocks, eq, and } from "@epicure/db";
import { db, recipes, comments, users, userBlocks, eq, and, inArray } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { dispatchWebhook } from "@/lib/webhooks";
import { sendPushNotification } from "@/lib/push";
import { createNotification } from "@/lib/notifications";
import { isBlockedEitherWay } from "@/lib/blocks";
import { extractMentionedUsernames } from "@/lib/mentions";
const Schema = z.object({
content: z.string().min(1).max(5000),
@@ -107,5 +108,18 @@ export async function POST(req: NextRequest, { params }: Params) {
void createNotification({ userId: recipe.authorId, type: "comment", actorId: session!.user.id, recipeId: id, commentId });
}
const mentionedUsernames = extractMentionedUsernames(parsed.data.content);
if (mentionedUsernames.length > 0) {
const alreadyNotified = new Set([recipe.authorId, parent?.userId].filter(Boolean));
const mentionedUsers = await db
.select({ id: users.id, username: users.username })
.from(users)
.where(inArray(users.username, mentionedUsernames));
for (const mentioned of mentionedUsers) {
if (alreadyNotified.has(mentioned.id)) continue;
void createNotification({ userId: mentioned.id, type: "mention", actorId: session!.user.id, recipeId: id, commentId });
}
}
return NextResponse.json({ id: commentId }, { status: 201 });
}
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo, Fragment } from "react";
import Link from "next/link";
import { MessageCircle, Reply, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
@@ -24,6 +25,25 @@ type Comment = {
};
const MAX_VISUAL_INDENT = 4;
const MENTION_REGEX = /@([a-z0-9_-]{3,30})/gi;
function renderContentWithMentions(content: string) {
const parts = content.split(MENTION_REGEX);
// split() with a capturing group interleaves [text, username, text, username, ...text]
return parts.map((part, i) =>
i % 2 === 1 ? (
<Link
key={i}
href={`/u/${part.toLowerCase()}`}
className="text-primary font-medium hover:underline"
>
@{part}
</Link>
) : (
<Fragment key={i}>{part}</Fragment>
)
);
}
function timeAgo(dateStr: string) {
const diff = Date.now() - new Date(dateStr).getTime();
@@ -126,7 +146,7 @@ function CommentItem({
<span className="font-medium text-sm">{comment.userName}</span>
<span className="text-xs text-muted-foreground">{timeAgo(comment.createdAt)}</span>
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{comment.content}</p>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{renderContentWithMentions(comment.content)}</p>
<CommentReactions recipeId={recipeId} commentId={comment.id} initialCounts={{}} initialUserReactions={[]} />
<div className="flex items-center gap-2">
{currentUserId && (
+11
View File
@@ -0,0 +1,11 @@
const MENTION_REGEX = /@([a-z0-9_-]{3,30})/gi;
export function extractMentionedUsernames(content: string): string[] {
const matches = content.matchAll(MENTION_REGEX);
const usernames = new Set<string>();
for (const m of matches) {
const username = m[1];
if (username) usernames.add(username.toLowerCase());
}
return [...usernames];
}
+2 -1
View File
@@ -25,7 +25,8 @@
"comment": "{name} commented on your recipe",
"reply": "{name} replied to your comment",
"reaction": "{name} reacted to your comment",
"rating": "{name} rated your recipe"
"rating": "{name} rated your recipe",
"mention": "{name} mentioned you in a comment"
},
"recipe": {
"share": "Share",
+2 -1
View File
@@ -25,7 +25,8 @@
"comment": "{name} a commenté votre recette",
"reply": "{name} a répondu à votre commentaire",
"reaction": "{name} a réagi à votre commentaire",
"rating": "{name} a noté votre recette"
"rating": "{name} a noté votre recette",
"mention": "{name} vous a mentionné dans un commentaire"
},
"recipe": {
"share": "Partager",