mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
fix: mention notifications and scroll-to-comment from notifications
This commit is contained in:
parent
7b170dfa75
commit
4d004cc1b2
6 changed files with 100 additions and 179 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue