fix: mention notifications and scroll-to-comment from notifications

This commit is contained in:
CREDO23 2026-01-19 19:56:21 +02:00
parent 7b170dfa75
commit 4d004cc1b2
6 changed files with 100 additions and 179 deletions

View file

@ -301,6 +301,7 @@ async def create_comment(
# Create notifications for mentioned users (excluding author)
thread = message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():
if mentioned_user_id == user.id:
continue # Don't notify yourself
@ -314,7 +315,7 @@ async def create_comment(
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
content_preview=content[:200],
content_preview=content_preview[:200],
search_space_id=search_space_id,
)
@ -411,6 +412,7 @@ async def create_reply(
# Create notifications for mentioned users (excluding author)
thread = parent_comment.message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():
if mentioned_user_id == user.id:
continue # Don't notify yourself
@ -424,7 +426,7 @@ async def create_reply(
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
content_preview=content[:200],
content_preview=content_preview[:200],
search_space_id=search_space_id,
)
@ -526,13 +528,16 @@ async def update_comment(
)
)
# Add new mentions (existing ones keep their read status)
# Add new mentions and collect their IDs for notifications
new_mentions_map: dict[UUID, int] = {}
for user_id in mentions_to_add:
mention = ChatCommentMention(
comment_id=comment_id,
mentioned_user_id=user_id,
)
session.add(mention)
await session.flush()
new_mentions_map[user_id] = mention.id
comment.content = content
@ -542,6 +547,28 @@ async def update_comment(
# Fetch user names for rendering mentions
user_names = await get_user_names_for_mentions(session, valid_new_mentions)
# Create notifications for newly added mentions (excluding author)
if new_mentions_map:
thread = comment.message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in new_mentions_map.items():
if mentioned_user_id == user.id:
continue # Don't notify yourself
await NotificationService.mention.notify_new_mention(
session=session,
mentioned_user_id=mentioned_user_id,
mention_id=mention_id,
comment_id=comment_id,
message_id=comment.message_id,
thread_id=thread.id,
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
content_preview=content_preview[:200],
search_space_id=search_space_id,
)
author = AuthorResponse(
id=user.id,
display_name=user.display_name,

View file

@ -8,7 +8,7 @@ import {
} from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
@ -367,6 +367,38 @@ export default function NewChatPage() {
initializeThread();
}, [initializeThread]);
// Handle scroll to comment from URL query params (e.g., from notification click)
const searchParams = useSearchParams();
const targetCommentId = searchParams.get("commentId");
useEffect(() => {
if (!targetCommentId || isInitializing || messages.length === 0) return;
const tryScroll = () => {
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
}
return false;
};
// Try immediately
if (tryScroll()) return;
// Retry every 200ms for up to 10 seconds
const intervalId = setInterval(() => {
if (tryScroll()) clearInterval(intervalId);
}, 200);
const timeoutId = setTimeout(() => clearInterval(intervalId), 10000);
return () => {
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [targetCommentId, isInitializing, messages.length]);
// Sync current thread state to atom
useEffect(() => {
setCurrentThreadState({

View file

@ -68,7 +68,7 @@ function formatTimestamp(dateString: string): string {
);
}
function convertRenderedToDisplay(contentRendered: string): string {
export function convertRenderedToDisplay(contentRendered: string): string {
// Convert @{DisplayName} format to @DisplayName for editing
return contentRendered.replace(/@\{([^}]+)\}/g, "@$1");
}
@ -128,7 +128,7 @@ export function CommentItem({
};
return (
<div className={cn("group flex gap-3")}>
<div className={cn("group flex gap-3")} data-comment-id={comment.id}>
<Avatar className="size-8 shrink-0">
{comment.author?.avatarUrl && (
<AvatarImage src={comment.author.avatarUrl} alt={displayName} />

View file

@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -12,6 +13,7 @@ import { cn } from "@/lib/utils";
import { useParams } from "next/navigation";
export function NotificationButton() {
const [open, setOpen] = useState(false);
const { data: user } = useAtomValue(currentUserAtom);
const params = useParams();
@ -25,7 +27,7 @@ export function NotificationButton() {
);
return (
<Popover>
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
@ -54,6 +56,7 @@ export function NotificationButton() {
loading={loading}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
onClose={() => setOpen(false)}
/>
</PopoverContent>
</Popover>

View file

@ -1,12 +1,14 @@
"use client";
import { Bell, CheckCheck, Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import type { Notification } from "@/hooks/use-notifications";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
interface NotificationPopupProps {
notifications: Notification[];
@ -14,6 +16,7 @@ interface NotificationPopupProps {
loading: boolean;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
onClose?: () => void;
}
export function NotificationPopup({
@ -22,15 +25,38 @@ export function NotificationPopup({
loading,
markAsRead,
markAllAsRead,
onClose,
}: NotificationPopupProps) {
const handleMarkAsRead = async (id: number) => {
await markAsRead(id);
};
const router = useRouter();
const handleMarkAllAsRead = async () => {
await markAllAsRead();
};
const handleNotificationClick = async (notification: Notification) => {
if (!notification.read) {
await markAsRead(notification.id);
}
if (notification.type === "new_mention") {
const metadata = notification.metadata as {
thread_id?: number;
comment_id?: number;
};
const searchSpaceId = notification.search_space_id;
const threadId = metadata?.thread_id;
const commentId = metadata?.comment_id;
if (searchSpaceId && threadId) {
const url = commentId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
onClose?.();
router.push(url);
}
}
};
const formatTime = (dateString: string) => {
try {
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
@ -86,7 +112,7 @@ export function NotificationPopup({
<div key={notification.id}>
<button
type="button"
onClick={() => !notification.read && handleMarkAsRead(notification.id)}
onClick={() => handleNotificationClick(notification)}
className={cn(
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
!notification.read && "bg-accent/50"
@ -106,7 +132,7 @@ export function NotificationPopup({
</p>
</div>
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
{notification.message}
{convertRenderedToDisplay(notification.message)}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-muted-foreground">

View file

@ -1,167 +0,0 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useElectricClient } from "@/lib/electric/context";
import type { SyncHandle } from "@/lib/electric/client";
export interface ElectricMention {
id: number;
comment_id: number;
mentioned_user_id: string;
created_at: string;
}
/**
* Hook for syncing mentions with Electric SQL for real-time updates.
* Syncs all mentions for the current user.
* @param userId - The user ID to sync mentions for
*/
export function useMentionsElectric(userId: string | null) {
const electricClient = useElectricClient();
const [mentions, setMentions] = useState<ElectricMention[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
const syncKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!electricClient) {
setLoading(false);
setError(new Error("Electric SQL not configured"));
return;
}
if (!userId) {
setMentions([]);
setLoading(false);
return;
}
const syncKey = `mentions_${userId}`;
if (syncKeyRef.current === syncKey) {
return;
}
let mounted = true;
syncKeyRef.current = syncKey;
const client = electricClient;
async function startSync() {
try {
const handle = await client.syncShape({
table: "chat_comment_mentions",
where: `mentioned_user_id = '${userId}'`,
primaryKey: ["id"],
});
if (!handle.isUpToDate && handle.initialSyncPromise) {
try {
await Promise.race([
handle.initialSyncPromise,
new Promise((resolve) => setTimeout(resolve, 2000)),
]);
} catch (syncErr) {
console.error("[useMentionsElectric] Initial sync failed:", syncErr);
}
}
if (!mounted) {
handle.unsubscribe();
return;
}
syncHandleRef.current = handle;
setLoading(false);
setError(null);
await fetchMentions();
await setupLiveQuery();
} catch (err) {
if (!mounted) return;
console.error("[useMentionsElectric] Failed to start sync:", err);
setError(err instanceof Error ? err : new Error("Failed to sync mentions"));
setLoading(false);
}
}
async function fetchMentions() {
try {
const result = await client.db.query<ElectricMention>(
`SELECT id, comment_id, mentioned_user_id, created_at
FROM chat_comment_mentions
WHERE mentioned_user_id = $1
ORDER BY created_at DESC`,
[userId]
);
if (mounted) {
setMentions(result.rows || []);
}
} catch (err) {
console.error("[useMentionsElectric] Failed to fetch:", err);
}
}
async function setupLiveQuery() {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = client.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(
`SELECT id, comment_id, mentioned_user_id, created_at
FROM chat_comment_mentions
WHERE mentioned_user_id = $1
ORDER BY created_at DESC`,
[userId]
);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
if (liveQuery.initialResults?.rows) {
setMentions(liveQuery.initialResults.rows);
} else if (liveQuery.rows) {
setMentions(liveQuery.rows);
}
if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: ElectricMention[] }) => {
if (mounted && result.rows) {
setMentions(result.rows);
}
});
}
if (typeof liveQuery.unsubscribe === "function") {
liveQueryRef.current = liveQuery;
}
}
} catch (liveErr) {
console.error("[useMentionsElectric] Failed to set up live query:", liveErr);
}
}
startSync();
return () => {
mounted = false;
syncKeyRef.current = null;
if (syncHandleRef.current) {
syncHandleRef.current.unsubscribe();
syncHandleRef.current = null;
}
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
};
}, [userId, electricClient]);
return { mentions, loading, error };
}