mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-20 21:18:13 +02:00
feat: implement inbox sidebar for enhanced user notifications
- Introduced a new InboxSidebar component to manage and display inbox items. - Integrated real-time syncing of inbox items using Electric SQL for instant updates. - Added functionality to mark items as read, archive/unarchive, and filter inbox content. - Updated existing components to accommodate the new inbox feature, including layout adjustments and state management. - Enhanced user experience with improved navigation and interaction for inbox items.
This commit is contained in:
parent
8eec948434
commit
93aa1dcf3c
13 changed files with 860 additions and 441 deletions
|
|
@ -367,7 +367,7 @@ export default function NewChatPage() {
|
||||||
initializeThread();
|
initializeThread();
|
||||||
}, [initializeThread]);
|
}, [initializeThread]);
|
||||||
|
|
||||||
// Handle scroll to comment from URL query params (e.g., from notification click)
|
// Handle scroll to comment from URL query params (e.g., from inbox item click)
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const targetCommentId = searchParams.get("commentId");
|
const targetCommentId = searchParams.get("commentId");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react";
|
import { Inbox, LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react";
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
||||||
import { cleanupElectric } from "@/lib/electric/client";
|
import { cleanupElectric } from "@/lib/electric/client";
|
||||||
|
|
@ -29,6 +30,7 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
||||||
import { LayoutShell } from "../ui/shell";
|
import { LayoutShell } from "../ui/shell";
|
||||||
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
||||||
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
||||||
|
import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
|
||||||
|
|
||||||
interface LayoutDataProviderProps {
|
interface LayoutDataProviderProps {
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
|
|
@ -59,8 +61,8 @@ export function LayoutDataProvider({
|
||||||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Fetch current search space
|
// Fetch current search space (for caching purposes)
|
||||||
const { data: searchSpace } = useQuery({
|
useQuery({
|
||||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
||||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!searchSpaceId,
|
||||||
|
|
@ -77,9 +79,20 @@ export function LayoutDataProvider({
|
||||||
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
|
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
|
||||||
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
|
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
// Inbox sidebar state
|
||||||
|
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
||||||
|
|
||||||
// Search space dialog state
|
// Search space dialog state
|
||||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Inbox hook
|
||||||
|
const userId = user?.id ? String(user.id) : null;
|
||||||
|
const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead, archiveItem } = useInbox(
|
||||||
|
userId,
|
||||||
|
Number(searchSpaceId) || null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
// Delete dialogs state
|
// Delete dialogs state
|
||||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
|
@ -149,14 +162,21 @@ export function LayoutDataProvider({
|
||||||
icon: SquareLibrary,
|
icon: SquareLibrary,
|
||||||
isActive: pathname?.includes("/documents"),
|
isActive: pathname?.includes("/documents"),
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// title: "Logs",
|
||||||
|
// url: `/dashboard/${searchSpaceId}/logs`,
|
||||||
|
// icon: Logs,
|
||||||
|
// isActive: pathname?.includes("/logs"),
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
title: "Logs",
|
title: "Inbox",
|
||||||
url: `/dashboard/${searchSpaceId}/logs`,
|
url: "#inbox", // Special URL to indicate this is handled differently
|
||||||
icon: Logs,
|
icon: Inbox,
|
||||||
isActive: pathname?.includes("/logs"),
|
isActive: isInboxSidebarOpen,
|
||||||
|
badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[searchSpaceId, pathname]
|
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
|
|
@ -248,6 +268,11 @@ export function LayoutDataProvider({
|
||||||
|
|
||||||
const handleNavItemClick = useCallback(
|
const handleNavItemClick = useCallback(
|
||||||
(item: NavItem) => {
|
(item: NavItem) => {
|
||||||
|
// Handle inbox specially - open sidebar instead of navigating
|
||||||
|
if (item.url === "#inbox") {
|
||||||
|
setIsInboxSidebarOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.push(item.url);
|
router.push(item.url);
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
|
|
@ -517,6 +542,18 @@ export function LayoutDataProvider({
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Inbox Sidebar */}
|
||||||
|
<InboxSidebar
|
||||||
|
open={isInboxSidebarOpen}
|
||||||
|
onOpenChange={setIsInboxSidebarOpen}
|
||||||
|
inboxItems={inboxItems}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
loading={inboxLoading}
|
||||||
|
markAsRead={markAsRead}
|
||||||
|
markAllAsRead={markAllAsRead}
|
||||||
|
archiveItem={archiveItem}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Create Search Space Dialog */}
|
{/* Create Search Space Dialog */}
|
||||||
<CreateSearchSpaceDialog
|
<CreateSearchSpaceDialog
|
||||||
open={isCreateSearchSpaceDialogOpen}
|
open={isCreateSearchSpaceDialogOpen}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useAtomValue } from "jotai";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||||
import { NotificationButton } from "@/components/notifications/NotificationButton";
|
|
||||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
|
|
@ -55,8 +54,6 @@ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
||||||
|
|
||||||
{/* Right side - Actions */}
|
{/* Right side - Actions */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Notifications */}
|
|
||||||
<NotificationButton />
|
|
||||||
{/* Share button - only show on chat pages when thread exists */}
|
{/* Share button - only show on chat pages when thread exists */}
|
||||||
{hasThread && (
|
{hasThread && (
|
||||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||||
|
|
|
||||||
662
surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
Normal file
662
surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
Normal file
|
|
@ -0,0 +1,662 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Archive,
|
||||||
|
AtSign,
|
||||||
|
BellDot,
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
CheckCircle2,
|
||||||
|
History,
|
||||||
|
Inbox,
|
||||||
|
ListFilter,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get initials from name or email for avatar fallback
|
||||||
|
*/
|
||||||
|
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
||||||
|
if (name) {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
const localPart = email.split("@")[0];
|
||||||
|
return localPart.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return "U";
|
||||||
|
}
|
||||||
|
|
||||||
|
type InboxTab = "mentions" | "status";
|
||||||
|
type InboxFilter = "all" | "unread" | "archived";
|
||||||
|
|
||||||
|
interface InboxSidebarProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
inboxItems: InboxItem[];
|
||||||
|
unreadCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
markAsRead: (id: number) => Promise<boolean>;
|
||||||
|
markAllAsRead: () => Promise<boolean>;
|
||||||
|
archiveItem: (id: number, archived: boolean) => Promise<boolean>;
|
||||||
|
onCloseMobileSidebar?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InboxSidebar({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
inboxItems,
|
||||||
|
unreadCount,
|
||||||
|
loading,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
archiveItem,
|
||||||
|
onCloseMobileSidebar,
|
||||||
|
}: InboxSidebarProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
|
||||||
|
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
// Unified dropdown state: "filter" | "options" | number (item id) | null
|
||||||
|
const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | number | null>(null);
|
||||||
|
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||||
|
const [archivingItemId, setArchivingItemId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && open) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Split items by type
|
||||||
|
const mentionItems = useMemo(
|
||||||
|
() => inboxItems.filter((item) => item.type === "new_mention"),
|
||||||
|
[inboxItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusItems = useMemo(
|
||||||
|
() =>
|
||||||
|
inboxItems.filter(
|
||||||
|
(item) => item.type === "connector_indexing" || item.type === "document_processing"
|
||||||
|
),
|
||||||
|
[inboxItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get items for current tab
|
||||||
|
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
|
||||||
|
|
||||||
|
// Filter items based on filter type and search query
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
let items = currentTabItems;
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
if (activeFilter === "all") {
|
||||||
|
// "Unread & read" shows all non-archived items
|
||||||
|
items = items.filter((item) => !(item as InboxItem & { archived?: boolean }).archived);
|
||||||
|
} else if (activeFilter === "unread") {
|
||||||
|
// "Unread" shows only unread non-archived items
|
||||||
|
items = items.filter((item) => !item.read && !(item as InboxItem & { archived?: boolean }).archived);
|
||||||
|
} else if (activeFilter === "archived") {
|
||||||
|
// "Archived" shows only archived items
|
||||||
|
items = items.filter((item) => (item as InboxItem & { archived?: boolean }).archived);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search query
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
items = items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.title.toLowerCase().includes(query) ||
|
||||||
|
item.message.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [currentTabItems, activeFilter, searchQuery]);
|
||||||
|
|
||||||
|
// Count unread items per tab
|
||||||
|
const unreadMentionsCount = useMemo(
|
||||||
|
() => mentionItems.filter((item) => !item.read).length,
|
||||||
|
[mentionItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unreadStatusCount = useMemo(
|
||||||
|
() => statusItems.filter((item) => !item.read).length,
|
||||||
|
[statusItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleItemClick = useCallback(
|
||||||
|
async (item: InboxItem) => {
|
||||||
|
if (!item.read) {
|
||||||
|
setMarkingAsReadId(item.id);
|
||||||
|
await markAsRead(item.id);
|
||||||
|
setMarkingAsReadId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "new_mention") {
|
||||||
|
const metadata = item.metadata as {
|
||||||
|
thread_id?: number;
|
||||||
|
comment_id?: number;
|
||||||
|
};
|
||||||
|
const searchSpaceId = item.search_space_id;
|
||||||
|
const threadId = metadata?.thread_id;
|
||||||
|
const commentId = metadata?.comment_id;
|
||||||
|
|
||||||
|
if (searchSpaceId && threadId) {
|
||||||
|
const url = commentId
|
||||||
|
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
||||||
|
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||||
|
onOpenChange(false);
|
||||||
|
onCloseMobileSidebar?.();
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[markAsRead, router, onOpenChange, onCloseMobileSidebar]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMarkAsRead = useCallback(
|
||||||
|
async (itemId: number) => {
|
||||||
|
setMarkingAsReadId(itemId);
|
||||||
|
await markAsRead(itemId);
|
||||||
|
setMarkingAsReadId(null);
|
||||||
|
},
|
||||||
|
[markAsRead]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMarkAllAsRead = useCallback(async () => {
|
||||||
|
await markAllAsRead();
|
||||||
|
}, [markAllAsRead]);
|
||||||
|
|
||||||
|
const handleToggleArchive = useCallback(
|
||||||
|
async (itemId: number, currentlyArchived: boolean) => {
|
||||||
|
setArchivingItemId(itemId);
|
||||||
|
await archiveItem(itemId, !currentlyArchived);
|
||||||
|
setArchivingItemId(null);
|
||||||
|
},
|
||||||
|
[archiveItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffMins < 1) return "now";
|
||||||
|
if (diffMins < 60) return `${diffMins}m`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d`;
|
||||||
|
return `${Math.floor(diffDays / 7)}w`;
|
||||||
|
} catch {
|
||||||
|
return "now";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (item: InboxItem) => {
|
||||||
|
// For mentions, show the author's avatar with initials fallback
|
||||||
|
if (item.type === "new_mention") {
|
||||||
|
const metadata = item.metadata as {
|
||||||
|
author_name?: string;
|
||||||
|
author_avatar_url?: string | null;
|
||||||
|
author_email?: string;
|
||||||
|
};
|
||||||
|
const authorName = metadata?.author_name;
|
||||||
|
const avatarUrl = metadata?.author_avatar_url;
|
||||||
|
const authorEmail = metadata?.author_email;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
|
||||||
|
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||||
|
{getInitials(authorName, authorEmail)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For status items (connector/document), show status icons
|
||||||
|
const status = item.metadata?.status as string | undefined;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "in_progress":
|
||||||
|
return (
|
||||||
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
|
||||||
|
<Loader2 className="h-4 w-4 text-foreground animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "completed":
|
||||||
|
return (
|
||||||
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-green-500/10">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "failed":
|
||||||
|
return (
|
||||||
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-red-500/10">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
|
||||||
|
<History className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmptyStateMessage = () => {
|
||||||
|
if (activeTab === "mentions") {
|
||||||
|
return {
|
||||||
|
title: t("no_mentions") || "No mentions",
|
||||||
|
hint: t("no_mentions_hint") || "You'll see mentions from others here",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: t("no_status_updates") || "No status updates",
|
||||||
|
hint: t("no_status_updates_hint") || "Document and connector updates will appear here",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 z-70 bg-black/50"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "-100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "-100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
|
className="fixed inset-y-0 left-0 z-70 w-96 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("inbox") || "Inbox"}
|
||||||
|
>
|
||||||
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Inbox className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<DropdownMenu
|
||||||
|
open={openDropdown === "filter"}
|
||||||
|
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
>
|
||||||
|
<ListFilter className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44 z-80">
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
||||||
|
{t("filter") || "Filter"}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setActiveFilter("all")}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Inbox className="h-4 w-4" />
|
||||||
|
<span>{t("unread_and_read") || "Unread & read"}</span>
|
||||||
|
</span>
|
||||||
|
{activeFilter === "all" && <Check className="h-4 w-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setActiveFilter("unread")}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<BellDot className="h-4 w-4" />
|
||||||
|
<span>{t("unread") || "Unread"}</span>
|
||||||
|
</span>
|
||||||
|
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setActiveFilter("archived")}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
<span>{t("archived") || "Archived"}</span>
|
||||||
|
</span>
|
||||||
|
{activeFilter === "archived" && <Check className="h-4 w-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DropdownMenu
|
||||||
|
open={openDropdown === "options"}
|
||||||
|
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "options" : null)}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleMarkAllAsRead}
|
||||||
|
disabled={unreadCount === 0}
|
||||||
|
>
|
||||||
|
<CheckCheck className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t("mark_all_read") || "Mark all as read"}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("search_inbox") || "Search inbox"}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 pr-8 h-9"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => setActiveTab(value as InboxTab)}
|
||||||
|
className="shrink-0 mx-4"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||||
|
<TabsTrigger
|
||||||
|
value="mentions"
|
||||||
|
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">
|
||||||
|
<AtSign className="h-4 w-4" />
|
||||||
|
<span>{t("mentions") || "Mentions"}</span>
|
||||||
|
{unreadMentionsCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-primary text-xs font-medium">
|
||||||
|
{unreadMentionsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="status"
|
||||||
|
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">
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
<span>{t("status") || "Status"}</span>
|
||||||
|
{unreadStatusCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-primary text-xs font-medium">
|
||||||
|
{unreadStatusCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : filteredItems.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const isMarkingAsRead = markingAsReadId === item.id;
|
||||||
|
const isArchiving = archivingItemId === item.id;
|
||||||
|
const isBusy = isMarkingAsRead || isArchiving;
|
||||||
|
const isArchived = (item as InboxItem & { archived?: boolean }).archived;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-3 rounded-lg px-3 py-5 text-sm",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"transition-colors cursor-pointer",
|
||||||
|
isBusy && "opacity-50 pointer-events-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="flex items-start gap-3 flex-1 min-w-0 text-left overflow-hidden self-start"
|
||||||
|
>
|
||||||
|
<div className="shrink-0">{getStatusIcon(item)}</div>
|
||||||
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium line-clamp-2",
|
||||||
|
!item.read && "font-semibold"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
|
||||||
|
{convertRenderedToDisplay(item.message)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
|
||||||
|
<p className="font-medium">{item.title}</p>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{convertRenderedToDisplay(item.message)}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Time/dot and 3-dot button container - swap on hover */}
|
||||||
|
<div className="relative flex items-center shrink-0 w-12 justify-end">
|
||||||
|
{/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 transition-opacity duration-150",
|
||||||
|
"group-hover:opacity-0 group-hover:pointer-events-none",
|
||||||
|
openDropdown === item.id && "opacity-0 pointer-events-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{formatTime(item.created_at)}
|
||||||
|
</span>
|
||||||
|
{!item.read && (
|
||||||
|
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3-dot menu - hidden by default, visible on hover or when dropdown is open */}
|
||||||
|
<DropdownMenu
|
||||||
|
open={openDropdown === item.id}
|
||||||
|
onOpenChange={(isOpen) =>
|
||||||
|
setOpenDropdown(isOpen ? item.id : null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6 absolute right-0 transition-opacity duration-150",
|
||||||
|
"opacity-0 pointer-events-none",
|
||||||
|
"group-hover:opacity-100 group-hover:pointer-events-auto",
|
||||||
|
openDropdown === item.id && "!opacity-100 !pointer-events-auto"
|
||||||
|
)}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{isBusy ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("more_options") || "More options"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||||
|
{!item.read && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleMarkAsRead(item.id)}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<CheckCheck className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t("mark_as_read") || "Mark as read"}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleToggleArchive(item.id, !!isArchived)}
|
||||||
|
disabled={isArchiving}
|
||||||
|
>
|
||||||
|
{isArchived ? (
|
||||||
|
<>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t("unarchive") || "Restore"}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t("archive") || "Archive"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : searchQuery ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("no_results_found") || "No results found"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
{t("try_different_search") || "Try a different search term"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
{activeTab === "mentions" ? (
|
||||||
|
<AtSign 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" />
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{getEmptyStateMessage().title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
{getEmptyStateMessage().hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onItemClick?.(item)}
|
onClick={() => onItemClick?.(item)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
"relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
item.isActive && "bg-accent text-accent-foreground"
|
item.isActive && "bg-accent text-accent-foreground"
|
||||||
|
|
@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
{...joyrideAttr}
|
{...joyrideAttr}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.badge && (
|
||||||
|
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="sr-only">{item.title}</span>
|
<span className="sr-only">{item.title}</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
<span className="flex-1 truncate">{item.title}</span>
|
<span className="flex-1 truncate">{item.title}</span>
|
||||||
{item.badge && <span className="text-xs text-muted-foreground">{item.badge}</span>}
|
{item.badge && (
|
||||||
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-red-500 text-white text-xs font-medium">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
||||||
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
||||||
export { ChatListItem } from "./ChatListItem";
|
export { ChatListItem } from "./ChatListItem";
|
||||||
|
export { InboxSidebar } from "./InboxSidebar";
|
||||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||||
export { NavSection } from "./NavSection";
|
export { NavSection } from "./NavSection";
|
||||||
export { PageUsageDisplay } from "./PageUsageDisplay";
|
export { PageUsageDisplay } from "./PageUsageDisplay";
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { Bell } from "lucide-react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { useNotifications, type NotificationTypeEnum } from "@/hooks/use-notifications";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { NotificationPopup } from "./NotificationPopup";
|
|
||||||
|
|
||||||
const NOTIFICATION_FILTER_STORAGE_KEY = "surfsense_notification_filter";
|
|
||||||
|
|
||||||
export function NotificationButton() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { data: user } = useAtomValue(currentUserAtom);
|
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
// Filter state - null means show all, otherwise filter by type
|
|
||||||
const [activeFilter, setActiveFilter] = useState<NotificationTypeEnum | null>(null);
|
|
||||||
|
|
||||||
// Load filter from localStorage on mount
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(NOTIFICATION_FILTER_STORAGE_KEY);
|
|
||||||
if (stored) {
|
|
||||||
const parsed = JSON.parse(stored);
|
|
||||||
if (
|
|
||||||
parsed === null ||
|
|
||||||
["new_mention", "connector_indexing", "document_processing"].includes(parsed)
|
|
||||||
) {
|
|
||||||
setActiveFilter(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore localStorage errors
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle filter toggle - clicking same pill again shows all
|
|
||||||
const handleFilterChange = useCallback((filter: NotificationTypeEnum | null) => {
|
|
||||||
setActiveFilter((current) => {
|
|
||||||
const newFilter = current === filter ? null : filter;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(NOTIFICATION_FILTER_STORAGE_KEY, JSON.stringify(newFilter));
|
|
||||||
} catch {
|
|
||||||
// Ignore localStorage errors
|
|
||||||
}
|
|
||||||
return newFilter;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const userId = user?.id ? String(user.id) : null;
|
|
||||||
// Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/
|
|
||||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
|
||||||
|
|
||||||
const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications(
|
|
||||||
userId,
|
|
||||||
searchSpaceId,
|
|
||||||
activeFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="h-8 w-8 relative border-0">
|
|
||||||
<Bell className="h-4 w-4" />
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black text-[10px] font-medium text-white dark:bg-zinc-800 dark:text-zinc-50",
|
|
||||||
unreadCount > 9 && "px-1"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{unreadCount > 99 ? "99+" : unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="sr-only">Notifications</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Notifications</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<PopoverContent align="end" className="w-80 p-0">
|
|
||||||
<NotificationPopup
|
|
||||||
notifications={notifications}
|
|
||||||
unreadCount={unreadCount}
|
|
||||||
loading={loading}
|
|
||||||
markAsRead={markAsRead}
|
|
||||||
markAllAsRead={markAllAsRead}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
activeFilter={activeFilter}
|
|
||||||
onFilterChange={handleFilterChange}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
AtSign,
|
|
||||||
Bell,
|
|
||||||
Cable,
|
|
||||||
CheckCheck,
|
|
||||||
CheckCircle2,
|
|
||||||
FileText,
|
|
||||||
Loader2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import type { Notification, NotificationTypeEnum } from "@/hooks/use-notifications";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter configuration for notification types
|
|
||||||
*/
|
|
||||||
const NOTIFICATION_FILTERS = {
|
|
||||||
new_mention: { label: "Mentions", icon: AtSign },
|
|
||||||
connector_indexing: { label: "Connectors", icon: Cable },
|
|
||||||
document_processing: { label: "Documents", icon: FileText },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get initials from name or email for avatar fallback
|
|
||||||
*/
|
|
||||||
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
|
||||||
if (name) {
|
|
||||||
return name
|
|
||||||
.split(" ")
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join("")
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2);
|
|
||||||
}
|
|
||||||
if (email) {
|
|
||||||
const localPart = email.split("@")[0];
|
|
||||||
return localPart.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
return "U";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationPopupProps {
|
|
||||||
notifications: Notification[];
|
|
||||||
unreadCount: number;
|
|
||||||
loading: boolean;
|
|
||||||
markAsRead: (id: number) => Promise<boolean>;
|
|
||||||
markAllAsRead: () => Promise<boolean>;
|
|
||||||
onClose?: () => void;
|
|
||||||
activeFilter: NotificationTypeEnum | null;
|
|
||||||
onFilterChange: (filter: NotificationTypeEnum | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationPopup({
|
|
||||||
notifications,
|
|
||||||
unreadCount,
|
|
||||||
loading,
|
|
||||||
markAsRead,
|
|
||||||
markAllAsRead,
|
|
||||||
onClose,
|
|
||||||
activeFilter,
|
|
||||||
onFilterChange,
|
|
||||||
}: NotificationPopupProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleMarkAllAsRead = async () => {
|
|
||||||
await markAllAsRead();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNotificationClick = async (notification: Notification) => {
|
|
||||||
if (!notification.read) {
|
|
||||||
await markAsRead(notification.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.type === "new_mention") {
|
|
||||||
const metadata = notification.metadata as {
|
|
||||||
thread_id?: number;
|
|
||||||
comment_id?: number;
|
|
||||||
};
|
|
||||||
const searchSpaceId = notification.search_space_id;
|
|
||||||
const threadId = metadata?.thread_id;
|
|
||||||
const commentId = metadata?.comment_id;
|
|
||||||
|
|
||||||
if (searchSpaceId && threadId) {
|
|
||||||
const url = commentId
|
|
||||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
|
||||||
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
|
||||||
onClose?.();
|
|
||||||
router.push(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateString: string) => {
|
|
||||||
try {
|
|
||||||
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
|
|
||||||
} catch {
|
|
||||||
return "Recently";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusIcon = (notification: Notification) => {
|
|
||||||
// For mentions, show the author's avatar with initials fallback
|
|
||||||
if (notification.type === "new_mention") {
|
|
||||||
const metadata = notification.metadata as {
|
|
||||||
author_name?: string;
|
|
||||||
author_avatar_url?: string | null;
|
|
||||||
author_email?: string;
|
|
||||||
};
|
|
||||||
const authorName = metadata?.author_name;
|
|
||||||
const avatarUrl = metadata?.author_avatar_url;
|
|
||||||
const authorEmail = metadata?.author_email;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Avatar className="h-6 w-6">
|
|
||||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
|
|
||||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
|
||||||
{getInitials(authorName, authorEmail)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other notification types, show status icons
|
|
||||||
const status = notification.metadata?.status as string | undefined;
|
|
||||||
|
|
||||||
switch (status) {
|
|
||||||
case "in_progress":
|
|
||||||
return <Loader2 className="h-4 w-4 text-foreground animate-spin" />;
|
|
||||||
case "completed":
|
|
||||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
|
||||||
case "failed":
|
|
||||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
|
||||||
default:
|
|
||||||
return <Bell className="h-4 w-4 text-muted-foreground" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-80 max-w-[calc(100vw-2rem)]">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm">Notifications</h3>
|
|
||||||
</div>
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={handleMarkAllAsRead} className="h-7 text-xs">
|
|
||||||
<CheckCheck className="h-3.5 w-3.5 mr-0" />
|
|
||||||
Mark all read
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter Pills */}
|
|
||||||
<div className="flex items-center gap-1.5 px-4 py-2 overflow-x-auto">
|
|
||||||
{(
|
|
||||||
Object.entries(NOTIFICATION_FILTERS) as [
|
|
||||||
NotificationTypeEnum,
|
|
||||||
(typeof NOTIFICATION_FILTERS)[keyof typeof NOTIFICATION_FILTERS],
|
|
||||||
][]
|
|
||||||
).map(([key, { label, icon: Icon }]) => {
|
|
||||||
const isActive = activeFilter === key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onFilterChange(key)}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-medium transition-colors whitespace-nowrap",
|
|
||||||
"border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
||||||
isActive
|
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
|
||||||
: "bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-3 w-3" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notifications List */}
|
|
||||||
<ScrollArea className="h-[400px]">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-foreground" />
|
|
||||||
</div>
|
|
||||||
) : notifications.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
|
|
||||||
<Bell className="h-8 w-8 text-muted-foreground mb-2" />
|
|
||||||
<p className="text-sm text-muted-foreground">No notifications</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="pt-0 pb-2">
|
|
||||||
{notifications.map((notification, index) => (
|
|
||||||
<div key={notification.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleNotificationClick(notification)}
|
|
||||||
className={cn(
|
|
||||||
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
|
|
||||||
!notification.read && "bg-accent/50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3 overflow-hidden">
|
|
||||||
<div className="flex-shrink-0 mt-0.5">{getStatusIcon(notification)}</div>
|
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
|
||||||
<div className="flex items-start justify-between gap-2 mb-1">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-xs font-medium break-all",
|
|
||||||
!notification.read && "font-semibold"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{notification.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
|
|
||||||
{convertRenderedToDisplay(notification.message)}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-between mt-2">
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
{formatTime(notification.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{index < notifications.length - 1 && <Separator />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -5,11 +5,11 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS
|
||||||
|
|
||||||
# Electric SQL
|
# Electric SQL
|
||||||
|
|
||||||
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for notifications, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
|
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
|
||||||
|
|
||||||
## What Does Electric SQL Do?
|
## What Does Electric SQL Do?
|
||||||
|
|
||||||
When you index documents or receive notifications, Electric SQL pushes updates to your browser in real-time. The data flows like this:
|
When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this:
|
||||||
|
|
||||||
1. Backend writes data to PostgreSQL
|
1. Backend writes data to PostgreSQL
|
||||||
2. Electric SQL detects changes and streams them to the frontend
|
2. Electric SQL detects changes and streams them to the frontend
|
||||||
|
|
@ -18,7 +18,7 @@ When you index documents or receive notifications, Electric SQL pushes updates t
|
||||||
|
|
||||||
This means:
|
This means:
|
||||||
|
|
||||||
- **Notifications appear instantly** - No need to refresh the page
|
- **Inbox updates appear instantly** - No need to refresh the page
|
||||||
- **Document indexing progress updates live** - Watch your documents get processed
|
- **Document indexing progress updates live** - Watch your documents get processed
|
||||||
- **Connector status syncs automatically** - See when connectors finish syncing
|
- **Connector status syncs automatically** - See when connectors finish syncing
|
||||||
- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible
|
- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,18 @@ import { searchSourceConnectorTypeEnum } from "./connector.types";
|
||||||
import { documentTypeEnum } from "./document.types";
|
import { documentTypeEnum } from "./document.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification type enum - matches backend notification types
|
* Inbox item type enum - matches backend notification types
|
||||||
*/
|
*/
|
||||||
export const notificationTypeEnum = z.enum([
|
export const inboxItemTypeEnum = z.enum([
|
||||||
"connector_indexing",
|
"connector_indexing",
|
||||||
"document_processing",
|
"document_processing",
|
||||||
"new_mention",
|
"new_mention",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification status enum - used in metadata
|
* Inbox item status enum - used in metadata
|
||||||
*/
|
*/
|
||||||
export const notificationStatusEnum = z.enum(["in_progress", "completed", "failed"]);
|
export const inboxItemStatusEnum = z.enum(["in_progress", "completed", "failed"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document processing stage enum
|
* Document processing stage enum
|
||||||
|
|
@ -30,11 +30,11 @@ export const documentProcessingStageEnum = z.enum([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base metadata schema shared across notification types
|
* Base metadata schema shared across inbox item types
|
||||||
*/
|
*/
|
||||||
export const baseNotificationMetadata = z.object({
|
export const baseInboxItemMetadata = z.object({
|
||||||
operation_id: z.string().optional(),
|
operation_id: z.string().optional(),
|
||||||
status: notificationStatusEnum.optional(),
|
status: inboxItemStatusEnum.optional(),
|
||||||
started_at: z.string().optional(),
|
started_at: z.string().optional(),
|
||||||
completed_at: z.string().optional(),
|
completed_at: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
@ -42,7 +42,7 @@ export const baseNotificationMetadata = z.object({
|
||||||
/**
|
/**
|
||||||
* Connector indexing metadata schema
|
* Connector indexing metadata schema
|
||||||
*/
|
*/
|
||||||
export const connectorIndexingMetadata = baseNotificationMetadata.extend({
|
export const connectorIndexingMetadata = baseInboxItemMetadata.extend({
|
||||||
connector_id: z.number(),
|
connector_id: z.number(),
|
||||||
connector_name: z.string(),
|
connector_name: z.string(),
|
||||||
connector_type: searchSourceConnectorTypeEnum,
|
connector_type: searchSourceConnectorTypeEnum,
|
||||||
|
|
@ -62,7 +62,7 @@ export const connectorIndexingMetadata = baseNotificationMetadata.extend({
|
||||||
/**
|
/**
|
||||||
* Document processing metadata schema
|
* Document processing metadata schema
|
||||||
*/
|
*/
|
||||||
export const documentProcessingMetadata = baseNotificationMetadata.extend({
|
export const documentProcessingMetadata = baseInboxItemMetadata.extend({
|
||||||
document_type: documentTypeEnum,
|
document_type: documentTypeEnum,
|
||||||
document_name: z.string(),
|
document_name: z.string(),
|
||||||
processing_stage: documentProcessingStageEnum,
|
processing_stage: documentProcessingStageEnum,
|
||||||
|
|
@ -89,24 +89,24 @@ export const newMentionMetadata = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union of all notification metadata types
|
* Union of all inbox item metadata types
|
||||||
* Use this when the notification type is unknown
|
* Use this when the inbox item type is unknown
|
||||||
*/
|
*/
|
||||||
export const notificationMetadata = z.union([
|
export const inboxItemMetadata = z.union([
|
||||||
connectorIndexingMetadata,
|
connectorIndexingMetadata,
|
||||||
documentProcessingMetadata,
|
documentProcessingMetadata,
|
||||||
newMentionMetadata,
|
newMentionMetadata,
|
||||||
baseNotificationMetadata,
|
baseInboxItemMetadata,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main notification schema
|
* Main inbox item schema
|
||||||
*/
|
*/
|
||||||
export const notification = z.object({
|
export const inboxItem = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
user_id: z.string(),
|
user_id: z.string(),
|
||||||
search_space_id: z.number().nullable(),
|
search_space_id: z.number().nullable(),
|
||||||
type: notificationTypeEnum,
|
type: inboxItemTypeEnum,
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
read: z.boolean(),
|
read: z.boolean(),
|
||||||
|
|
@ -116,33 +116,34 @@ export const notification = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Typed notification schemas for specific notification types
|
* Typed inbox item schemas for specific types
|
||||||
*/
|
*/
|
||||||
export const connectorIndexingNotification = notification.extend({
|
export const connectorIndexingInboxItem = inboxItem.extend({
|
||||||
type: z.literal("connector_indexing"),
|
type: z.literal("connector_indexing"),
|
||||||
metadata: connectorIndexingMetadata,
|
metadata: connectorIndexingMetadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const documentProcessingNotification = notification.extend({
|
export const documentProcessingInboxItem = inboxItem.extend({
|
||||||
type: z.literal("document_processing"),
|
type: z.literal("document_processing"),
|
||||||
metadata: documentProcessingMetadata,
|
metadata: documentProcessingMetadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const newMentionNotification = notification.extend({
|
export const newMentionInboxItem = inboxItem.extend({
|
||||||
type: z.literal("new_mention"),
|
type: z.literal("new_mention"),
|
||||||
metadata: newMentionMetadata,
|
metadata: newMentionMetadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inferred types
|
// Inferred types
|
||||||
export type NotificationTypeEnum = z.infer<typeof notificationTypeEnum>;
|
export type InboxItemTypeEnum = z.infer<typeof inboxItemTypeEnum>;
|
||||||
export type NotificationStatusEnum = z.infer<typeof notificationStatusEnum>;
|
export type InboxItemStatusEnum = z.infer<typeof inboxItemStatusEnum>;
|
||||||
export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>;
|
export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>;
|
||||||
export type BaseNotificationMetadata = z.infer<typeof baseNotificationMetadata>;
|
export type BaseInboxItemMetadata = z.infer<typeof baseInboxItemMetadata>;
|
||||||
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
|
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
|
||||||
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 NotificationMetadata = z.infer<typeof notificationMetadata>;
|
export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
|
||||||
export type Notification = z.infer<typeof notification>;
|
export type InboxItem = z.infer<typeof inboxItem>;
|
||||||
export type ConnectorIndexingNotification = z.infer<typeof connectorIndexingNotification>;
|
export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxItem>;
|
||||||
export type DocumentProcessingNotification = z.infer<typeof documentProcessingNotification>;
|
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
|
||||||
export type NewMentionNotification = z.infer<typeof newMentionNotification>;
|
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
|
||||||
|
|
||||||
|
|
@ -1,38 +1,38 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
|
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
import type { SyncHandle } from "@/lib/electric/client";
|
import type { SyncHandle } from "@/lib/electric/client";
|
||||||
import { useElectricClient } from "@/lib/electric/context";
|
import { useElectricClient } from "@/lib/electric/context";
|
||||||
|
|
||||||
export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
|
export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing notifications with Electric SQL real-time sync
|
* Hook for managing inbox items with Electric SQL real-time sync
|
||||||
*
|
*
|
||||||
* Uses the Electric client from context (provided by ElectricProvider)
|
* Uses the Electric client from context (provided by ElectricProvider)
|
||||||
* instead of initializing its own - prevents race conditions and memory leaks
|
* instead of initializing its own - prevents race conditions and memory leaks
|
||||||
*
|
*
|
||||||
* Architecture:
|
* Architecture:
|
||||||
* - User-level sync: Syncs ALL notifications for a user (runs once per user)
|
* - User-level sync: Syncs ALL inbox items for a user (runs once per user)
|
||||||
* - Search-space-level query: Filters notifications by searchSpaceId (updates on search space change)
|
* - Search-space-level query: Filters inbox items by searchSpaceId (updates on search space change)
|
||||||
*
|
*
|
||||||
* This separation ensures smooth transitions when switching search spaces (no flash).
|
* This separation ensures smooth transitions when switching search spaces (no flash).
|
||||||
*
|
*
|
||||||
* @param userId - The user ID to fetch notifications for
|
* @param userId - The user ID to fetch inbox items for
|
||||||
* @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only)
|
* @param searchSpaceId - The search space ID to filter inbox items (null shows global items only)
|
||||||
* @param typeFilter - Optional notification type to filter by (null shows all types)
|
* @param typeFilter - Optional inbox item type to filter by (null shows all types)
|
||||||
*/
|
*/
|
||||||
export function useNotifications(
|
export function useInbox(
|
||||||
userId: string | null,
|
userId: string | null,
|
||||||
searchSpaceId: number | null,
|
searchSpaceId: number | null,
|
||||||
typeFilter: NotificationTypeEnum | null = null
|
typeFilter: InboxItemTypeEnum | null = null
|
||||||
) {
|
) {
|
||||||
// Get Electric client from context - ElectricProvider handles initialization
|
// Get Electric client from context - ElectricProvider handles initialization
|
||||||
const electricClient = useElectricClient();
|
const electricClient = useElectricClient();
|
||||||
|
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||||
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
|
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
@ -43,34 +43,37 @@ export function useNotifications(
|
||||||
// Track user-level sync key to prevent duplicate sync subscriptions
|
// Track user-level sync key to prevent duplicate sync subscriptions
|
||||||
const userSyncKeyRef = useRef<string | null>(null);
|
const userSyncKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// EFFECT 1: User-level sync - runs once per user, syncs ALL notifications
|
// EFFECT 1: User-level sync - runs once per user, syncs ALL inbox items
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || !electricClient) {
|
if (!userId || !electricClient) {
|
||||||
setLoading(!electricClient);
|
setLoading(!electricClient);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSyncKey = `notifications_${userId}`;
|
const userSyncKey = `inbox_${userId}`;
|
||||||
if (userSyncKeyRef.current === userSyncKey) {
|
if (userSyncKeyRef.current === userSyncKey) {
|
||||||
// Already syncing for this user
|
// Already syncing for this user
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture electricClient to satisfy TypeScript in async function
|
||||||
|
const client = electricClient;
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
userSyncKeyRef.current = userSyncKey;
|
userSyncKeyRef.current = userSyncKey;
|
||||||
|
|
||||||
async function startUserSync() {
|
async function startUserSync() {
|
||||||
try {
|
try {
|
||||||
console.log("[useNotifications] Starting user-level sync for:", userId);
|
console.log("[useInbox] Starting user-level sync for:", userId);
|
||||||
|
|
||||||
// Sync ALL notifications for this user (cached via syncShape caching)
|
// Sync ALL inbox items for this user (cached via syncShape caching)
|
||||||
const handle = await electricClient.syncShape({
|
// Note: Backend table is still named "notifications"
|
||||||
|
const handle = await client.syncShape({
|
||||||
table: "notifications",
|
table: "notifications",
|
||||||
where: `user_id = '${userId}'`,
|
where: `user_id = '${userId}'`,
|
||||||
primaryKey: ["id"],
|
primaryKey: ["id"],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[useNotifications] User sync started:", {
|
console.log("[useInbox] User sync started:", {
|
||||||
isUpToDate: handle.isUpToDate,
|
isUpToDate: handle.isUpToDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -82,7 +85,7 @@ export function useNotifications(
|
||||||
new Promise((resolve) => setTimeout(resolve, 2000)),
|
new Promise((resolve) => setTimeout(resolve, 2000)),
|
||||||
]);
|
]);
|
||||||
} catch (syncErr) {
|
} catch (syncErr) {
|
||||||
console.error("[useNotifications] Initial sync failed:", syncErr);
|
console.error("[useInbox] Initial sync failed:", syncErr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,8 +99,8 @@ export function useNotifications(
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
console.error("[useNotifications] Failed to start user sync:", err);
|
console.error("[useInbox] Failed to start user sync:", err);
|
||||||
setError(err instanceof Error ? err : new Error("Failed to sync notifications"));
|
setError(err instanceof Error ? err : new Error("Failed to sync inbox"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -122,10 +125,12 @@ export function useNotifications(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture electricClient to satisfy TypeScript in async function
|
||||||
|
const client = electricClient;
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
async function updateQuery() {
|
async function updateQuery() {
|
||||||
// Clean up previous live query (but DON'T clear notifications - keep showing old until new arrive)
|
// Clean up previous live query (but DON'T clear inbox items - keep showing old until new arrive)
|
||||||
if (liveQueryRef.current) {
|
if (liveQueryRef.current) {
|
||||||
liveQueryRef.current.unsubscribe();
|
liveQueryRef.current.unsubscribe();
|
||||||
liveQueryRef.current = null;
|
liveQueryRef.current = null;
|
||||||
|
|
@ -133,13 +138,14 @@ export function useNotifications(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log(
|
||||||
"[useNotifications] Updating query for searchSpace:",
|
"[useInbox] Updating query for searchSpace:",
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
"typeFilter:",
|
"typeFilter:",
|
||||||
typeFilter
|
typeFilter
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build query with optional type filter
|
// Build query with optional type filter
|
||||||
|
// Note: Backend table is still named "notifications"
|
||||||
const baseQuery = `SELECT * FROM notifications
|
const baseQuery = `SELECT * FROM notifications
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND (search_space_id = $2 OR search_space_id IS NULL)`;
|
AND (search_space_id = $2 OR search_space_id IS NULL)`;
|
||||||
|
|
@ -148,16 +154,15 @@ export function useNotifications(
|
||||||
const fullQuery = baseQuery + typeClause + orderClause;
|
const fullQuery = baseQuery + typeClause + orderClause;
|
||||||
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
|
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
|
||||||
|
|
||||||
// Fetch notifications for current search space immediately
|
// Fetch inbox items for current search space immediately
|
||||||
const result = await electricClient.db.query<Notification>(fullQuery, params);
|
const result = await client.db.query<InboxItem>(fullQuery, params);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setNotifications(result.rows || []);
|
setInboxItems(result.rows || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up live query for real-time updates
|
// Set up live query for real-time updates
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const db = client.db as any;
|
||||||
const db = electricClient.db as any;
|
|
||||||
|
|
||||||
if (db.live?.query && typeof db.live.query === "function") {
|
if (db.live?.query && typeof db.live.query === "function") {
|
||||||
const liveQuery = await db.live.query(fullQuery, params);
|
const liveQuery = await db.live.query(fullQuery, params);
|
||||||
|
|
@ -169,16 +174,16 @@ export function useNotifications(
|
||||||
|
|
||||||
// Set initial results from live query
|
// Set initial results from live query
|
||||||
if (liveQuery.initialResults?.rows) {
|
if (liveQuery.initialResults?.rows) {
|
||||||
setNotifications(liveQuery.initialResults.rows);
|
setInboxItems(liveQuery.initialResults.rows);
|
||||||
} else if (liveQuery.rows) {
|
} else if (liveQuery.rows) {
|
||||||
setNotifications(liveQuery.rows);
|
setInboxItems(liveQuery.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to changes
|
// Subscribe to changes
|
||||||
if (typeof liveQuery.subscribe === "function") {
|
if (typeof liveQuery.subscribe === "function") {
|
||||||
liveQuery.subscribe((result: { rows: Notification[] }) => {
|
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
|
||||||
if (mounted && result.rows) {
|
if (mounted && result.rows) {
|
||||||
setNotifications(result.rows);
|
setInboxItems(result.rows);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +193,7 @@ export function useNotifications(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[useNotifications] Failed to update query:", err);
|
console.error("[useInbox] Failed to update query:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,6 +215,8 @@ export function useNotifications(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture electricClient to satisfy TypeScript in async function
|
||||||
|
const client = electricClient;
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
async function updateUnreadCount() {
|
async function updateUnreadCount() {
|
||||||
|
|
@ -220,13 +227,14 @@ export function useNotifications(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Note: Backend table is still named "notifications"
|
||||||
const countQuery = `SELECT COUNT(*) as count FROM notifications
|
const countQuery = `SELECT COUNT(*) as count FROM notifications
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND (search_space_id = $2 OR search_space_id IS NULL)
|
AND (search_space_id = $2 OR search_space_id IS NULL)
|
||||||
AND read = false`;
|
AND read = false`;
|
||||||
|
|
||||||
// Fetch initial count
|
// Fetch initial count
|
||||||
const result = await electricClient.db.query<{ count: number }>(countQuery, [
|
const result = await client.db.query<{ count: number }>(countQuery, [
|
||||||
userId,
|
userId,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
]);
|
]);
|
||||||
|
|
@ -236,8 +244,7 @@ export function useNotifications(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up live query for real-time updates
|
// Set up live query for real-time updates
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const db = client.db as any;
|
||||||
const db = electricClient.db as any;
|
|
||||||
|
|
||||||
if (db.live?.query && typeof db.live.query === "function") {
|
if (db.live?.query && typeof db.live.query === "function") {
|
||||||
const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
|
const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
|
||||||
|
|
@ -268,7 +275,7 @@ export function useNotifications(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[useNotifications] Failed to update unread count:", err);
|
console.error("[useInbox] Failed to update unread count:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,29 +290,31 @@ export function useNotifications(
|
||||||
};
|
};
|
||||||
}, [userId, searchSpaceId, electricClient]);
|
}, [userId, searchSpaceId, electricClient]);
|
||||||
|
|
||||||
// Mark notification as read via backend API
|
// Mark inbox item as read via backend API
|
||||||
const markAsRead = useCallback(async (notificationId: number) => {
|
const markAsRead = useCallback(async (itemId: number) => {
|
||||||
try {
|
try {
|
||||||
|
// Note: Backend API endpoint is still /notifications/
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`,
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`,
|
||||||
{ method: "PATCH" }
|
{ method: "PATCH" }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ detail: "Failed to mark as read" }));
|
const error = await response.json().catch(() => ({ detail: "Failed to mark as read" }));
|
||||||
throw new Error(error.detail || "Failed to mark notification as read");
|
throw new Error(error.detail || "Failed to mark inbox item as read");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to mark notification as read:", err);
|
console.error("Failed to mark inbox item as read:", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Mark all notifications as read via backend API
|
// Mark all inbox items as read via backend API
|
||||||
const markAllAsRead = useCallback(async () => {
|
const markAllAsRead = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
// Note: Backend API endpoint is still /notifications/
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
|
||||||
{ method: "PATCH" }
|
{ method: "PATCH" }
|
||||||
|
|
@ -313,22 +322,48 @@ export function useNotifications(
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" }));
|
const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" }));
|
||||||
throw new Error(error.detail || "Failed to mark all notifications as read");
|
throw new Error(error.detail || "Failed to mark all inbox items as read");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to mark all notifications as read:", err);
|
console.error("Failed to mark all inbox items as read:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Archive/unarchive an inbox item via backend API
|
||||||
|
const archiveItem = useCallback(async (itemId: number, archived: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/archive`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ archived }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: "Failed to update archive status" }));
|
||||||
|
throw new Error(error.detail || "Failed to update inbox item archive status");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update inbox item archive status:", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notifications,
|
inboxItems,
|
||||||
unreadCount: totalUnreadCount,
|
unreadCount: totalUnreadCount,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
|
archiveItem,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -692,7 +692,22 @@
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"logout": "Logout"
|
"logout": "Logout",
|
||||||
|
"inbox": "Inbox",
|
||||||
|
"search_inbox": "Search inbox",
|
||||||
|
"mark_all_read": "Mark all as read",
|
||||||
|
"mark_as_read": "Mark as read",
|
||||||
|
"mentions": "Mentions",
|
||||||
|
"status": "Status",
|
||||||
|
"no_results_found": "No results found",
|
||||||
|
"no_mentions": "No mentions",
|
||||||
|
"no_mentions_hint": "You'll see mentions from others here",
|
||||||
|
"no_status_updates": "No status updates",
|
||||||
|
"no_status_updates_hint": "Document and connector updates will appear here",
|
||||||
|
"filter": "Filter",
|
||||||
|
"unread_and_read": "Unread & read",
|
||||||
|
"unread": "Unread",
|
||||||
|
"archived": "Archived"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"something_went_wrong": "Something went wrong",
|
"something_went_wrong": "Something went wrong",
|
||||||
|
|
|
||||||
|
|
@ -677,7 +677,18 @@
|
||||||
"light": "浅色",
|
"light": "浅色",
|
||||||
"dark": "深色",
|
"dark": "深色",
|
||||||
"system": "系统",
|
"system": "系统",
|
||||||
"logout": "退出登录"
|
"logout": "退出登录",
|
||||||
|
"inbox": "收件箱",
|
||||||
|
"search_inbox": "搜索收件箱...",
|
||||||
|
"mark_all_read": "全部标记为已读",
|
||||||
|
"mark_as_read": "标记为已读",
|
||||||
|
"mentions": "提及",
|
||||||
|
"status": "状态",
|
||||||
|
"no_results_found": "未找到结果",
|
||||||
|
"no_mentions": "没有提及",
|
||||||
|
"no_mentions_hint": "您会在这里看到他人的提及",
|
||||||
|
"no_status_updates": "没有状态更新",
|
||||||
|
"no_status_updates_hint": "文档和连接器更新将显示在这里"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"something_went_wrong": "出错了",
|
"something_went_wrong": "出错了",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue