diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index 24fa2ec1d..fa26bf6d5 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -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, diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index c41dd872c..43c33ba5a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -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({ diff --git a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx index 362a6079e..8489bdb4a 100644 --- a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx +++ b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx @@ -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 ( -
+
{comment.author?.avatarUrl && ( diff --git a/surfsense_web/components/notifications/NotificationButton.tsx b/surfsense_web/components/notifications/NotificationButton.tsx index 26b4713b5..e9f5db2dc 100644 --- a/surfsense_web/components/notifications/NotificationButton.tsx +++ b/surfsense_web/components/notifications/NotificationButton.tsx @@ -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 ( - + @@ -54,6 +56,7 @@ export function NotificationButton() { loading={loading} markAsRead={markAsRead} markAllAsRead={markAllAsRead} + onClose={() => setOpen(false)} /> diff --git a/surfsense_web/components/notifications/NotificationPopup.tsx b/surfsense_web/components/notifications/NotificationPopup.tsx index 74e2f1e31..9196ceaa4 100644 --- a/surfsense_web/components/notifications/NotificationPopup.tsx +++ b/surfsense_web/components/notifications/NotificationPopup.tsx @@ -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; markAllAsRead: () => Promise; + 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({

- {notification.message} + {convertRenderedToDisplay(notification.message)}

diff --git a/surfsense_web/hooks/use-mentions-electric.ts b/surfsense_web/hooks/use-mentions-electric.ts deleted file mode 100644 index d56891018..000000000 --- a/surfsense_web/hooks/use-mentions-electric.ts +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const syncHandleRef = useRef(null); - const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); - const syncKeyRef = useRef(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( - `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 }; -}