diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py
index dc3b51238..c9ca920f6 100644
--- a/surfsense_backend/app/services/chat_comments_service.py
+++ b/surfsense_backend/app/services/chat_comments_service.py
@@ -5,7 +5,7 @@ Service layer for chat comments and mentions.
from uuid import UUID
from fastapi import HTTPException
-from sqlalchemy import delete, select
+from sqlalchemy import delete, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -103,6 +103,37 @@ async def process_mentions(
return mentions_map
+async def get_comment_thread_participants(
+ session: AsyncSession,
+ parent_comment_id: int,
+ exclude_user_ids: set[UUID],
+) -> list[UUID]:
+ """
+ Get all unique authors in a comment thread (parent + replies), excluding specified users.
+
+ Args:
+ session: Database session
+ parent_comment_id: ID of the parent comment
+ exclude_user_ids: Set of user IDs to exclude (e.g., replier, mentioned users)
+
+ Returns:
+ List of user UUIDs who have participated in the thread
+ """
+ query = select(ChatComment.author_id).where(
+ or_(
+ ChatComment.id == parent_comment_id,
+ ChatComment.parent_id == parent_comment_id,
+ ),
+ ChatComment.author_id.isnot(None),
+ )
+
+ if exclude_user_ids:
+ query = query.where(ChatComment.author_id.notin_(list(exclude_user_ids)))
+
+ result = await session.execute(query.distinct())
+ return [row[0] for row in result.fetchall()]
+
+
async def get_comments_for_message(
session: AsyncSession,
message_id: int,
@@ -436,6 +467,31 @@ async def create_reply(
search_space_id=search_space_id,
)
+ # Notify thread participants (excluding replier and mentioned users)
+ mentioned_user_ids = set(mentions_map.keys())
+ exclude_ids = {user.id} | mentioned_user_ids
+ participants = await get_comment_thread_participants(
+ session, comment_id, exclude_ids
+ )
+ for participant_id in participants:
+ if participant_id in mentioned_user_ids:
+ continue
+ await NotificationService.comment_reply.notify_comment_reply(
+ session=session,
+ user_id=participant_id,
+ reply_id=reply.id,
+ parent_comment_id=comment_id,
+ message_id=parent_comment.message_id,
+ thread_id=thread.id,
+ thread_title=thread.title or "Untitled thread",
+ author_id=str(user.id),
+ author_name=author_name,
+ author_avatar_url=user.avatar_url,
+ author_email=user.email,
+ 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_backend/app/services/notification_service.py b/surfsense_backend/app/services/notification_service.py
index 1788d05e1..a759f3536 100644
--- a/surfsense_backend/app/services/notification_service.py
+++ b/surfsense_backend/app/services/notification_service.py
@@ -861,6 +861,98 @@ class MentionNotificationHandler(BaseNotificationHandler):
raise
+class CommentReplyNotificationHandler(BaseNotificationHandler):
+ """Handler for comment reply notifications."""
+
+ def __init__(self):
+ super().__init__("comment_reply")
+
+ async def find_notification_by_reply(
+ self,
+ session: AsyncSession,
+ reply_id: int,
+ user_id: UUID,
+ ) -> Notification | None:
+ query = select(Notification).where(
+ Notification.type == self.notification_type,
+ Notification.user_id == user_id,
+ Notification.notification_metadata["reply_id"].astext == str(reply_id),
+ )
+ result = await session.execute(query)
+ return result.scalar_one_or_none()
+
+ async def notify_comment_reply(
+ self,
+ session: AsyncSession,
+ user_id: UUID,
+ reply_id: int,
+ parent_comment_id: int,
+ message_id: int,
+ thread_id: int,
+ thread_title: str,
+ author_id: str,
+ author_name: str,
+ author_avatar_url: str | None,
+ author_email: str,
+ content_preview: str,
+ search_space_id: int,
+ ) -> Notification:
+ existing = await self.find_notification_by_reply(session, reply_id, user_id)
+ if existing:
+ logger.info(
+ f"Notification already exists for reply {reply_id} to user {user_id}"
+ )
+ return existing
+
+ title = f"{author_name} replied in a thread"
+ message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
+
+ metadata = {
+ "reply_id": reply_id,
+ "parent_comment_id": parent_comment_id,
+ "message_id": message_id,
+ "thread_id": thread_id,
+ "thread_title": thread_title,
+ "author_id": author_id,
+ "author_name": author_name,
+ "author_avatar_url": author_avatar_url,
+ "author_email": author_email,
+ "content_preview": content_preview[:200],
+ }
+
+ try:
+ notification = Notification(
+ user_id=user_id,
+ search_space_id=search_space_id,
+ type=self.notification_type,
+ title=title,
+ message=message,
+ notification_metadata=metadata,
+ )
+ session.add(notification)
+ await session.commit()
+ await session.refresh(notification)
+ logger.info(
+ f"Created comment_reply notification {notification.id} for user {user_id}"
+ )
+ return notification
+ except Exception as e:
+ await session.rollback()
+ if (
+ "duplicate key" in str(e).lower()
+ or "unique constraint" in str(e).lower()
+ ):
+ logger.warning(
+ f"Duplicate notification for reply {reply_id} to user {user_id}"
+ )
+ existing = await self.find_notification_by_reply(
+ session, reply_id, user_id
+ )
+ if existing:
+ return existing
+ raise
+
+
class PageLimitNotificationHandler(BaseNotificationHandler):
"""Handler for page limit exceeded notifications."""
@@ -959,6 +1051,7 @@ class NotificationService:
connector_indexing = ConnectorIndexingNotificationHandler()
document_processing = DocumentProcessingNotificationHandler()
mention = MentionNotificationHandler()
+ comment_reply = CommentReplyNotificationHandler()
page_limit = PageLimitNotificationHandler()
@staticmethod
diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py
index 2125dd8ce..4da316240 100644
--- a/surfsense_backend/app/services/public_chat_service.py
+++ b/surfsense_backend/app/services/public_chat_service.py
@@ -366,11 +366,14 @@ async def list_snapshots_for_thread(
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
- if thread.created_by_id != user.id:
- raise HTTPException(
- status_code=403,
- detail="Only the creator can view snapshots",
- )
+ # Check permission to view public share links
+ await check_permission(
+ session,
+ user,
+ thread.search_space_id,
+ Permission.PUBLIC_SHARING_VIEW.value,
+ "You don't have permission to view public share links",
+ )
result = await session.execute(
select(PublicChatSnapshot)
diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx
index 4fd2446c3..5cdd287de 100644
--- a/surfsense_web/components/assistant-ui/assistant-message.tsx
+++ b/surfsense_web/components/assistant-ui/assistant-message.tsx
@@ -4,20 +4,19 @@ import {
ErrorPrimitive,
MessagePrimitive,
useAssistantState,
+ useMessage,
} from "@assistant-ui/react";
-import { useAtom, useAtomValue, useSetAtom } from "jotai";
+import { useAtom, useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
import type { FC } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import {
addingCommentToMessageIdAtom,
- clearTargetCommentIdAtom,
commentsCollapsedAtom,
commentsEnabledAtom,
targetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
-import { BranchPicker } from "@/components/assistant-ui/branch-picker";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import {
ThinkingStepsContext,
@@ -84,7 +83,6 @@ const AssistantMessageInner: FC = () => {
>
@@ -126,7 +124,6 @@ export const AssistantMessage: FC = () => {
// Target comment navigation - read target from global atom
const targetCommentId = useAtomValue(targetCommentIdAtom);
- const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
// Check if target comment belongs to this message (including replies)
const hasTargetComment = useMemo(() => {
@@ -263,6 +260,8 @@ export const AssistantMessage: FC = () => {
};
const AssistantActionBar: FC = () => {
+ const { isLast } = useMessage();
+
return (
{
-
-
-
-
-
+ {/* Only allow regenerating the last assistant message */}
+ {isLast && (
+
+
+
+
+
+ )}
);
};
diff --git a/surfsense_web/components/assistant-ui/branch-picker.tsx b/surfsense_web/components/assistant-ui/branch-picker.tsx
deleted file mode 100644
index ee4addd2a..000000000
--- a/surfsense_web/components/assistant-ui/branch-picker.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { BranchPickerPrimitive } from "@assistant-ui/react";
-import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
-import type { FC } from "react";
-import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
-import { cn } from "@/lib/utils";
-
-export const BranchPicker: FC = ({ className, ...rest }) => {
- return (
-
-
-
-
-
-
-
- /
-
-
-
-
-
-
-
- );
-};
diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx
index 896b8c748..1ae8aef3c 100644
--- a/surfsense_web/components/assistant-ui/user-message.tsx
+++ b/surfsense_web/components/assistant-ui/user-message.tsx
@@ -4,7 +4,6 @@ import { FileText, PencilIcon } from "lucide-react";
import { type FC, useState } from "react";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
-import { BranchPicker } from "@/components/assistant-ui/branch-picker";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
interface AuthorMetadata {
@@ -95,24 +94,47 @@ export const UserMessage: FC = () => {
)}
-
-
);
};
const UserActionBar: FC = () => {
+ const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
+
+ // Get current message ID
+ const currentMessageId = useAssistantState(({ message }) => message?.id);
+
+ // Find the last user message ID in the thread (computed once, memoized by selector)
+ const lastUserMessageId = useAssistantState(({ thread }) => {
+ const messages = thread.messages;
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].role === "user") {
+ return messages[i].id;
+ }
+ }
+ return null;
+ });
+
+ // Simple comparison - no iteration needed per message
+ const isLastUserMessage = currentMessageId === lastUserMessageId;
+
+ // Show edit button only on the last user message and when thread is not running
+ const canEdit = isLastUserMessage && !isThreadRunning;
+
return (
-
-
-
-
-
+ {/* Only allow editing the last user message */}
+ {canEdit && (
+
+
+
+
+
+ )}
);
};
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 4d8e2d23a..c1a9c18c3 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -110,7 +110,6 @@ export function LayoutDataProvider({
// This ensures each tab has independent pagination and data loading
const userId = user?.id ? String(user.id) : null;
- // Mentions: Only fetch "new_mention" type notifications
const {
inboxItems: mentionItems,
unreadCount: mentionUnreadCount,
@@ -122,11 +121,9 @@ export function LayoutDataProvider({
markAllAsRead: markAllMentionsAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
- // Status: Fetch all types (will be filtered client-side to status types)
- // We pass null to get all, then InboxSidebar filters to status types
const {
inboxItems: statusItems,
- unreadCount: statusUnreadCount,
+ unreadCount: allUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
@@ -135,8 +132,8 @@ export function LayoutDataProvider({
markAllAsRead: markAllStatusAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, null);
- // Combined unread count for nav badge (mentions take priority for visibility)
- const totalUnreadCount = mentionUnreadCount + statusUnreadCount;
+ const totalUnreadCount = allUnreadCount;
+ const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount);
// Track seen notification IDs to detect new page_limit_exceeded notifications
const seenPageLimitNotifications = useRef>(new Set());
@@ -598,7 +595,7 @@ export function LayoutDataProvider({
},
status: {
items: statusItems,
- unreadCount: statusUnreadCount,
+ unreadCount: statusOnlyUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index 9ef49c0d8..f313dd6f9 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -4,7 +4,6 @@ import { useAtom } from "jotai";
import {
AlertCircle,
AlertTriangle,
- AtSign,
BellDot,
Check,
CheckCheck,
@@ -15,6 +14,7 @@ import {
Inbox,
LayoutGrid,
ListFilter,
+ MessageSquare,
Search,
X,
} from "lucide-react";
@@ -46,6 +46,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
+ isCommentReplyMetadata,
isConnectorIndexingMetadata,
isNewMentionMetadata,
isPageLimitExceededMetadata,
@@ -133,7 +134,7 @@ function getConnectorTypeDisplayName(connectorType: string): string {
);
}
-type InboxTab = "mentions" | "status";
+type InboxTab = "comments" | "status";
type InboxFilter = "all" | "unread";
// Tab-specific data source with independent pagination
@@ -186,7 +187,7 @@ export function InboxSidebar({
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [searchQuery, setSearchQuery] = useState("");
- const [activeTab, setActiveTab] = useState("mentions");
+ const [activeTab, setActiveTab] = useState("comments");
const [activeFilter, setActiveFilter] = useState("all");
const [selectedConnector, setSelectedConnector] = useState(null);
const [mounted, setMounted] = useState(false);
@@ -233,12 +234,17 @@ export function InboxSidebar({
}
}, [activeTab]);
- // Get current tab's data source - each tab has independent data and pagination
- const currentDataSource = activeTab === "mentions" ? mentions : status;
- const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
+ // Both tabs now derive items from status (all types), so use status for pagination
+ const { loading, loadingMore = false, hasMore = false, loadMore } = status;
- // Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion
- // Filter to only show status notification types
+ // Comments tab: mentions and comment replies
+ const commentsItems = useMemo(
+ () =>
+ status.items.filter((item) => item.type === "new_mention" || item.type === "comment_reply"),
+ [status.items]
+ );
+
+ // Status tab: connector indexing, document processing, page limit exceeded, connector deletion
const statusItems = useMemo(
() =>
status.items.filter(
@@ -270,8 +276,8 @@ export function InboxSidebar({
}));
}, [statusItems]);
- // Get items for current tab - mentions use their source directly, status uses filtered items
- const displayItems = activeTab === "mentions" ? mentions.items : statusItems;
+ // Get items for current tab
+ const displayItems = activeTab === "comments" ? commentsItems : statusItems;
// Filter items based on filter type, connector filter, and search query
const filteredItems = useMemo(() => {
@@ -334,9 +340,15 @@ export function InboxSidebar({
return () => observer.disconnect();
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
- // Use unread counts from data sources (more accurate than client-side counting)
- const unreadMentionsCount = mentions.unreadCount;
- const unreadStatusCount = status.unreadCount;
+ // Unread counts derived from filtered items
+ const unreadCommentsCount = useMemo(
+ () => commentsItems.filter((item) => !item.read).length,
+ [commentsItems]
+ );
+ const unreadStatusCount = useMemo(
+ () => statusItems.filter((item) => !item.read).length,
+ [statusItems]
+ );
const handleItemClick = useCallback(
async (item: InboxItem) => {
@@ -347,19 +359,15 @@ export function InboxSidebar({
}
if (item.type === "new_mention") {
- // Use type guard for safe metadata access
if (isNewMentionMetadata(item.metadata)) {
const searchSpaceId = item.search_space_id;
const threadId = item.metadata.thread_id;
const commentId = item.metadata.comment_id;
if (searchSpaceId && threadId) {
- // Pre-set target comment ID before navigation
- // This also ensures comments panel is not collapsed
if (commentId) {
setTargetCommentId(commentId);
}
-
const url = commentId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
@@ -368,6 +376,24 @@ export function InboxSidebar({
router.push(url);
}
}
+ } else if (item.type === "comment_reply") {
+ if (isCommentReplyMetadata(item.metadata)) {
+ const searchSpaceId = item.search_space_id;
+ const threadId = item.metadata.thread_id;
+ const replyId = item.metadata.reply_id;
+
+ if (searchSpaceId && threadId) {
+ if (replyId) {
+ setTargetCommentId(replyId);
+ }
+ const url = replyId
+ ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${replyId}`
+ : `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
+ onOpenChange(false);
+ onCloseMobileSidebar?.();
+ router.push(url);
+ }
+ }
} else if (item.type === "page_limit_exceeded") {
// Navigate to the upgrade/more-pages page
if (isPageLimitExceededMetadata(item.metadata)) {
@@ -411,24 +437,29 @@ export function InboxSidebar({
};
const getStatusIcon = (item: InboxItem) => {
- // For mentions, show the author's avatar with initials fallback
- if (item.type === "new_mention") {
- // Use type guard for safe metadata access
- if (isNewMentionMetadata(item.metadata)) {
- const authorName = item.metadata.author_name;
- const avatarUrl = item.metadata.author_avatar_url;
- const authorEmail = item.metadata.author_email;
+ // For mentions and comment replies, show the author's avatar
+ if (item.type === "new_mention" || item.type === "comment_reply") {
+ const metadata =
+ item.type === "new_mention"
+ ? isNewMentionMetadata(item.metadata)
+ ? item.metadata
+ : null
+ : isCommentReplyMetadata(item.metadata)
+ ? item.metadata
+ : null;
+ if (metadata) {
return (
- {avatarUrl && }
+ {metadata.author_avatar_url && (
+
+ )}
- {getInitials(authorName, authorEmail)}
+ {getInitials(metadata.author_name, metadata.author_email)}
);
}
- // Fallback for invalid metadata
return (
@@ -481,10 +512,10 @@ export function InboxSidebar({
};
const getEmptyStateMessage = () => {
- if (activeTab === "mentions") {
+ if (activeTab === "comments") {
return {
- title: t("no_mentions") || "No mentions",
- hint: t("no_mentions_hint") || "You'll see mentions from others here",
+ title: t("no_comments") || "No comments",
+ hint: t("no_comments_hint") || "You'll see mentions and replies here",
};
}
return {
@@ -823,14 +854,14 @@ export function InboxSidebar({
>
-
- {t("mentions") || "Mentions"}
+
+ {t("comments") || "Comments"}
- {formatInboxCount(unreadMentionsCount)}
+ {formatInboxCount(unreadCommentsCount)}
@@ -932,8 +963,8 @@ export function InboxSidebar({
) : (
- {activeTab === "mentions" ? (
-
+ {activeTab === "comments" ? (
+
) : (
)}
diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx
index fa05f44c1..2e04fa3ba 100644
--- a/surfsense_web/components/new-chat/chat-share-button.tsx
+++ b/surfsense_web/components/new-chat/chat-share-button.tsx
@@ -1,8 +1,9 @@
"use client";
-import { useQueryClient } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { Globe, User, Users } from "lucide-react";
+import { useParams, useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
@@ -11,6 +12,7 @@ import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapsh
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
import {
type ChatVisibility,
type ThreadRecord,
@@ -46,6 +48,8 @@ const visibilityOptions: {
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
const queryClient = useQueryClient();
+ const router = useRouter();
+ const params = useParams();
const [open, setOpen] = useState(false);
// Use Jotai atom for visibility (single source of truth)
@@ -65,6 +69,16 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
return access.permissions?.includes("public_sharing:create") ?? false;
}, [access]);
+ // Query to check if thread has public snapshots
+ const { data: snapshotsData } = useQuery({
+ queryKey: ["thread-snapshots", thread?.id],
+ queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }),
+ enabled: !!thread?.id,
+ staleTime: 30000, // Cache for 30 seconds
+ });
+ const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
+ const snapshotCount = snapshotsData?.snapshots?.length ?? 0;
+
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
@@ -106,11 +120,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
try {
await createSnapshot({ thread_id: thread.id });
+ // Refetch snapshots to show the globe indicator
+ await queryClient.invalidateQueries({ queryKey: ["thread-snapshots", thread.id] });
setOpen(false);
} catch (error) {
console.error("Failed to create public link:", error);
}
- }, [thread, createSnapshot]);
+ }, [thread, createSnapshot, queryClient]);
// Don't show if no thread (new chat that hasn't been created yet)
if (!thread) {
@@ -121,112 +137,131 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared";
return (
-
-
-
-
-
-
-
- Share settings
-
-
- e.preventDefault()}
- >
-
- {/* Visibility Options */}
- {visibilityOptions.map((option) => {
- const isSelected = currentVisibility === option.value;
- const Icon = option.icon;
-
- return (
-
);
}
diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx
index 696d32466..5f0048100 100644
--- a/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx
+++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx
@@ -38,6 +38,13 @@ export function PublicChatSnapshotRow({
{snapshot.message_count}
+ (e.target as HTMLInputElement).select()}
+ />
{
- switch (position) {
- case "top-left":
- return { cx: "0", cy: "0" };
- case "top-right":
- return { cx: "40", cy: "0" };
- case "bottom-left":
- return { cx: "0", cy: "40" };
- case "bottom-right":
- return { cx: "40", cy: "40" };
- case "top-center":
- return { cx: "20", cy: "0" };
- case "bottom-center":
- return { cx: "20", cy: "40" };
- case "bottom-up":
- case "top-down":
- case "left-right":
- case "right-left":
- return { cx: "20", cy: "20" };
- }
+ switch (position) {
+ case "top-left":
+ return { cx: "0", cy: "0" };
+ case "top-right":
+ return { cx: "40", cy: "0" };
+ case "bottom-left":
+ return { cx: "0", cy: "40" };
+ case "bottom-right":
+ return { cx: "40", cy: "40" };
+ case "top-center":
+ return { cx: "20", cy: "0" };
+ case "bottom-center":
+ return { cx: "20", cy: "40" };
+ case "bottom-up":
+ case "top-down":
+ case "left-right":
+ case "right-left":
+ return { cx: "20", cy: "20" };
+ }
};
const generateSVG = (variant: AnimationVariant, start: AnimationStart) => {
- if (variant === "circle-blur") {
- if (start === "center") {
- return `data:image/svg+xml,`;
- }
- const positionCoords = getPositionCoords(start);
- if (!positionCoords) {
- throw new Error(`Invalid start position: ${start}`);
- }
- const { cx, cy } = positionCoords;
- return `data:image/svg+xml,`;
- }
+ if (variant === "circle-blur") {
+ if (start === "center") {
+ return `data:image/svg+xml,`;
+ }
+ const positionCoords = getPositionCoords(start);
+ if (!positionCoords) {
+ throw new Error(`Invalid start position: ${start}`);
+ }
+ const { cx, cy } = positionCoords;
+ return `data:image/svg+xml,`;
+ }
- if (start === "center") return;
+ if (start === "center") return;
- if (variant === "rectangle") return "";
+ if (variant === "rectangle") return "";
- const positionCoords = getPositionCoords(start);
- if (!positionCoords) {
- throw new Error(`Invalid start position: ${start}`);
- }
- const { cx, cy } = positionCoords;
+ const positionCoords = getPositionCoords(start);
+ if (!positionCoords) {
+ throw new Error(`Invalid start position: ${start}`);
+ }
+ const { cx, cy } = positionCoords;
- if (variant === "circle") {
- return `data:image/svg+xml,`;
- }
+ if (variant === "circle") {
+ return `data:image/svg+xml,`;
+ }
- return "";
+ return "";
};
const getTransformOrigin = (start: AnimationStart) => {
- switch (start) {
- case "top-left":
- return "top left";
- case "top-right":
- return "top right";
- case "bottom-left":
- return "bottom left";
- case "bottom-right":
- return "bottom right";
- case "top-center":
- return "top center";
- case "bottom-center":
- return "bottom center";
- case "bottom-up":
- case "top-down":
- case "left-right":
- case "right-left":
- return "center";
- }
+ switch (start) {
+ case "top-left":
+ return "top left";
+ case "top-right":
+ return "top right";
+ case "bottom-left":
+ return "bottom left";
+ case "bottom-right":
+ return "bottom right";
+ case "top-center":
+ return "top center";
+ case "bottom-center":
+ return "bottom center";
+ case "bottom-up":
+ case "top-down":
+ case "left-right":
+ case "right-left":
+ return "center";
+ }
};
export const createAnimation = (
- variant: AnimationVariant,
- start: AnimationStart = "center",
- blur = false,
- url?: string,
+ variant: AnimationVariant,
+ start: AnimationStart = "center",
+ blur = false,
+ url?: string
): Animation => {
- const svg = generateSVG(variant, start);
- const transformOrigin = getTransformOrigin(start);
+ const svg = generateSVG(variant, start);
+ const transformOrigin = getTransformOrigin(start);
- if (variant === "rectangle") {
- const getClipPath = (direction: AnimationStart) => {
- switch (direction) {
- case "bottom-up":
- return {
- from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
- to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
- };
- case "top-down":
- return {
- from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
- to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
- };
- case "left-right":
- return {
- from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)",
- to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
- };
- case "right-left":
- return {
- from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)",
- to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
- };
- case "top-left":
- return {
- from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)",
- to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
- };
- case "top-right":
- return {
- from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)",
- to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
- };
- case "bottom-left":
- return {
- from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)",
- to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
- };
- case "bottom-right":
- return {
- from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)",
- to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
- };
- default:
- return {
- from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
- to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
- };
- }
- };
+ if (variant === "rectangle") {
+ const getClipPath = (direction: AnimationStart) => {
+ switch (direction) {
+ case "bottom-up":
+ return {
+ from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
+ to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
+ };
+ case "top-down":
+ return {
+ from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
+ to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
+ };
+ case "left-right":
+ return {
+ from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)",
+ to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
+ };
+ case "right-left":
+ return {
+ from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)",
+ to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
+ };
+ case "top-left":
+ return {
+ from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)",
+ to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
+ };
+ case "top-right":
+ return {
+ from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)",
+ to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
+ };
+ case "bottom-left":
+ return {
+ from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)",
+ to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
+ };
+ case "bottom-right":
+ return {
+ from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)",
+ to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
+ };
+ default:
+ return {
+ from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
+ to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
+ };
+ }
+ };
- const clipPath = getClipPath(start);
+ const clipPath = getClipPath(start);
- return {
- name: `${variant}-${start}${blur ? "-blur" : ""}`,
- css: `
+ return {
+ name: `${variant}-${start}${blur ? "-blur" : ""}`,
+ css: `
::view-transition-group(root) {
animation-duration: 0.7s;
animation-timing-function: var(--expo-out);
@@ -218,12 +213,12 @@ export const createAnimation = (
}
}
`,
- };
- }
- if (variant === "circle" && start == "center") {
- return {
- name: `${variant}-${start}${blur ? "-blur" : ""}`,
- css: `
+ };
+ }
+ if (variant === "circle" && start == "center") {
+ return {
+ name: `${variant}-${start}${blur ? "-blur" : ""}`,
+ css: `
::view-transition-group(root) {
animation-duration: 0.7s;
animation-timing-function: var(--expo-out);
@@ -268,12 +263,12 @@ export const createAnimation = (
}
}
`,
- };
- }
- if (variant === "gif") {
- return {
- name: `${variant}-${start}`,
- css: `
+ };
+ }
+ if (variant === "gif") {
+ return {
+ name: `${variant}-${start}`,
+ css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-in);
}
@@ -302,14 +297,14 @@ export const createAnimation = (
mask-size: 2000vmax;
}
}`,
- };
- }
+ };
+ }
- if (variant === "circle-blur") {
- if (start === "center") {
- return {
- name: `${variant}-${start}`,
- css: `
+ if (variant === "circle-blur") {
+ if (start === "center") {
+ return {
+ name: `${variant}-${start}`,
+ css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-out);
}
@@ -334,12 +329,12 @@ export const createAnimation = (
}
}
`,
- };
- }
+ };
+ }
- return {
- name: `${variant}-${start}`,
- css: `
+ return {
+ name: `${variant}-${start}`,
+ css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-out);
}
@@ -364,41 +359,41 @@ export const createAnimation = (
}
}
`,
- };
- }
+ };
+ }
- if (variant === "polygon") {
- const getPolygonClipPaths = (position: AnimationStart) => {
- switch (position) {
- case "top-left":
- return {
- darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
- darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
- lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
- lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
- };
- case "top-right":
- return {
- darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)",
- darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)",
- lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)",
- lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)",
- };
- default:
- return {
- darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
- darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
- lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
- lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
- };
- }
- };
+ if (variant === "polygon") {
+ const getPolygonClipPaths = (position: AnimationStart) => {
+ switch (position) {
+ case "top-left":
+ return {
+ darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
+ darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
+ lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
+ lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
+ };
+ case "top-right":
+ return {
+ darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)",
+ darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)",
+ lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)",
+ lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)",
+ };
+ default:
+ return {
+ darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
+ darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
+ lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
+ lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
+ };
+ }
+ };
- const clipPaths = getPolygonClipPaths(start);
+ const clipPaths = getPolygonClipPaths(start);
- return {
- name: `${variant}-${start}${blur ? "-blur" : ""}`,
- css: `
+ return {
+ name: `${variant}-${start}${blur ? "-blur" : ""}`,
+ css: `
::view-transition-group(root) {
animation-duration: 0.7s;
animation-timing-function: var(--expo-out);
@@ -443,35 +438,35 @@ export const createAnimation = (
}
}
`,
- };
- }
+ };
+ }
- // Handle circle variants with start positions using clip-path
- if (variant === "circle" && start !== "center") {
- const getClipPathPosition = (position: AnimationStart) => {
- switch (position) {
- case "top-left":
- return "0% 0%";
- case "top-right":
- return "100% 0%";
- case "bottom-left":
- return "0% 100%";
- case "bottom-right":
- return "100% 100%";
- case "top-center":
- return "50% 0%";
- case "bottom-center":
- return "50% 100%";
- default:
- return "50% 50%";
- }
- };
+ // Handle circle variants with start positions using clip-path
+ if (variant === "circle" && start !== "center") {
+ const getClipPathPosition = (position: AnimationStart) => {
+ switch (position) {
+ case "top-left":
+ return "0% 0%";
+ case "top-right":
+ return "100% 0%";
+ case "bottom-left":
+ return "0% 100%";
+ case "bottom-right":
+ return "100% 100%";
+ case "top-center":
+ return "50% 0%";
+ case "bottom-center":
+ return "50% 100%";
+ default:
+ return "50% 50%";
+ }
+ };
- const clipPosition = getClipPathPosition(start);
+ const clipPosition = getClipPathPosition(start);
- return {
- name: `${variant}-${start}${blur ? "-blur" : ""}`,
- css: `
+ return {
+ name: `${variant}-${start}${blur ? "-blur" : ""}`,
+ css: `
::view-transition-group(root) {
animation-duration: 1s;
animation-timing-function: var(--expo-out);
@@ -516,12 +511,12 @@ export const createAnimation = (
}
}
`,
- };
- }
+ };
+ }
- return {
- name: `${variant}-${start}${blur ? "-blur" : ""}`,
- css: `
+ return {
+ name: `${variant}-${start}${blur ? "-blur" : ""}`,
+ css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-in);
}
@@ -549,237 +544,229 @@ export const createAnimation = (
}
}
`,
- };
+ };
};
// ///////////////////////////////////////////////////////////////////////////
// Custom hook for theme toggle functionality
export const useThemeToggle = ({
- variant = "circle",
- start = "center",
- blur = false,
- gifUrl = "",
+ variant = "circle",
+ start = "center",
+ blur = false,
+ gifUrl = "",
}: {
- variant?: AnimationVariant;
- start?: AnimationStart;
- blur?: boolean;
- gifUrl?: string;
+ variant?: AnimationVariant;
+ start?: AnimationStart;
+ blur?: boolean;
+ gifUrl?: string;
} = {}) => {
- const { theme, setTheme, resolvedTheme } = useTheme();
+ const { theme, setTheme, resolvedTheme } = useTheme();
- const [isDark, setIsDark] = useState(false);
+ const [isDark, setIsDark] = useState(false);
- // Sync isDark state with resolved theme after hydration
- useEffect(() => {
- setIsDark(resolvedTheme === "dark");
- }, [resolvedTheme]);
+ // Sync isDark state with resolved theme after hydration
+ useEffect(() => {
+ setIsDark(resolvedTheme === "dark");
+ }, [resolvedTheme]);
- const styleId = "theme-transition-styles";
+ const styleId = "theme-transition-styles";
- const updateStyles = useCallback((css: string) => {
- if (typeof window === "undefined") return;
+ const updateStyles = useCallback((css: string) => {
+ if (typeof window === "undefined") return;
- let styleElement = document.getElementById(styleId) as HTMLStyleElement;
+ let styleElement = document.getElementById(styleId) as HTMLStyleElement;
- if (!styleElement) {
- styleElement = document.createElement("style");
- styleElement.id = styleId;
- document.head.appendChild(styleElement);
- }
+ if (!styleElement) {
+ styleElement = document.createElement("style");
+ styleElement.id = styleId;
+ document.head.appendChild(styleElement);
+ }
- styleElement.textContent = css;
- }, []);
+ styleElement.textContent = css;
+ }, []);
- const toggleTheme = useCallback(() => {
- setIsDark(!isDark);
+ const toggleTheme = useCallback(() => {
+ setIsDark(!isDark);
- const animation = createAnimation(variant, start, blur, gifUrl);
+ const animation = createAnimation(variant, start, blur, gifUrl);
- updateStyles(animation.css);
+ updateStyles(animation.css);
- if (typeof window === "undefined") return;
+ if (typeof window === "undefined") return;
- const switchTheme = () => {
- setTheme(theme === "light" ? "dark" : "light");
- };
+ const switchTheme = () => {
+ setTheme(theme === "light" ? "dark" : "light");
+ };
- if (!document.startViewTransition) {
- switchTheme();
- return;
- }
+ if (!document.startViewTransition) {
+ switchTheme();
+ return;
+ }
- document.startViewTransition(switchTheme);
- }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]);
+ document.startViewTransition(switchTheme);
+ }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]);
- const setCrazyLightTheme = useCallback(() => {
- setIsDark(false);
+ const setCrazyLightTheme = useCallback(() => {
+ setIsDark(false);
- const animation = createAnimation(variant, start, blur, gifUrl);
+ const animation = createAnimation(variant, start, blur, gifUrl);
- updateStyles(animation.css);
+ updateStyles(animation.css);
- if (typeof window === "undefined") return;
+ if (typeof window === "undefined") return;
- const switchTheme = () => {
- setTheme("light");
- };
+ const switchTheme = () => {
+ setTheme("light");
+ };
- if (!document.startViewTransition) {
- switchTheme();
- return;
- }
+ if (!document.startViewTransition) {
+ switchTheme();
+ return;
+ }
- document.startViewTransition(switchTheme);
- }, [setTheme, variant, start, blur, gifUrl, updateStyles]);
+ document.startViewTransition(switchTheme);
+ }, [setTheme, variant, start, blur, gifUrl, updateStyles]);
- const setCrazyDarkTheme = useCallback(() => {
- setIsDark(true);
+ const setCrazyDarkTheme = useCallback(() => {
+ setIsDark(true);
- const animation = createAnimation(variant, start, blur, gifUrl);
+ const animation = createAnimation(variant, start, blur, gifUrl);
- updateStyles(animation.css);
+ updateStyles(animation.css);
- if (typeof window === "undefined") return;
+ if (typeof window === "undefined") return;
- const switchTheme = () => {
- setTheme("dark");
- };
+ const switchTheme = () => {
+ setTheme("dark");
+ };
- if (!document.startViewTransition) {
- switchTheme();
- return;
- }
+ if (!document.startViewTransition) {
+ switchTheme();
+ return;
+ }
- document.startViewTransition(switchTheme);
- }, [setTheme, variant, start, blur, gifUrl, updateStyles]);
+ document.startViewTransition(switchTheme);
+ }, [setTheme, variant, start, blur, gifUrl, updateStyles]);
- const setCrazySystemTheme = useCallback(() => {
- if (typeof window === "undefined") return;
+ const setCrazySystemTheme = useCallback(() => {
+ if (typeof window === "undefined") return;
- const prefersDark = window.matchMedia(
- "(prefers-color-scheme: dark)",
- ).matches;
- setIsDark(prefersDark);
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+ setIsDark(prefersDark);
- const animation = createAnimation(variant, start, blur, gifUrl);
+ const animation = createAnimation(variant, start, blur, gifUrl);
- updateStyles(animation.css);
+ updateStyles(animation.css);
- const switchTheme = () => {
- setTheme("system");
- };
+ const switchTheme = () => {
+ setTheme("system");
+ };
- if (!document.startViewTransition) {
- switchTheme();
- return;
- }
+ if (!document.startViewTransition) {
+ switchTheme();
+ return;
+ }
- document.startViewTransition(switchTheme);
- }, [setTheme, variant, start, blur, gifUrl, updateStyles]);
+ document.startViewTransition(switchTheme);
+ }, [setTheme, variant, start, blur, gifUrl, updateStyles]);
- return {
- isDark,
- setIsDark,
- toggleTheme,
- setCrazyLightTheme,
- setCrazyDarkTheme,
- setCrazySystemTheme,
- };
+ return {
+ isDark,
+ setIsDark,
+ toggleTheme,
+ setCrazyLightTheme,
+ setCrazyDarkTheme,
+ setCrazySystemTheme,
+ };
};
// ///////////////////////////////////////////////////////////////////////////
// Theme Toggle Button Component (Sun/Moon Style)
export const ThemeToggleButton = ({
- className = "",
- variant = "circle",
- start = "center",
- blur = false,
- gifUrl = "",
+ className = "",
+ variant = "circle",
+ start = "center",
+ blur = false,
+ gifUrl = "",
}: {
- className?: string;
- variant?: AnimationVariant;
- start?: AnimationStart;
- blur?: boolean;
- gifUrl?: string;
+ className?: string;
+ variant?: AnimationVariant;
+ start?: AnimationStart;
+ blur?: boolean;
+ gifUrl?: string;
}) => {
- const { isDark, toggleTheme } = useThemeToggle({
- variant,
- start,
- blur,
- gifUrl,
- });
- const clipId = useId();
- const clipPathId = `theme-toggle-clip-${clipId}`;
+ const { isDark, toggleTheme } = useThemeToggle({
+ variant,
+ start,
+ blur,
+ gifUrl,
+ });
+ const clipId = useId();
+ const clipPathId = `theme-toggle-clip-${clipId}`;
- return (
-
- Toggle theme
-
-
- );
+ return (
+
+ Toggle theme
+
+
+ );
};
// ///////////////////////////////////////////////////////////////////////////
// Backwards compatible export (alias for ThemeToggleButton with default settings)
export function ThemeTogglerComponent() {
- return (
-
- );
+ return ;
}
/**
diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts
index 8e4b9ae86..ebf1889a1 100644
--- a/surfsense_web/contracts/types/inbox.types.ts
+++ b/surfsense_web/contracts/types/inbox.types.ts
@@ -10,6 +10,7 @@ export const inboxItemTypeEnum = z.enum([
"connector_deletion",
"document_processing",
"new_mention",
+ "comment_reply",
"page_limit_exceeded",
]);
@@ -101,6 +102,19 @@ export const newMentionMetadata = z.object({
content_preview: z.string(),
});
+export const commentReplyMetadata = z.object({
+ reply_id: z.number(),
+ parent_comment_id: z.number(),
+ message_id: z.number(),
+ thread_id: z.number(),
+ thread_title: z.string(),
+ author_id: z.string(),
+ author_name: z.string(),
+ author_avatar_url: z.string().nullable().optional(),
+ author_email: z.string().optional(),
+ content_preview: z.string(),
+});
+
/**
* Page limit exceeded metadata schema
*/
@@ -125,6 +139,7 @@ export const inboxItemMetadata = z.union([
connectorDeletionMetadata,
documentProcessingMetadata,
newMentionMetadata,
+ commentReplyMetadata,
pageLimitExceededMetadata,
baseInboxItemMetadata,
]);
@@ -168,6 +183,11 @@ export const newMentionInboxItem = inboxItem.extend({
metadata: newMentionMetadata,
});
+export const commentReplyInboxItem = inboxItem.extend({
+ type: z.literal("comment_reply"),
+ metadata: commentReplyMetadata,
+});
+
export const pageLimitExceededInboxItem = inboxItem.extend({
type: z.literal("page_limit_exceeded"),
metadata: pageLimitExceededMetadata,
@@ -278,6 +298,10 @@ export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionM
return newMentionMetadata.safeParse(metadata).success;
}
+export function isCommentReplyMetadata(metadata: unknown): metadata is CommentReplyMetadata {
+ return commentReplyMetadata.safeParse(metadata).success;
+}
+
/**
* Type guard for PageLimitExceededMetadata
*/
@@ -298,6 +322,7 @@ export function parseInboxItemMetadata(
| ConnectorDeletionMetadata
| DocumentProcessingMetadata
| NewMentionMetadata
+ | CommentReplyMetadata
| PageLimitExceededMetadata
| null {
switch (type) {
@@ -317,6 +342,10 @@ export function parseInboxItemMetadata(
const result = newMentionMetadata.safeParse(metadata);
return result.success ? result.data : null;
}
+ case "comment_reply": {
+ const result = commentReplyMetadata.safeParse(metadata);
+ return result.success ? result.data : null;
+ }
case "page_limit_exceeded": {
const result = pageLimitExceededMetadata.safeParse(metadata);
return result.success ? result.data : null;
@@ -338,6 +367,7 @@ export type ConnectorIndexingMetadata = z.infer;
export type DocumentProcessingMetadata = z.infer;
export type NewMentionMetadata = z.infer;
+export type CommentReplyMetadata = z.infer;
export type PageLimitExceededMetadata = z.infer;
export type InboxItemMetadata = z.infer;
export type InboxItem = z.infer;
@@ -345,6 +375,7 @@ export type ConnectorIndexingInboxItem = z.infer;
export type DocumentProcessingInboxItem = z.infer;
export type NewMentionInboxItem = z.infer;
+export type CommentReplyInboxItem = z.infer;
export type PageLimitExceededInboxItem = z.infer;
// API Request/Response types
diff --git a/surfsense_web/hooks/use-api-key.ts b/surfsense_web/hooks/use-api-key.ts
index a5f24d4c6..0c595b420 100644
--- a/surfsense_web/hooks/use-api-key.ts
+++ b/surfsense_web/hooks/use-api-key.ts
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { getBearerToken } from "@/lib/auth-utils";
+import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
interface UseApiKeyReturn {
apiKey: string | null;
@@ -33,60 +34,17 @@ export function useApiKey(): UseApiKeyReturn {
return () => clearTimeout(timer);
}, []);
- const fallbackCopyTextToClipboard = (text: string) => {
- const textArea = document.createElement("textarea");
- textArea.value = text;
-
- // Avoid scrolling to bottom
- textArea.style.top = "0";
- textArea.style.left = "0";
- textArea.style.position = "fixed";
- textArea.style.opacity = "0";
-
- document.body.appendChild(textArea);
- textArea.focus();
- textArea.select();
-
- try {
- const successful = document.execCommand("copy");
- document.body.removeChild(textArea);
-
- if (successful) {
- setCopied(true);
- toast.success("API key copied to clipboard");
-
- setTimeout(() => {
- setCopied(false);
- }, 2000);
- } else {
- toast.error("Failed to copy API key");
- }
- } catch (err) {
- console.error("Fallback: Oops, unable to copy", err);
- document.body.removeChild(textArea);
- toast.error("Failed to copy API key");
- }
- };
-
const copyToClipboard = useCallback(async () => {
if (!apiKey) return;
- try {
- if (navigator.clipboard && window.isSecureContext) {
- // Use Clipboard API if available and in secure context
- await navigator.clipboard.writeText(apiKey);
- setCopied(true);
- toast.success("API key copied to clipboard");
-
- setTimeout(() => {
- setCopied(false);
- }, 2000);
- } else {
- // Fallback for non-secure contexts or browsers without clipboard API
- fallbackCopyTextToClipboard(apiKey);
- }
- } catch (err) {
- console.error("Failed to copy:", err);
+ const success = await copyToClipboardUtil(apiKey);
+ if (success) {
+ setCopied(true);
+ toast.success("API key copied to clipboard");
+ setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+ } else {
toast.error("Failed to copy API key");
}
}, [apiKey]);
diff --git a/surfsense_web/lib/utils.ts b/surfsense_web/lib/utils.ts
index 212ff1259..e7bf8bdbe 100644
--- a/surfsense_web/lib/utils.ts
+++ b/surfsense_web/lib/utils.ts
@@ -12,3 +12,44 @@ export const formatDate = (date: Date): string => {
day: "numeric",
});
};
+
+/**
+ * Copy text to clipboard with fallback for older browsers and non-secure contexts.
+ * Returns true if successful, false otherwise.
+ */
+export async function copyToClipboard(text: string): Promise {
+ // Use modern Clipboard API if available and in secure context
+ if (navigator.clipboard && window.isSecureContext) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch (err) {
+ console.error("Clipboard API failed:", err);
+ return false;
+ }
+ }
+
+ // Fallback for non-secure contexts or browsers without Clipboard API
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+
+ // Avoid scrolling to bottom
+ textArea.style.top = "0";
+ textArea.style.left = "0";
+ textArea.style.position = "fixed";
+ textArea.style.opacity = "0";
+
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
+
+ try {
+ const successful = document.execCommand("copy");
+ document.body.removeChild(textArea);
+ return successful;
+ } catch (err) {
+ console.error("Fallback copy failed:", err);
+ document.body.removeChild(textArea);
+ return false;
+ }
+}
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index b44287af5..3020f6289 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -705,10 +705,13 @@
"mark_all_read": "Mark all as read",
"mark_as_read": "Mark as read",
"mentions": "Mentions",
+ "comments": "Comments",
"status": "Status",
"no_results_found": "No results found",
"no_mentions": "No mentions",
"no_mentions_hint": "You'll see mentions from others here",
+ "no_comments": "No comments",
+ "no_comments_hint": "You'll see mentions and replies here",
"no_status_updates": "No status updates",
"no_status_updates_hint": "Document and connector updates will appear here",
"filter": "Filter",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index cbcef3691..8c112da03 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -690,10 +690,13 @@
"mark_all_read": "全部标记为已读",
"mark_as_read": "标记为已读",
"mentions": "提及",
+ "comments": "评论",
"status": "状态",
"no_results_found": "未找到结果",
"no_mentions": "没有提及",
"no_mentions_hint": "您会在这里看到他人的提及",
+ "no_comments": "没有评论",
+ "no_comments_hint": "您会在这里看到提及和回复",
"no_status_updates": "没有状态更新",
"no_status_updates_hint": "文档和连接器更新将显示在这里",
"filter": "筛选",