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:
@@ -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 && (
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user