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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
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 { requireSession } from "@/lib/api-auth";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { dispatchWebhook } from "@/lib/webhooks";
|
import { dispatchWebhook } from "@/lib/webhooks";
|
||||||
import { sendPushNotification } from "@/lib/push";
|
import { sendPushNotification } from "@/lib/push";
|
||||||
import { createNotification } from "@/lib/notifications";
|
import { createNotification } from "@/lib/notifications";
|
||||||
import { isBlockedEitherWay } from "@/lib/blocks";
|
import { isBlockedEitherWay } from "@/lib/blocks";
|
||||||
|
import { extractMentionedUsernames } from "@/lib/mentions";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
content: z.string().min(1).max(5000),
|
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 });
|
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 });
|
return NextResponse.json({ id: commentId }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { MessageCircle, Reply, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -24,6 +25,25 @@ type Comment = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MAX_VISUAL_INDENT = 4;
|
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) {
|
function timeAgo(dateStr: string) {
|
||||||
const diff = Date.now() - new Date(dateStr).getTime();
|
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="font-medium text-sm">{comment.userName}</span>
|
||||||
<span className="text-xs text-muted-foreground">{timeAgo(comment.createdAt)}</span>
|
<span className="text-xs text-muted-foreground">{timeAgo(comment.createdAt)}</span>
|
||||||
</div>
|
</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={[]} />
|
<CommentReactions recipeId={recipeId} commentId={comment.id} initialCounts={{}} initialUserReactions={[]} />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{currentUserId && (
|
{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",
|
"comment": "{name} commented on your recipe",
|
||||||
"reply": "{name} replied to your comment",
|
"reply": "{name} replied to your comment",
|
||||||
"reaction": "{name} reacted 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": {
|
"recipe": {
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
"comment": "{name} a commenté votre recette",
|
"comment": "{name} a commenté votre recette",
|
||||||
"reply": "{name} a répondu à votre commentaire",
|
"reply": "{name} a répondu à votre commentaire",
|
||||||
"reaction": "{name} a réagi à 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": {
|
"recipe": {
|
||||||
"share": "Partager",
|
"share": "Partager",
|
||||||
|
|||||||
Reference in New Issue
Block a user