Merge pull request #782 from CREDO23/sur-107-comment-reply-notifications

[Feat] Comment reply notifications and chat page &  sharing improvements
This commit is contained in:
Rohan Verma 2026-02-05 10:48:01 -08:00 committed by GitHub
commit 1ef3fd4ce9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 888 additions and 651 deletions

View file

@ -5,7 +5,7 @@ Service layer for chat comments and mentions.
from uuid import UUID from uuid import UUID
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import delete, select from sqlalchemy import delete, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -103,6 +103,37 @@ async def process_mentions(
return mentions_map 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( async def get_comments_for_message(
session: AsyncSession, session: AsyncSession,
message_id: int, message_id: int,
@ -436,6 +467,31 @@ async def create_reply(
search_space_id=search_space_id, 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( author = AuthorResponse(
id=user.id, id=user.id,
display_name=user.display_name, display_name=user.display_name,

View file

@ -861,6 +861,98 @@ class MentionNotificationHandler(BaseNotificationHandler):
raise 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): class PageLimitNotificationHandler(BaseNotificationHandler):
"""Handler for page limit exceeded notifications.""" """Handler for page limit exceeded notifications."""
@ -959,6 +1051,7 @@ class NotificationService:
connector_indexing = ConnectorIndexingNotificationHandler() connector_indexing = ConnectorIndexingNotificationHandler()
document_processing = DocumentProcessingNotificationHandler() document_processing = DocumentProcessingNotificationHandler()
mention = MentionNotificationHandler() mention = MentionNotificationHandler()
comment_reply = CommentReplyNotificationHandler()
page_limit = PageLimitNotificationHandler() page_limit = PageLimitNotificationHandler()
@staticmethod @staticmethod

View file

@ -366,11 +366,14 @@ async def list_snapshots_for_thread(
if not thread: if not thread:
raise HTTPException(status_code=404, detail="Thread not found") raise HTTPException(status_code=404, detail="Thread not found")
if thread.created_by_id != user.id: # Check permission to view public share links
raise HTTPException( await check_permission(
status_code=403, session,
detail="Only the creator can view snapshots", user,
) thread.search_space_id,
Permission.PUBLIC_SHARING_VIEW.value,
"You don't have permission to view public share links",
)
result = await session.execute( result = await session.execute(
select(PublicChatSnapshot) select(PublicChatSnapshot)

View file

@ -4,20 +4,19 @@ import {
ErrorPrimitive, ErrorPrimitive,
MessagePrimitive, MessagePrimitive,
useAssistantState, useAssistantState,
useMessage,
} from "@assistant-ui/react"; } 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 { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { import {
addingCommentToMessageIdAtom, addingCommentToMessageIdAtom,
clearTargetCommentIdAtom,
commentsCollapsedAtom, commentsCollapsedAtom,
commentsEnabledAtom, commentsEnabledAtom,
targetCommentIdAtom, targetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom"; } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; 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 { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { import {
ThinkingStepsContext, ThinkingStepsContext,
@ -84,7 +83,6 @@ const AssistantMessageInner: FC = () => {
</div> </div>
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex"> <div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
<BranchPicker />
<AssistantActionBar /> <AssistantActionBar />
</div> </div>
</> </>
@ -126,7 +124,6 @@ export const AssistantMessage: FC = () => {
// Target comment navigation - read target from global atom // Target comment navigation - read target from global atom
const targetCommentId = useAtomValue(targetCommentIdAtom); const targetCommentId = useAtomValue(targetCommentIdAtom);
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
// Check if target comment belongs to this message (including replies) // Check if target comment belongs to this message (including replies)
const hasTargetComment = useMemo(() => { const hasTargetComment = useMemo(() => {
@ -263,6 +260,8 @@ export const AssistantMessage: FC = () => {
}; };
const AssistantActionBar: FC = () => { const AssistantActionBar: FC = () => {
const { isLast } = useMessage();
return ( return (
<ActionBarPrimitive.Root <ActionBarPrimitive.Root
hideWhenRunning hideWhenRunning
@ -285,11 +284,14 @@ const AssistantActionBar: FC = () => {
<DownloadIcon /> <DownloadIcon />
</TooltipIconButton> </TooltipIconButton>
</ActionBarPrimitive.ExportMarkdown> </ActionBarPrimitive.ExportMarkdown>
<ActionBarPrimitive.Reload asChild> {/* Only allow regenerating the last assistant message */}
<TooltipIconButton tooltip="Refresh"> {isLast && (
<RefreshCwIcon /> <ActionBarPrimitive.Reload asChild>
</TooltipIconButton> <TooltipIconButton tooltip="Refresh">
</ActionBarPrimitive.Reload> <RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
)}
</ActionBarPrimitive.Root> </ActionBarPrimitive.Root>
); );
}; };

View file

@ -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<BranchPickerPrimitive.Root.Props> = ({ className, ...rest }) => {
return (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn(
"aui-branch-picker-root -ml-2 mr-2 inline-flex items-center text-muted-foreground text-xs",
className
)}
{...rest}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="aui-branch-picker-state font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};

View file

@ -4,7 +4,6 @@ import { FileText, PencilIcon } from "lucide-react";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { UserMessageAttachments } from "@/components/assistant-ui/attachment"; import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
interface AuthorMetadata { interface AuthorMetadata {
@ -95,24 +94,47 @@ export const UserMessage: FC = () => {
</div> </div>
)} )}
</div> </div>
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />
</MessagePrimitive.Root> </MessagePrimitive.Root>
); );
}; };
const UserActionBar: 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 ( return (
<ActionBarPrimitive.Root <ActionBarPrimitive.Root
hideWhenRunning hideWhenRunning
autohide="not-last" autohide="not-last"
className="aui-user-action-bar-root flex flex-col items-end" className="aui-user-action-bar-root flex flex-col items-end"
> >
<ActionBarPrimitive.Edit asChild> {/* Only allow editing the last user message */}
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4"> {canEdit && (
<PencilIcon /> <ActionBarPrimitive.Edit asChild>
</TooltipIconButton> <TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
</ActionBarPrimitive.Edit> <PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
)}
</ActionBarPrimitive.Root> </ActionBarPrimitive.Root>
); );
}; };

View file

@ -110,7 +110,6 @@ export function LayoutDataProvider({
// This ensures each tab has independent pagination and data loading // This ensures each tab has independent pagination and data loading
const userId = user?.id ? String(user.id) : null; const userId = user?.id ? String(user.id) : null;
// Mentions: Only fetch "new_mention" type notifications
const { const {
inboxItems: mentionItems, inboxItems: mentionItems,
unreadCount: mentionUnreadCount, unreadCount: mentionUnreadCount,
@ -122,11 +121,9 @@ export function LayoutDataProvider({
markAllAsRead: markAllMentionsAsRead, markAllAsRead: markAllMentionsAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention"); } = 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 { const {
inboxItems: statusItems, inboxItems: statusItems,
unreadCount: statusUnreadCount, unreadCount: allUnreadCount,
loading: statusLoading, loading: statusLoading,
loadingMore: statusLoadingMore, loadingMore: statusLoadingMore,
hasMore: statusHasMore, hasMore: statusHasMore,
@ -135,8 +132,8 @@ export function LayoutDataProvider({
markAllAsRead: markAllStatusAsRead, markAllAsRead: markAllStatusAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, null); } = useInbox(userId, Number(searchSpaceId) || null, null);
// Combined unread count for nav badge (mentions take priority for visibility) const totalUnreadCount = allUnreadCount;
const totalUnreadCount = mentionUnreadCount + statusUnreadCount; const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount);
// Track seen notification IDs to detect new page_limit_exceeded notifications // Track seen notification IDs to detect new page_limit_exceeded notifications
const seenPageLimitNotifications = useRef<Set<number>>(new Set()); const seenPageLimitNotifications = useRef<Set<number>>(new Set());
@ -598,7 +595,7 @@ export function LayoutDataProvider({
}, },
status: { status: {
items: statusItems, items: statusItems,
unreadCount: statusUnreadCount, unreadCount: statusOnlyUnreadCount,
loading: statusLoading, loading: statusLoading,
loadingMore: statusLoadingMore, loadingMore: statusLoadingMore,
hasMore: statusHasMore, hasMore: statusHasMore,

View file

@ -4,7 +4,6 @@ import { useAtom } from "jotai";
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
AtSign,
BellDot, BellDot,
Check, Check,
CheckCheck, CheckCheck,
@ -15,6 +14,7 @@ import {
Inbox, Inbox,
LayoutGrid, LayoutGrid,
ListFilter, ListFilter,
MessageSquare,
Search, Search,
X, X,
} from "lucide-react"; } from "lucide-react";
@ -46,6 +46,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { import {
isCommentReplyMetadata,
isConnectorIndexingMetadata, isConnectorIndexingMetadata,
isNewMentionMetadata, isNewMentionMetadata,
isPageLimitExceededMetadata, isPageLimitExceededMetadata,
@ -133,7 +134,7 @@ function getConnectorTypeDisplayName(connectorType: string): string {
); );
} }
type InboxTab = "mentions" | "status"; type InboxTab = "comments" | "status";
type InboxFilter = "all" | "unread"; type InboxFilter = "all" | "unread";
// Tab-specific data source with independent pagination // Tab-specific data source with independent pagination
@ -186,7 +187,7 @@ export function InboxSidebar({
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState<InboxTab>("mentions"); const [activeTab, setActiveTab] = useState<InboxTab>("comments");
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all"); const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
const [selectedConnector, setSelectedConnector] = useState<string | null>(null); const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@ -233,12 +234,17 @@ export function InboxSidebar({
} }
}, [activeTab]); }, [activeTab]);
// Get current tab's data source - each tab has independent data and pagination // Both tabs now derive items from status (all types), so use status for pagination
const currentDataSource = activeTab === "mentions" ? mentions : status; const { loading, loadingMore = false, hasMore = false, loadMore } = status;
const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
// Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion // Comments tab: mentions and comment replies
// Filter to only show status notification types 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( const statusItems = useMemo(
() => () =>
status.items.filter( status.items.filter(
@ -270,8 +276,8 @@ export function InboxSidebar({
})); }));
}, [statusItems]); }, [statusItems]);
// Get items for current tab - mentions use their source directly, status uses filtered items // Get items for current tab
const displayItems = activeTab === "mentions" ? mentions.items : statusItems; const displayItems = activeTab === "comments" ? commentsItems : statusItems;
// Filter items based on filter type, connector filter, and search query // Filter items based on filter type, connector filter, and search query
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
@ -334,9 +340,15 @@ export function InboxSidebar({
return () => observer.disconnect(); return () => observer.disconnect();
}, [loadMore, hasMore, loadingMore, open, searchQuery]); }, [loadMore, hasMore, loadingMore, open, searchQuery]);
// Use unread counts from data sources (more accurate than client-side counting) // Unread counts derived from filtered items
const unreadMentionsCount = mentions.unreadCount; const unreadCommentsCount = useMemo(
const unreadStatusCount = status.unreadCount; () => commentsItems.filter((item) => !item.read).length,
[commentsItems]
);
const unreadStatusCount = useMemo(
() => statusItems.filter((item) => !item.read).length,
[statusItems]
);
const handleItemClick = useCallback( const handleItemClick = useCallback(
async (item: InboxItem) => { async (item: InboxItem) => {
@ -347,19 +359,15 @@ export function InboxSidebar({
} }
if (item.type === "new_mention") { if (item.type === "new_mention") {
// Use type guard for safe metadata access
if (isNewMentionMetadata(item.metadata)) { if (isNewMentionMetadata(item.metadata)) {
const searchSpaceId = item.search_space_id; const searchSpaceId = item.search_space_id;
const threadId = item.metadata.thread_id; const threadId = item.metadata.thread_id;
const commentId = item.metadata.comment_id; const commentId = item.metadata.comment_id;
if (searchSpaceId && threadId) { if (searchSpaceId && threadId) {
// Pre-set target comment ID before navigation
// This also ensures comments panel is not collapsed
if (commentId) { if (commentId) {
setTargetCommentId(commentId); setTargetCommentId(commentId);
} }
const url = commentId const url = commentId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`; : `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
@ -368,6 +376,24 @@ export function InboxSidebar({
router.push(url); 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") { } else if (item.type === "page_limit_exceeded") {
// Navigate to the upgrade/more-pages page // Navigate to the upgrade/more-pages page
if (isPageLimitExceededMetadata(item.metadata)) { if (isPageLimitExceededMetadata(item.metadata)) {
@ -411,24 +437,29 @@ export function InboxSidebar({
}; };
const getStatusIcon = (item: InboxItem) => { const getStatusIcon = (item: InboxItem) => {
// For mentions, show the author's avatar with initials fallback // For mentions and comment replies, show the author's avatar
if (item.type === "new_mention") { if (item.type === "new_mention" || item.type === "comment_reply") {
// Use type guard for safe metadata access const metadata =
if (isNewMentionMetadata(item.metadata)) { item.type === "new_mention"
const authorName = item.metadata.author_name; ? isNewMentionMetadata(item.metadata)
const avatarUrl = item.metadata.author_avatar_url; ? item.metadata
const authorEmail = item.metadata.author_email; : null
: isCommentReplyMetadata(item.metadata)
? item.metadata
: null;
if (metadata) {
return ( return (
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />} {metadata.author_avatar_url && (
<AvatarImage src={metadata.author_avatar_url} alt={metadata.author_name || "User"} />
)}
<AvatarFallback className="text-[10px] bg-primary/10 text-primary"> <AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(authorName, authorEmail)} {getInitials(metadata.author_name, metadata.author_email)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
); );
} }
// Fallback for invalid metadata
return ( return (
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarFallback className="text-[10px] bg-primary/10 text-primary"> <AvatarFallback className="text-[10px] bg-primary/10 text-primary">
@ -481,10 +512,10 @@ export function InboxSidebar({
}; };
const getEmptyStateMessage = () => { const getEmptyStateMessage = () => {
if (activeTab === "mentions") { if (activeTab === "comments") {
return { return {
title: t("no_mentions") || "No mentions", title: t("no_comments") || "No comments",
hint: t("no_mentions_hint") || "You'll see mentions from others here", hint: t("no_comments_hint") || "You'll see mentions and replies here",
}; };
} }
return { return {
@ -823,14 +854,14 @@ export function InboxSidebar({
> >
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b"> <TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger <TabsTrigger
value="mentions" value="comments"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none" className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
> >
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors"> <span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<AtSign className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
<span>{t("mentions") || "Mentions"}</span> <span>{t("comments") || "Comments"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium"> <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{formatInboxCount(unreadMentionsCount)} {formatInboxCount(unreadCommentsCount)}
</span> </span>
</span> </span>
</TabsTrigger> </TabsTrigger>
@ -932,8 +963,8 @@ export function InboxSidebar({
</div> </div>
) : ( ) : (
<div className="text-center py-8"> <div className="text-center py-8">
{activeTab === "mentions" ? ( {activeTab === "comments" ? (
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" /> <MessageSquare className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
) : ( ) : (
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" /> <History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
)} )}

View file

@ -1,8 +1,9 @@
"use client"; "use client";
import { useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Globe, User, Users } from "lucide-react"; import { Globe, User, Users } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; 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 { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
import { import {
type ChatVisibility, type ChatVisibility,
type ThreadRecord, type ThreadRecord,
@ -46,6 +48,8 @@ const visibilityOptions: {
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) { export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const params = useParams();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Use Jotai atom for visibility (single source of truth) // 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; return access.permissions?.includes("public_sharing:create") ?? false;
}, [access]); }, [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 // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
@ -106,11 +120,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
try { try {
await createSnapshot({ thread_id: thread.id }); await createSnapshot({ thread_id: thread.id });
// Refetch snapshots to show the globe indicator
await queryClient.invalidateQueries({ queryKey: ["thread-snapshots", thread.id] });
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
console.error("Failed to create public link:", 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) // Don't show if no thread (new chat that hasn't been created yet)
if (!thread) { if (!thread) {
@ -121,112 +137,131 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared"; const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared";
return ( return (
<Popover open={open} onOpenChange={setOpen}> <div className={cn("flex items-center gap-1", className)}>
<Tooltip> <Popover open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild> <Tooltip>
<PopoverTrigger asChild> <TooltipTrigger asChild>
<Button <PopoverTrigger asChild>
variant="outline" <Button
size="icon" variant="outline"
className={cn( size="icon"
"h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0", className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0"
className
)}
>
<CurrentIcon className="h-4 w-4" />
<span className="hidden md:inline text-sm">{buttonLabel}</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Share settings</TooltipContent>
</Tooltip>
<PopoverContent
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
align="end"
sideOffset={8}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="p-1.5 space-y-1">
{/* Visibility Options */}
{visibilityOptions.map((option) => {
const isSelected = currentVisibility === option.value;
const Icon = option.icon;
return (
<button
type="button"
key={option.value}
onClick={() => handleVisibilityChange(option.value)}
className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none",
isSelected && "bg-accent/80"
)}
> >
<div <CurrentIcon className="h-4 w-4" />
<span className="hidden md:inline text-sm">{buttonLabel}</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Share settings</TooltipContent>
</Tooltip>
<PopoverContent
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
align="end"
sideOffset={8}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="p-1.5 space-y-1">
{/* Visibility Options */}
{visibilityOptions.map((option) => {
const isSelected = currentVisibility === option.value;
const Icon = option.icon;
return (
<button
type="button"
key={option.value}
onClick={() => handleVisibilityChange(option.value)}
className={cn( className={cn(
"size-7 rounded-md shrink-0 grid place-items-center", "w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
isSelected ? "bg-primary/10" : "bg-muted" "hover:bg-accent/50 cursor-pointer",
"focus:outline-none",
isSelected && "bg-accent/80"
)} )}
> >
<Icon <div
className={cn( className={cn(
"size-4 block", "size-7 rounded-md shrink-0 grid place-items-center",
isSelected ? "text-primary" : "text-muted-foreground" isSelected ? "bg-primary/10" : "bg-muted"
)} )}
/> >
</div> <Icon
<div className="flex-1 text-left min-w-0"> className={cn(
<div className="flex items-center gap-1.5"> "size-4 block",
<span className={cn("text-sm font-medium", isSelected && "text-primary")}> isSelected ? "text-primary" : "text-muted-foreground"
{option.label} )}
</span> />
</div> </div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug"> <div className="flex-1 text-left min-w-0">
{option.description} <div className="flex items-center gap-1.5">
</p> <span className={cn("text-sm font-medium", isSelected && "text-primary")}>
</div> {option.label}
</button> </span>
); </div>
})} <p className="text-xs text-muted-foreground mt-0.5 leading-snug">
{option.description}
{canCreatePublicLink && ( </p>
<>
{/* Divider */}
<div className="border-t border-border my-1" />
{/* Public Link Option */}
<button
type="button"
onClick={handleCreatePublicLink}
disabled={isCreatingSnapshot}
className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
<Globe className="size-4 block text-muted-foreground" />
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
</span>
</div> </div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug"> </button>
Creates a shareable snapshot of this chat );
</p> })}
</div>
</button> {canCreatePublicLink && (
</> <>
)} {/* Divider */}
</div> <div className="border-t border-border my-1" />
</PopoverContent>
</Popover> {/* Public Link Option */}
<button
type="button"
onClick={handleCreatePublicLink}
disabled={isCreatingSnapshot}
className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
<Globe className="size-4 block text-muted-foreground" />
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
Creates a shareable snapshot of this chat
</p>
</div>
</button>
</>
)}
</div>
</PopoverContent>
</Popover>
{/* Globe indicator when public snapshots exist - clicks to settings */}
{hasPublicSnapshots && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>
<Globe className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>
{snapshotCount === 1
? "This chat has a public link"
: `This chat has ${snapshotCount} public links`}
</TooltipContent>
</Tooltip>
)}
</div>
); );
} }

View file

@ -38,6 +38,13 @@ export function PublicChatSnapshotRow({
{snapshot.message_count} {snapshot.message_count}
</span> </span>
</div> </div>
<input
type="text"
readOnly
value={snapshot.public_url}
className="mt-2 w-full text-xs text-muted-foreground bg-muted/50 border rounded px-2 py-1 select-all focus:outline-none focus:ring-1 focus:ring-ring"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button

View file

@ -8,172 +8,167 @@ import { cn } from "@/lib/utils";
// /////////////////////////////////////////////////////////////////////////// // ///////////////////////////////////////////////////////////////////////////
// Types // Types
export type AnimationVariant = export type AnimationVariant = "circle" | "rectangle" | "gif" | "polygon" | "circle-blur";
| "circle"
| "rectangle"
| "gif"
| "polygon"
| "circle-blur";
export type AnimationStart = export type AnimationStart =
| "top-left" | "top-left"
| "top-right" | "top-right"
| "bottom-left" | "bottom-left"
| "bottom-right" | "bottom-right"
| "center" | "center"
| "top-center" | "top-center"
| "bottom-center" | "bottom-center"
| "bottom-up" | "bottom-up"
| "top-down" | "top-down"
| "left-right" | "left-right"
| "right-left"; | "right-left";
interface Animation { interface Animation {
name: string; name: string;
css: string; css: string;
} }
// /////////////////////////////////////////////////////////////////////////// // ///////////////////////////////////////////////////////////////////////////
// Helper functions // Helper functions
const getPositionCoords = (position: AnimationStart) => { const getPositionCoords = (position: AnimationStart) => {
switch (position) { switch (position) {
case "top-left": case "top-left":
return { cx: "0", cy: "0" }; return { cx: "0", cy: "0" };
case "top-right": case "top-right":
return { cx: "40", cy: "0" }; return { cx: "40", cy: "0" };
case "bottom-left": case "bottom-left":
return { cx: "0", cy: "40" }; return { cx: "0", cy: "40" };
case "bottom-right": case "bottom-right":
return { cx: "40", cy: "40" }; return { cx: "40", cy: "40" };
case "top-center": case "top-center":
return { cx: "20", cy: "0" }; return { cx: "20", cy: "0" };
case "bottom-center": case "bottom-center":
return { cx: "20", cy: "40" }; return { cx: "20", cy: "40" };
case "bottom-up": case "bottom-up":
case "top-down": case "top-down":
case "left-right": case "left-right":
case "right-left": case "right-left":
return { cx: "20", cy: "20" }; return { cx: "20", cy: "20" };
} }
}; };
const generateSVG = (variant: AnimationVariant, start: AnimationStart) => { const generateSVG = (variant: AnimationVariant, start: AnimationStart) => {
if (variant === "circle-blur") { if (variant === "circle-blur") {
if (start === "center") { if (start === "center") {
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="20" cy="20" r="18" fill="white" filter="url(%23blur)"/></svg>`; return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="20" cy="20" r="18" fill="white" filter="url(%23blur)"/></svg>`;
} }
const positionCoords = getPositionCoords(start); const positionCoords = getPositionCoords(start);
if (!positionCoords) { if (!positionCoords) {
throw new Error(`Invalid start position: ${start}`); throw new Error(`Invalid start position: ${start}`);
} }
const { cx, cy } = positionCoords; const { cx, cy } = positionCoords;
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="${cx}" cy="${cy}" r="18" fill="white" filter="url(%23blur)"/></svg>`; return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="${cx}" cy="${cy}" r="18" fill="white" filter="url(%23blur)"/></svg>`;
} }
if (start === "center") return; if (start === "center") return;
if (variant === "rectangle") return ""; if (variant === "rectangle") return "";
const positionCoords = getPositionCoords(start); const positionCoords = getPositionCoords(start);
if (!positionCoords) { if (!positionCoords) {
throw new Error(`Invalid start position: ${start}`); throw new Error(`Invalid start position: ${start}`);
} }
const { cx, cy } = positionCoords; const { cx, cy } = positionCoords;
if (variant === "circle") { if (variant === "circle") {
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><circle cx="${cx}" cy="${cy}" r="20" fill="white"/></svg>`; return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><circle cx="${cx}" cy="${cy}" r="20" fill="white"/></svg>`;
} }
return ""; return "";
}; };
const getTransformOrigin = (start: AnimationStart) => { const getTransformOrigin = (start: AnimationStart) => {
switch (start) { switch (start) {
case "top-left": case "top-left":
return "top left"; return "top left";
case "top-right": case "top-right":
return "top right"; return "top right";
case "bottom-left": case "bottom-left":
return "bottom left"; return "bottom left";
case "bottom-right": case "bottom-right":
return "bottom right"; return "bottom right";
case "top-center": case "top-center":
return "top center"; return "top center";
case "bottom-center": case "bottom-center":
return "bottom center"; return "bottom center";
case "bottom-up": case "bottom-up":
case "top-down": case "top-down":
case "left-right": case "left-right":
case "right-left": case "right-left":
return "center"; return "center";
} }
}; };
export const createAnimation = ( export const createAnimation = (
variant: AnimationVariant, variant: AnimationVariant,
start: AnimationStart = "center", start: AnimationStart = "center",
blur = false, blur = false,
url?: string, url?: string
): Animation => { ): Animation => {
const svg = generateSVG(variant, start); const svg = generateSVG(variant, start);
const transformOrigin = getTransformOrigin(start); const transformOrigin = getTransformOrigin(start);
if (variant === "rectangle") { if (variant === "rectangle") {
const getClipPath = (direction: AnimationStart) => { const getClipPath = (direction: AnimationStart) => {
switch (direction) { switch (direction) {
case "bottom-up": case "bottom-up":
return { return {
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
}; };
case "top-down": case "top-down":
return { return {
from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
}; };
case "left-right": case "left-right":
return { return {
from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)", from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
}; };
case "right-left": case "right-left":
return { return {
from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)", from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
}; };
case "top-left": case "top-left":
return { return {
from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)", from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
}; };
case "top-right": case "top-right":
return { return {
from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)", from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
}; };
case "bottom-left": case "bottom-left":
return { return {
from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)", from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
}; };
case "bottom-right": case "bottom-right":
return { return {
from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)", from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
}; };
default: default:
return { return {
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
}; };
} }
}; };
const clipPath = getClipPath(start); const clipPath = getClipPath(start);
return { return {
name: `${variant}-${start}${blur ? "-blur" : ""}`, name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: ` css: `
::view-transition-group(root) { ::view-transition-group(root) {
animation-duration: 0.7s; animation-duration: 0.7s;
animation-timing-function: var(--expo-out); animation-timing-function: var(--expo-out);
@ -218,12 +213,12 @@ export const createAnimation = (
} }
} }
`, `,
}; };
} }
if (variant === "circle" && start == "center") { if (variant === "circle" && start == "center") {
return { return {
name: `${variant}-${start}${blur ? "-blur" : ""}`, name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: ` css: `
::view-transition-group(root) { ::view-transition-group(root) {
animation-duration: 0.7s; animation-duration: 0.7s;
animation-timing-function: var(--expo-out); animation-timing-function: var(--expo-out);
@ -268,12 +263,12 @@ export const createAnimation = (
} }
} }
`, `,
}; };
} }
if (variant === "gif") { if (variant === "gif") {
return { return {
name: `${variant}-${start}`, name: `${variant}-${start}`,
css: ` css: `
::view-transition-group(root) { ::view-transition-group(root) {
animation-timing-function: var(--expo-in); animation-timing-function: var(--expo-in);
} }
@ -302,14 +297,14 @@ export const createAnimation = (
mask-size: 2000vmax; mask-size: 2000vmax;
} }
}`, }`,
}; };
} }
if (variant === "circle-blur") { if (variant === "circle-blur") {
if (start === "center") { if (start === "center") {
return { return {
name: `${variant}-${start}`, name: `${variant}-${start}`,
css: ` css: `
::view-transition-group(root) { ::view-transition-group(root) {
animation-timing-function: var(--expo-out); animation-timing-function: var(--expo-out);
} }
@ -334,12 +329,12 @@ export const createAnimation = (
} }
} }
`, `,
}; };
} }
return { return {
name: `${variant}-${start}`, name: `${variant}-${start}`,
css: ` css: `
::view-transition-group(root) { ::view-transition-group(root) {
animation-timing-function: var(--expo-out); animation-timing-function: var(--expo-out);
} }
@ -364,41 +359,41 @@ export const createAnimation = (
} }
} }
`, `,
}; };
} }
if (variant === "polygon") { if (variant === "polygon") {
const getPolygonClipPaths = (position: AnimationStart) => { const getPolygonClipPaths = (position: AnimationStart) => {
switch (position) { switch (position) {
case "top-left": case "top-left":
return { return {
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
}; };
case "top-right": case "top-right":
return { return {
darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)", darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)",
darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)", darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)",
lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)", lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)",
lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)", lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)",
}; };
default: default:
return { return {
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
}; };
} }
}; };
const clipPaths = getPolygonClipPaths(start); const clipPaths = getPolygonClipPaths(start);
return { return {
name: `${variant}-${start}${blur ? "-blur" : ""}`, name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: ` css: `
::view-transition-group(root) { ::view-transition-group(root) {
animation-duration: 0.7s; animation-duration: 0.7s;
animation-timing-function: var(--expo-out); animation-timing-function: var(--expo-out);
@ -443,35 +438,35 @@ export const createAnimation = (
} }
} }
`, `,
}; };
} }
// Handle circle variants with start positions using clip-path // Handle circle variants with start positions using clip-path
if (variant === "circle" && start !== "center") { if (variant === "circle" && start !== "center") {
const getClipPathPosition = (position: AnimationStart) => { const getClipPathPosition = (position: AnimationStart) => {
switch (position) { switch (position) {
case "top-left": case "top-left":
return "0% 0%"; return "0% 0%";
case "top-right": case "top-right":
return "100% 0%"; return "100% 0%";
case "bottom-left": case "bottom-left":
return "0% 100%"; return "0% 100%";
case "bottom-right": case "bottom-right":
return "100% 100%"; return "100% 100%";
case "top-center": case "top-center":
return "50% 0%"; return "50% 0%";
case "bottom-center": case "bottom-center":
return "50% 100%"; return "50% 100%";
default: default:
return "50% 50%"; return "50% 50%";
} }
}; };
const clipPosition = getClipPathPosition(start); const clipPosition = getClipPathPosition(start);
return { return {
name: `${variant}-${start}${blur ? "-blur" : ""}`, name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: ` css: `
::view-transition-group(root) { ::view-transition-group(root) {
animation-duration: 1s; animation-duration: 1s;
animation-timing-function: var(--expo-out); animation-timing-function: var(--expo-out);
@ -516,12 +511,12 @@ export const createAnimation = (
} }
} }
`, `,
}; };
} }
return { return {
name: `${variant}-${start}${blur ? "-blur" : ""}`, name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: ` css: `
::view-transition-group(root) { ::view-transition-group(root) {
animation-timing-function: var(--expo-in); animation-timing-function: var(--expo-in);
} }
@ -549,237 +544,229 @@ export const createAnimation = (
} }
} }
`, `,
}; };
}; };
// /////////////////////////////////////////////////////////////////////////// // ///////////////////////////////////////////////////////////////////////////
// Custom hook for theme toggle functionality // Custom hook for theme toggle functionality
export const useThemeToggle = ({ export const useThemeToggle = ({
variant = "circle", variant = "circle",
start = "center", start = "center",
blur = false, blur = false,
gifUrl = "", gifUrl = "",
}: { }: {
variant?: AnimationVariant; variant?: AnimationVariant;
start?: AnimationStart; start?: AnimationStart;
blur?: boolean; blur?: boolean;
gifUrl?: string; 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 // Sync isDark state with resolved theme after hydration
useEffect(() => { useEffect(() => {
setIsDark(resolvedTheme === "dark"); setIsDark(resolvedTheme === "dark");
}, [resolvedTheme]); }, [resolvedTheme]);
const styleId = "theme-transition-styles"; const styleId = "theme-transition-styles";
const updateStyles = useCallback((css: string) => { const updateStyles = useCallback((css: string) => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
let styleElement = document.getElementById(styleId) as HTMLStyleElement; let styleElement = document.getElementById(styleId) as HTMLStyleElement;
if (!styleElement) { if (!styleElement) {
styleElement = document.createElement("style"); styleElement = document.createElement("style");
styleElement.id = styleId; styleElement.id = styleId;
document.head.appendChild(styleElement); document.head.appendChild(styleElement);
} }
styleElement.textContent = css; styleElement.textContent = css;
}, []); }, []);
const toggleTheme = useCallback(() => { const toggleTheme = useCallback(() => {
setIsDark(!isDark); 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 = () => { const switchTheme = () => {
setTheme(theme === "light" ? "dark" : "light"); setTheme(theme === "light" ? "dark" : "light");
}; };
if (!document.startViewTransition) { if (!document.startViewTransition) {
switchTheme(); switchTheme();
return; return;
} }
document.startViewTransition(switchTheme); document.startViewTransition(switchTheme);
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]); }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]);
const setCrazyLightTheme = useCallback(() => { const setCrazyLightTheme = useCallback(() => {
setIsDark(false); 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 = () => { const switchTheme = () => {
setTheme("light"); setTheme("light");
}; };
if (!document.startViewTransition) { if (!document.startViewTransition) {
switchTheme(); switchTheme();
return; return;
} }
document.startViewTransition(switchTheme); document.startViewTransition(switchTheme);
}, [setTheme, variant, start, blur, gifUrl, updateStyles]); }, [setTheme, variant, start, blur, gifUrl, updateStyles]);
const setCrazyDarkTheme = useCallback(() => { const setCrazyDarkTheme = useCallback(() => {
setIsDark(true); 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 = () => { const switchTheme = () => {
setTheme("dark"); setTheme("dark");
}; };
if (!document.startViewTransition) { if (!document.startViewTransition) {
switchTheme(); switchTheme();
return; return;
} }
document.startViewTransition(switchTheme); document.startViewTransition(switchTheme);
}, [setTheme, variant, start, blur, gifUrl, updateStyles]); }, [setTheme, variant, start, blur, gifUrl, updateStyles]);
const setCrazySystemTheme = useCallback(() => { const setCrazySystemTheme = useCallback(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const prefersDark = window.matchMedia( const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
"(prefers-color-scheme: dark)", setIsDark(prefersDark);
).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 = () => { const switchTheme = () => {
setTheme("system"); setTheme("system");
}; };
if (!document.startViewTransition) { if (!document.startViewTransition) {
switchTheme(); switchTheme();
return; return;
} }
document.startViewTransition(switchTheme); document.startViewTransition(switchTheme);
}, [setTheme, variant, start, blur, gifUrl, updateStyles]); }, [setTheme, variant, start, blur, gifUrl, updateStyles]);
return { return {
isDark, isDark,
setIsDark, setIsDark,
toggleTheme, toggleTheme,
setCrazyLightTheme, setCrazyLightTheme,
setCrazyDarkTheme, setCrazyDarkTheme,
setCrazySystemTheme, setCrazySystemTheme,
}; };
}; };
// /////////////////////////////////////////////////////////////////////////// // ///////////////////////////////////////////////////////////////////////////
// Theme Toggle Button Component (Sun/Moon Style) // Theme Toggle Button Component (Sun/Moon Style)
export const ThemeToggleButton = ({ export const ThemeToggleButton = ({
className = "", className = "",
variant = "circle", variant = "circle",
start = "center", start = "center",
blur = false, blur = false,
gifUrl = "", gifUrl = "",
}: { }: {
className?: string; className?: string;
variant?: AnimationVariant; variant?: AnimationVariant;
start?: AnimationStart; start?: AnimationStart;
blur?: boolean; blur?: boolean;
gifUrl?: string; gifUrl?: string;
}) => { }) => {
const { isDark, toggleTheme } = useThemeToggle({ const { isDark, toggleTheme } = useThemeToggle({
variant, variant,
start, start,
blur, blur,
gifUrl, gifUrl,
}); });
const clipId = useId(); const clipId = useId();
const clipPathId = `theme-toggle-clip-${clipId}`; const clipPathId = `theme-toggle-clip-${clipId}`;
return ( return (
<button <button
type="button" type="button"
className={cn( className={cn(
"size-10 cursor-pointer rounded-full p-2 transition-all duration-300 active:scale-95 bg-transparent", "size-10 cursor-pointer rounded-full p-2 transition-all duration-300 active:scale-95 bg-transparent",
isDark ? "text-white" : "text-black", isDark ? "text-white" : "text-black",
className, className
)} )}
onClick={toggleTheme} onClick={toggleTheme}
aria-label="Toggle theme" aria-label="Toggle theme"
> >
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" aria-hidden="true"
fill="currentColor" fill="currentColor"
strokeLinecap="round" strokeLinecap="round"
viewBox="0 0 32 32" viewBox="0 0 32 32"
> >
<clipPath id={clipPathId}> <clipPath id={clipPathId}>
<motion.path <motion.path
animate={{ y: isDark ? 10 : 0, x: isDark ? -12 : 0 }} animate={{ y: isDark ? 10 : 0, x: isDark ? -12 : 0 }}
transition={{ ease: "easeInOut", duration: 0.35 }} transition={{ ease: "easeInOut", duration: 0.35 }}
d="M0-5h30a1 1 0 0 0 9 13v24H0Z" d="M0-5h30a1 1 0 0 0 9 13v24H0Z"
/> />
</clipPath> </clipPath>
<g clipPath={`url(#${clipPathId})`}> <g clipPath={`url(#${clipPathId})`}>
<motion.circle <motion.circle
animate={{ r: isDark ? 10 : 8 }} animate={{ r: isDark ? 10 : 8 }}
transition={{ ease: "easeInOut", duration: 0.35 }} transition={{ ease: "easeInOut", duration: 0.35 }}
cx="16" cx="16"
cy="16" cy="16"
/> />
<motion.g <motion.g
animate={{ animate={{
rotate: isDark ? -100 : 0, rotate: isDark ? -100 : 0,
scale: isDark ? 0.5 : 1, scale: isDark ? 0.5 : 1,
opacity: isDark ? 0 : 1, opacity: isDark ? 0 : 1,
}} }}
transition={{ ease: "easeInOut", duration: 0.35 }} transition={{ ease: "easeInOut", duration: 0.35 }}
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
> >
<path d="M16 5.5v-4" /> <path d="M16 5.5v-4" />
<path d="M16 30.5v-4" /> <path d="M16 30.5v-4" />
<path d="M1.5 16h4" /> <path d="M1.5 16h4" />
<path d="M26.5 16h4" /> <path d="M26.5 16h4" />
<path d="m23.4 8.6 2.8-2.8" /> <path d="m23.4 8.6 2.8-2.8" />
<path d="m5.7 26.3 2.9-2.9" /> <path d="m5.7 26.3 2.9-2.9" />
<path d="m5.8 5.8 2.8 2.8" /> <path d="m5.8 5.8 2.8 2.8" />
<path d="m23.4 23.4 2.9 2.9" /> <path d="m23.4 23.4 2.9 2.9" />
</motion.g> </motion.g>
</g> </g>
</svg> </svg>
</button> </button>
); );
}; };
// /////////////////////////////////////////////////////////////////////////// // ///////////////////////////////////////////////////////////////////////////
// Backwards compatible export (alias for ThemeToggleButton with default settings) // Backwards compatible export (alias for ThemeToggleButton with default settings)
export function ThemeTogglerComponent() { export function ThemeTogglerComponent() {
return ( return <ThemeToggleButton variant="circle" start="top-right" className="size-8" />;
<ThemeToggleButton
variant="circle"
start="top-right"
className="size-8"
/>
);
} }
/** /**

View file

@ -10,6 +10,7 @@ export const inboxItemTypeEnum = z.enum([
"connector_deletion", "connector_deletion",
"document_processing", "document_processing",
"new_mention", "new_mention",
"comment_reply",
"page_limit_exceeded", "page_limit_exceeded",
]); ]);
@ -101,6 +102,19 @@ export const newMentionMetadata = z.object({
content_preview: z.string(), 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 * Page limit exceeded metadata schema
*/ */
@ -125,6 +139,7 @@ export const inboxItemMetadata = z.union([
connectorDeletionMetadata, connectorDeletionMetadata,
documentProcessingMetadata, documentProcessingMetadata,
newMentionMetadata, newMentionMetadata,
commentReplyMetadata,
pageLimitExceededMetadata, pageLimitExceededMetadata,
baseInboxItemMetadata, baseInboxItemMetadata,
]); ]);
@ -168,6 +183,11 @@ export const newMentionInboxItem = inboxItem.extend({
metadata: newMentionMetadata, metadata: newMentionMetadata,
}); });
export const commentReplyInboxItem = inboxItem.extend({
type: z.literal("comment_reply"),
metadata: commentReplyMetadata,
});
export const pageLimitExceededInboxItem = inboxItem.extend({ export const pageLimitExceededInboxItem = inboxItem.extend({
type: z.literal("page_limit_exceeded"), type: z.literal("page_limit_exceeded"),
metadata: pageLimitExceededMetadata, metadata: pageLimitExceededMetadata,
@ -278,6 +298,10 @@ export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionM
return newMentionMetadata.safeParse(metadata).success; return newMentionMetadata.safeParse(metadata).success;
} }
export function isCommentReplyMetadata(metadata: unknown): metadata is CommentReplyMetadata {
return commentReplyMetadata.safeParse(metadata).success;
}
/** /**
* Type guard for PageLimitExceededMetadata * Type guard for PageLimitExceededMetadata
*/ */
@ -298,6 +322,7 @@ export function parseInboxItemMetadata(
| ConnectorDeletionMetadata | ConnectorDeletionMetadata
| DocumentProcessingMetadata | DocumentProcessingMetadata
| NewMentionMetadata | NewMentionMetadata
| CommentReplyMetadata
| PageLimitExceededMetadata | PageLimitExceededMetadata
| null { | null {
switch (type) { switch (type) {
@ -317,6 +342,10 @@ export function parseInboxItemMetadata(
const result = newMentionMetadata.safeParse(metadata); const result = newMentionMetadata.safeParse(metadata);
return result.success ? result.data : null; return result.success ? result.data : null;
} }
case "comment_reply": {
const result = commentReplyMetadata.safeParse(metadata);
return result.success ? result.data : null;
}
case "page_limit_exceeded": { case "page_limit_exceeded": {
const result = pageLimitExceededMetadata.safeParse(metadata); const result = pageLimitExceededMetadata.safeParse(metadata);
return result.success ? result.data : null; return result.success ? result.data : null;
@ -338,6 +367,7 @@ export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata
export type ConnectorDeletionMetadata = z.infer<typeof connectorDeletionMetadata>; export type ConnectorDeletionMetadata = z.infer<typeof connectorDeletionMetadata>;
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>; export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>; export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
export type CommentReplyMetadata = z.infer<typeof commentReplyMetadata>;
export type PageLimitExceededMetadata = z.infer<typeof pageLimitExceededMetadata>; export type PageLimitExceededMetadata = z.infer<typeof pageLimitExceededMetadata>;
export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>; export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
export type InboxItem = z.infer<typeof inboxItem>; export type InboxItem = z.infer<typeof inboxItem>;
@ -345,6 +375,7 @@ export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxIt
export type ConnectorDeletionInboxItem = z.infer<typeof connectorDeletionInboxItem>; export type ConnectorDeletionInboxItem = z.infer<typeof connectorDeletionInboxItem>;
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>; export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>; export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
export type CommentReplyInboxItem = z.infer<typeof commentReplyInboxItem>;
export type PageLimitExceededInboxItem = z.infer<typeof pageLimitExceededInboxItem>; export type PageLimitExceededInboxItem = z.infer<typeof pageLimitExceededInboxItem>;
// API Request/Response types // API Request/Response types

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getBearerToken } from "@/lib/auth-utils"; import { getBearerToken } from "@/lib/auth-utils";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
interface UseApiKeyReturn { interface UseApiKeyReturn {
apiKey: string | null; apiKey: string | null;
@ -33,60 +34,17 @@ export function useApiKey(): UseApiKeyReturn {
return () => clearTimeout(timer); 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 () => { const copyToClipboard = useCallback(async () => {
if (!apiKey) return; if (!apiKey) return;
try { const success = await copyToClipboardUtil(apiKey);
if (navigator.clipboard && window.isSecureContext) { if (success) {
// Use Clipboard API if available and in secure context setCopied(true);
await navigator.clipboard.writeText(apiKey); toast.success("API key copied to clipboard");
setCopied(true); setTimeout(() => {
toast.success("API key copied to clipboard"); setCopied(false);
}, 2000);
setTimeout(() => { } else {
setCopied(false);
}, 2000);
} else {
// Fallback for non-secure contexts or browsers without clipboard API
fallbackCopyTextToClipboard(apiKey);
}
} catch (err) {
console.error("Failed to copy:", err);
toast.error("Failed to copy API key"); toast.error("Failed to copy API key");
} }
}, [apiKey]); }, [apiKey]);

View file

@ -12,3 +12,44 @@ export const formatDate = (date: Date): string => {
day: "numeric", 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<boolean> {
// 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;
}
}

View file

@ -705,10 +705,13 @@
"mark_all_read": "Mark all as read", "mark_all_read": "Mark all as read",
"mark_as_read": "Mark as read", "mark_as_read": "Mark as read",
"mentions": "Mentions", "mentions": "Mentions",
"comments": "Comments",
"status": "Status", "status": "Status",
"no_results_found": "No results found", "no_results_found": "No results found",
"no_mentions": "No mentions", "no_mentions": "No mentions",
"no_mentions_hint": "You'll see mentions from others here", "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": "No status updates",
"no_status_updates_hint": "Document and connector updates will appear here", "no_status_updates_hint": "Document and connector updates will appear here",
"filter": "Filter", "filter": "Filter",

View file

@ -690,10 +690,13 @@
"mark_all_read": "全部标记为已读", "mark_all_read": "全部标记为已读",
"mark_as_read": "标记为已读", "mark_as_read": "标记为已读",
"mentions": "提及", "mentions": "提及",
"comments": "评论",
"status": "状态", "status": "状态",
"no_results_found": "未找到结果", "no_results_found": "未找到结果",
"no_mentions": "没有提及", "no_mentions": "没有提及",
"no_mentions_hint": "您会在这里看到他人的提及", "no_mentions_hint": "您会在这里看到他人的提及",
"no_comments": "没有评论",
"no_comments_hint": "您会在这里看到提及和回复",
"no_status_updates": "没有状态更新", "no_status_updates": "没有状态更新",
"no_status_updates_hint": "文档和连接器更新将显示在这里", "no_status_updates_hint": "文档和连接器更新将显示在这里",
"filter": "筛选", "filter": "筛选",