mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
Merge pull request #721 from AnishSarkar22/feat/inbox
feat: introduce inbox with some fixes
This commit is contained in:
commit
2065009bbd
33 changed files with 2578 additions and 1042 deletions
|
|
@ -1,12 +1,15 @@
|
|||
"""
|
||||
Notifications API routes.
|
||||
These endpoints allow marking notifications as read.
|
||||
Electric SQL automatically syncs the changes to all connected clients.
|
||||
These endpoints allow marking notifications as read and fetching older notifications.
|
||||
Electric SQL automatically syncs the changes to all connected clients for recent items.
|
||||
For older items (beyond the sync window), use the list endpoint.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import desc, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import Notification, User, get_async_session
|
||||
|
|
@ -14,6 +17,36 @@ from app.users import current_active_user
|
|||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
|
||||
SYNC_WINDOW_DAYS = 14
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
"""Response model for a single notification."""
|
||||
|
||||
id: int
|
||||
user_id: str
|
||||
search_space_id: int | None
|
||||
type: str
|
||||
title: str
|
||||
message: str
|
||||
read: bool
|
||||
metadata: dict
|
||||
created_at: str
|
||||
updated_at: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
"""Response for listing notifications with pagination."""
|
||||
|
||||
items: list[NotificationResponse]
|
||||
total: int
|
||||
has_more: bool
|
||||
next_offset: int | None
|
||||
|
||||
|
||||
class MarkReadResponse(BaseModel):
|
||||
"""Response for mark as read operations."""
|
||||
|
|
@ -30,6 +63,169 @@ class MarkAllReadResponse(BaseModel):
|
|||
updated_count: int
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
"""Response for unread count with split between recent and older items."""
|
||||
|
||||
total_unread: int
|
||||
recent_unread: int # Within SYNC_WINDOW_DAYS
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
async def get_unread_count(
|
||||
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> UnreadCountResponse:
|
||||
"""
|
||||
Get the total unread notification count for the current user.
|
||||
|
||||
Returns both:
|
||||
- total_unread: All unread notifications (for accurate badge count)
|
||||
- recent_unread: Unread notifications within the sync window (last 14 days)
|
||||
|
||||
This allows the frontend to calculate:
|
||||
- older_unread = total_unread - recent_unread (static until reconciliation)
|
||||
- Display count = older_unread + live_recent_count (from Electric SQL)
|
||||
"""
|
||||
# Calculate cutoff date for sync window
|
||||
cutoff_date = datetime.now(UTC) - timedelta(days=SYNC_WINDOW_DAYS)
|
||||
|
||||
# Base filter for user's unread notifications
|
||||
base_filter = [
|
||||
Notification.user_id == user.id,
|
||||
Notification.read == False, # noqa: E712
|
||||
]
|
||||
|
||||
# Add search space filter if provided (include null for global notifications)
|
||||
if search_space_id is not None:
|
||||
base_filter.append(
|
||||
(Notification.search_space_id == search_space_id)
|
||||
| (Notification.search_space_id.is_(None))
|
||||
)
|
||||
|
||||
# Total unread count (all time)
|
||||
total_query = select(func.count(Notification.id)).where(*base_filter)
|
||||
total_result = await session.execute(total_query)
|
||||
total_unread = total_result.scalar() or 0
|
||||
|
||||
# Recent unread count (within sync window)
|
||||
recent_query = select(func.count(Notification.id)).where(
|
||||
*base_filter,
|
||||
Notification.created_at > cutoff_date,
|
||||
)
|
||||
recent_result = await session.execute(recent_query)
|
||||
recent_unread = recent_result.scalar() or 0
|
||||
|
||||
return UnreadCountResponse(
|
||||
total_unread=total_unread,
|
||||
recent_unread=recent_unread,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||
type_filter: str | None = Query(
|
||||
None, alias="type", description="Filter by notification type"
|
||||
),
|
||||
before_date: str | None = Query(
|
||||
None, description="Get notifications before this ISO date (for pagination)"
|
||||
),
|
||||
limit: int = Query(50, ge=1, le=100, description="Number of items to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of items to skip"),
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> NotificationListResponse:
|
||||
"""
|
||||
List notifications for the current user with pagination.
|
||||
|
||||
This endpoint is used as a fallback for older notifications that are
|
||||
outside the Electric SQL sync window (2 weeks).
|
||||
|
||||
Use `before_date` to paginate through older notifications efficiently.
|
||||
"""
|
||||
# Build base query
|
||||
query = select(Notification).where(Notification.user_id == user.id)
|
||||
count_query = select(func.count(Notification.id)).where(
|
||||
Notification.user_id == user.id
|
||||
)
|
||||
|
||||
# Filter by search space (include null search_space_id for global notifications)
|
||||
if search_space_id is not None:
|
||||
query = query.where(
|
||||
(Notification.search_space_id == search_space_id)
|
||||
| (Notification.search_space_id.is_(None))
|
||||
)
|
||||
count_query = count_query.where(
|
||||
(Notification.search_space_id == search_space_id)
|
||||
| (Notification.search_space_id.is_(None))
|
||||
)
|
||||
|
||||
# Filter by type
|
||||
if type_filter:
|
||||
query = query.where(Notification.type == type_filter)
|
||||
count_query = count_query.where(Notification.type == type_filter)
|
||||
|
||||
# Filter by date (for efficient pagination of older items)
|
||||
if before_date:
|
||||
try:
|
||||
before_datetime = datetime.fromisoformat(before_date.replace("Z", "+00:00"))
|
||||
query = query.where(Notification.created_at < before_datetime)
|
||||
count_query = count_query.where(Notification.created_at < before_datetime)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)",
|
||||
) from None
|
||||
|
||||
# Get total count
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Apply ordering and pagination
|
||||
query = (
|
||||
query.order_by(desc(Notification.created_at)).offset(offset).limit(limit + 1)
|
||||
)
|
||||
|
||||
# Execute query
|
||||
result = await session.execute(query)
|
||||
notifications = result.scalars().all()
|
||||
|
||||
# Check if there are more items
|
||||
has_more = len(notifications) > limit
|
||||
if has_more:
|
||||
notifications = notifications[:limit]
|
||||
|
||||
# Convert to response format
|
||||
items = []
|
||||
for notification in notifications:
|
||||
items.append(
|
||||
NotificationResponse(
|
||||
id=notification.id,
|
||||
user_id=str(notification.user_id),
|
||||
search_space_id=notification.search_space_id,
|
||||
type=notification.type,
|
||||
title=notification.title,
|
||||
message=notification.message,
|
||||
read=notification.read,
|
||||
metadata=notification.notification_metadata or {},
|
||||
created_at=notification.created_at.isoformat()
|
||||
if notification.created_at
|
||||
else "",
|
||||
updated_at=notification.updated_at.isoformat()
|
||||
if notification.updated_at
|
||||
else None,
|
||||
)
|
||||
)
|
||||
|
||||
return NotificationListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
has_more=has_more,
|
||||
next_offset=offset + limit if has_more else None,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read", response_model=MarkReadResponse)
|
||||
async def mark_notification_as_read(
|
||||
notification_id: int,
|
||||
|
|
|
|||
|
|
@ -368,7 +368,7 @@ export default function NewChatPage() {
|
|||
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 targetCommentId = searchParams.get("commentId");
|
||||
|
||||
|
|
|
|||
|
|
@ -3,29 +3,38 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Bot,
|
||||
Calendar,
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
Crown,
|
||||
Edit2,
|
||||
FileText,
|
||||
Hash,
|
||||
Link2,
|
||||
LinkIcon,
|
||||
Loader2,
|
||||
Logs,
|
||||
type LucideIcon,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Plug,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -512,6 +521,25 @@ export default function TeamManagementPage() {
|
|||
|
||||
// ============ Members Tab ============
|
||||
|
||||
// Helper function to get avatar initials
|
||||
function getAvatarInitials(member: Membership): string {
|
||||
// Try display name first
|
||||
if (member.user_display_name) {
|
||||
const parts = member.user_display_name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return member.user_display_name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
// Try email
|
||||
if (member.user_email) {
|
||||
const emailName = member.user_email.split("@")[0];
|
||||
return emailName.slice(0, 2).toUpperCase();
|
||||
}
|
||||
// Fallback
|
||||
return "U";
|
||||
}
|
||||
|
||||
function MembersTab({
|
||||
members,
|
||||
roles,
|
||||
|
|
@ -560,7 +588,7 @@ function MembersTab({
|
|||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search members..."
|
||||
placeholder="Search members"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
|
|
@ -573,10 +601,30 @@ function MembersTab({
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-auto md:w-[300px] px-2 md:px-4">Member</TableHead>
|
||||
<TableHead className="px-2 md:px-4">Role</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Joined</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead className="w-auto md:w-[300px] px-2 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Member
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="px-2 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Role
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Joined
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Actions
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -601,19 +649,36 @@ function MembersTab({
|
|||
<TableCell className="py-2 px-2 md:py-4 md:px-4 align-middle">
|
||||
<div className="flex items-center gap-1.5 md:gap-3">
|
||||
<div className="relative">
|
||||
<div className="h-8 w-8 md:h-10 md:w-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center ring-2 ring-background">
|
||||
<User className="h-4 w-4 md:h-5 md:w-5 text-primary" />
|
||||
</div>
|
||||
{member.user_avatar_url ? (
|
||||
<Image
|
||||
src={member.user_avatar_url}
|
||||
alt={member.user_display_name || member.user_email || "User"}
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-8 w-8 md:h-10 md:w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 md:h-10 md:w-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
|
||||
<span className="text-xs md:text-sm font-medium text-primary">
|
||||
{getAvatarInitials(member)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{member.is_owner && (
|
||||
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-amber-500 flex items-center justify-center ring-2 ring-background">
|
||||
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-amber-500 flex items-center justify-center">
|
||||
<Crown className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-xs md:text-sm truncate">
|
||||
{member.user_email || "Unknown"}
|
||||
{member.user_display_name || member.user_email || "Unknown"}
|
||||
</p>
|
||||
{member.user_display_name && member.user_email && (
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground truncate">
|
||||
{member.user_email}
|
||||
</p>
|
||||
)}
|
||||
{member.is_owner && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
|
@ -640,29 +705,21 @@ function MembersTab({
|
|||
<SelectItem value="none">No role</SelectItem>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-3 w-3" />
|
||||
{role.name}
|
||||
</div>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 text-[10px] md:text-xs py-0 md:py-0.5"
|
||||
>
|
||||
<Shield className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||
<Badge variant="secondary" className="text-[10px] md:text-xs py-0 md:py-0.5">
|
||||
{member.role?.name || "No role"}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(member.joined_at).toLocaleDateString()}
|
||||
</div>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right py-2 px-2 md:py-4 md:px-4 align-middle">
|
||||
{canRemove && !member.is_owner && (
|
||||
|
|
@ -708,13 +765,137 @@ function MembersTab({
|
|||
);
|
||||
}
|
||||
|
||||
// ============ Role Permissions Display ============
|
||||
|
||||
const CATEGORY_CONFIG: Record<string, { label: string; icon: LucideIcon; order: number }> = {
|
||||
documents: { label: "Documents", icon: FileText, order: 1 },
|
||||
chats: { label: "Chats", icon: MessageSquare, order: 2 },
|
||||
comments: { label: "Comments", icon: MessageCircle, order: 3 },
|
||||
llm_configs: { label: "LLM Configs", icon: Bot, order: 4 },
|
||||
podcasts: { label: "Podcasts", icon: Mic, order: 5 },
|
||||
connectors: { label: "Connectors", icon: Plug, order: 6 },
|
||||
logs: { label: "Logs", icon: Logs, order: 7 },
|
||||
members: { label: "Members", icon: Users, order: 8 },
|
||||
roles: { label: "Roles", icon: Shield, order: 9 },
|
||||
settings: { label: "Settings", icon: Settings, order: 10 },
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: "Create",
|
||||
read: "Read",
|
||||
update: "Update",
|
||||
delete: "Delete",
|
||||
invite: "Invite",
|
||||
view: "View",
|
||||
remove: "Remove",
|
||||
manage_roles: "Manage Roles",
|
||||
};
|
||||
|
||||
function RolePermissionsDisplay({ permissions }: { permissions: string[] }) {
|
||||
if (permissions.includes("*")) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center shrink-0">
|
||||
<Crown className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold">Full Access</p>
|
||||
<p className="text-xs text-muted-foreground">All permissions granted</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group permissions by category
|
||||
const grouped: Record<string, string[]> = {};
|
||||
for (const perm of permissions) {
|
||||
const [category, action] = perm.split(":");
|
||||
if (!grouped[category]) grouped[category] = [];
|
||||
grouped[category].push(action);
|
||||
}
|
||||
|
||||
// Sort categories by predefined order
|
||||
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
||||
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
|
||||
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const categoryCount = sortedCategories.length;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between p-3 rounded-lg border border-border/50 bg-muted/30 hover:bg-muted/50 transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{permissions.length} Permissions</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Across {categoryCount} {categoryCount === 1 ? "category" : "categories"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">View details</div>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[92vw] max-w-md p-0 gap-0">
|
||||
<DialogHeader className="p-4 md:p-5 border-b">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
Role Permissions
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{permissions.length} permissions across {categoryCount} categories
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[55vh]">
|
||||
<div className="divide-y divide-border/50">
|
||||
{sortedCategories.map((category) => {
|
||||
const actions = grouped[category];
|
||||
const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText };
|
||||
const IconComponent = config.icon;
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="flex items-center justify-between gap-3 px-4 md:px-5 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{config.label}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{actions.map((action) => (
|
||||
<span
|
||||
key={action}
|
||||
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] font-medium"
|
||||
>
|
||||
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Roles Tab ============
|
||||
|
||||
function RolesTab({
|
||||
roles,
|
||||
groupedPermissions,
|
||||
groupedPermissions: _groupedPermissions,
|
||||
loading,
|
||||
onUpdateRole,
|
||||
onUpdateRole: _onUpdateRole,
|
||||
onDeleteRole,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
|
|
@ -852,32 +1033,7 @@ function RolesTab({
|
|||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Permissions ({role.permissions.includes("*") ? "All" : role.permissions.length})
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{role.permissions.includes("*") ? (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-gradient-to-r from-amber-500 to-orange-500"
|
||||
>
|
||||
Full Access
|
||||
</Badge>
|
||||
) : (
|
||||
role.permissions.slice(0, 5).map((perm) => (
|
||||
<Badge key={perm} variant="secondary" className="text-xs">
|
||||
{perm.replace(":", " ")}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{!role.permissions.includes("*") && role.permissions.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{role.permissions.length - 5} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RolePermissionsDisplay permissions={role.permissions} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
|
@ -1500,7 +1656,11 @@ function CreateRoleDialog({
|
|||
|
||||
return (
|
||||
<div key={category} className="space-y-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={() => toggleCategory(category)}
|
||||
|
|
@ -1508,19 +1668,21 @@ function CreateRoleDialog({
|
|||
<span className="text-sm font-medium capitalize">
|
||||
{category} ({categorySelected}/{perms.length})
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-2 ml-6">
|
||||
{perms.map((perm) => (
|
||||
<label
|
||||
<button
|
||||
type="button"
|
||||
key={perm.value}
|
||||
className="flex items-center gap-2 cursor-pointer text-left"
|
||||
onClick={() => togglePermission(perm.value)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedPermissions.includes(perm.value)}
|
||||
onCheckedChange={() => togglePermission(perm.value)}
|
||||
/>
|
||||
<span className="text-xs">{perm.value.split(":")[1]}</span>
|
||||
</label>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export function CommentPanel({
|
|||
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
|
||||
>
|
||||
{hasThreads && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
<div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", isMobile && "pb-24")}>
|
||||
<div className="space-y-4 p-4">
|
||||
{threads.map((thread) => (
|
||||
<CommentThread
|
||||
|
|
@ -106,7 +106,7 @@ export function CommentPanel({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-3">
|
||||
<div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-card border-t")}>
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container";
|
||||
|
|
@ -15,22 +22,39 @@ export function CommentSheet({
|
|||
}: CommentSheetProps) {
|
||||
const isBottomSheet = side === "bottom";
|
||||
|
||||
// Use Drawer for mobile (bottom), Sheet for medium screens (right)
|
||||
if (isBottomSheet) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={onOpenChange} shouldScaleBackground={false}>
|
||||
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
|
||||
<DrawerHandle />
|
||||
<DrawerHeader className="px-4 pb-3 pt-2">
|
||||
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<MessageSquare className="size-5" />
|
||||
Comments
|
||||
{commentCount > 0 && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{commentCount}
|
||||
</span>
|
||||
)}
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
<CommentPanelContainer messageId={messageId} isOpen={true} variant="mobile" />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// Use Sheet for medium screens (right side)
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side={side}
|
||||
className={cn(
|
||||
"flex flex-col gap-0 overflow-hidden p-0",
|
||||
isBottomSheet ? "h-[85vh] max-h-[85vh] rounded-t-xl" : "h-full w-full max-w-md"
|
||||
)}
|
||||
className={cn("flex flex-col gap-0 overflow-hidden p-0 h-full w-full max-w-md")}
|
||||
>
|
||||
{/* Drag handle indicator - only for bottom sheet */}
|
||||
{isBottomSheet && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
<SheetHeader className={cn("flex-shrink-0 border-b px-4", isBottomSheet ? "pb-3" : "py-4")}>
|
||||
<SheetHeader className="flex-shrink-0 px-4 py-4">
|
||||
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<MessageSquare className="size-5" />
|
||||
Comments
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
||||
import { cleanupElectric } from "@/lib/electric/client";
|
||||
|
|
@ -29,6 +30,7 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
|||
import { LayoutShell } from "../ui/shell";
|
||||
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
||||
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
||||
import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
|
||||
|
||||
interface LayoutDataProviderProps {
|
||||
searchSpaceId: string;
|
||||
|
|
@ -59,8 +61,8 @@ export function LayoutDataProvider({
|
|||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||
: null;
|
||||
|
||||
// Fetch current search space
|
||||
const { data: searchSpace } = useQuery({
|
||||
// Fetch current search space (for caching purposes)
|
||||
useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
||||
enabled: !!searchSpaceId,
|
||||
|
|
@ -77,9 +79,25 @@ export function LayoutDataProvider({
|
|||
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
|
||||
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
|
||||
|
||||
// Inbox sidebar state
|
||||
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
||||
|
||||
// Search space dialog state
|
||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||
|
||||
// Inbox hook
|
||||
const userId = user?.id ? String(user.id) : null;
|
||||
const {
|
||||
inboxItems,
|
||||
unreadCount,
|
||||
loading: inboxLoading,
|
||||
loadingMore: inboxLoadingMore,
|
||||
hasMore: inboxHasMore,
|
||||
loadMore: inboxLoadMore,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
||||
|
||||
// Delete dialogs state
|
||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||
|
|
@ -149,14 +167,21 @@ export function LayoutDataProvider({
|
|||
icon: SquareLibrary,
|
||||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
// {
|
||||
// title: "Logs",
|
||||
// url: `/dashboard/${searchSpaceId}/logs`,
|
||||
// icon: Logs,
|
||||
// isActive: pathname?.includes("/logs"),
|
||||
// },
|
||||
{
|
||||
title: "Logs",
|
||||
url: `/dashboard/${searchSpaceId}/logs`,
|
||||
icon: Logs,
|
||||
isActive: pathname?.includes("/logs"),
|
||||
title: "Inbox",
|
||||
url: "#inbox", // Special URL to indicate this is handled differently
|
||||
icon: Inbox,
|
||||
isActive: isInboxSidebarOpen,
|
||||
badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
|
||||
},
|
||||
],
|
||||
[searchSpaceId, pathname]
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
|
|
@ -248,6 +273,11 @@ export function LayoutDataProvider({
|
|||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
// Handle inbox specially - open sidebar instead of navigating
|
||||
if (item.url === "#inbox") {
|
||||
setIsInboxSidebarOpen(true);
|
||||
return;
|
||||
}
|
||||
router.push(item.url);
|
||||
},
|
||||
[router]
|
||||
|
|
@ -517,6 +547,20 @@ export function LayoutDataProvider({
|
|||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
|
||||
{/* Inbox Sidebar */}
|
||||
<InboxSidebar
|
||||
open={isInboxSidebarOpen}
|
||||
onOpenChange={setIsInboxSidebarOpen}
|
||||
inboxItems={inboxItems}
|
||||
unreadCount={unreadCount}
|
||||
loading={inboxLoading}
|
||||
loadingMore={inboxLoadingMore}
|
||||
hasMore={inboxHasMore}
|
||||
loadMore={inboxLoadMore}
|
||||
markAsRead={markAsRead}
|
||||
markAllAsRead={markAllAsRead}
|
||||
/>
|
||||
|
||||
{/* Create Search Space Dialog */}
|
||||
<CreateSearchSpaceDialog
|
||||
open={isCreateSearchSpaceDialogOpen}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useAtomValue } from "jotai";
|
|||
import { usePathname } from "next/navigation";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import { NotificationButton } from "@/components/notifications/NotificationButton";
|
||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
|
||||
interface HeaderProps {
|
||||
|
|
@ -55,8 +54,6 @@ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
|||
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notifications */}
|
||||
<NotificationButton />
|
||||
{/* Share button - only show on chat pages when thread exists */}
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
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 { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import {
|
||||
|
|
@ -237,20 +238,9 @@ export function AllPrivateChatsSidebar({
|
|||
aria-label={t("chats") || "Private Chats"}
|
||||
>
|
||||
<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">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
|
|
@ -277,32 +267,38 @@ export function AllPrivateChatsSidebar({
|
|||
</div>
|
||||
|
||||
{!isSearchMode && (
|
||||
<div className="shrink-0 flex border-b mx-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(false)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
!showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Active ({activeCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(true)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Archived ({archivedCount})
|
||||
</button>
|
||||
</div>
|
||||
<Tabs
|
||||
value={showArchived ? "archived" : "active"}
|
||||
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||
className="shrink-0 mx-4"
|
||||
>
|
||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||
<TabsTrigger
|
||||
value="active"
|
||||
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">
|
||||
<MessageCircleMore className="h-4 w-4" />
|
||||
<span>Active</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">
|
||||
{activeCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="archived"
|
||||
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">
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
<span>Archived</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">
|
||||
{archivedCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
|
|
@ -371,7 +367,7 @@ export function AllPrivateChatsSidebar({
|
|||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
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 { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import {
|
||||
|
|
@ -237,20 +238,9 @@ export function AllSharedChatsSidebar({
|
|||
aria-label={t("shared_chats") || "Shared Chats"}
|
||||
>
|
||||
<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">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
|
|
@ -277,32 +267,38 @@ export function AllSharedChatsSidebar({
|
|||
</div>
|
||||
|
||||
{!isSearchMode && (
|
||||
<div className="shrink-0 flex border-b mx-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(false)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
!showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Active ({activeCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(true)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Archived ({archivedCount})
|
||||
</button>
|
||||
</div>
|
||||
<Tabs
|
||||
value={showArchived ? "archived" : "active"}
|
||||
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||
className="shrink-0 mx-4"
|
||||
>
|
||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||
<TabsTrigger
|
||||
value="active"
|
||||
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">
|
||||
<MessageCircleMore className="h-4 w-4" />
|
||||
<span>Active</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">
|
||||
{activeCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="archived"
|
||||
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">
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
<span>Archived</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">
|
||||
{archivedCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
|
|
@ -371,7 +367,7 @@ export function AllSharedChatsSidebar({
|
|||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
|
|||
</button>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-100 md:opacity-0 md:group-hover/item:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="sr-only">{t("more_options")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
|
|||
854
surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
Normal file
854
surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
Normal file
|
|
@ -0,0 +1,854 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
AtSign,
|
||||
BellDot,
|
||||
Check,
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
History,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
ListFilter,
|
||||
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, useRef, 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 {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import {
|
||||
type ConnectorIndexingMetadata,
|
||||
type NewMentionMetadata,
|
||||
isConnectorIndexingMetadata,
|
||||
isNewMentionMetadata,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for connector type
|
||||
*/
|
||||
function getConnectorTypeDisplayName(connectorType: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
GITHUB_CONNECTOR: "GitHub",
|
||||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||
GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
||||
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||
LINEAR_CONNECTOR: "Linear",
|
||||
NOTION_CONNECTOR: "Notion",
|
||||
SLACK_CONNECTOR: "Slack",
|
||||
TEAMS_CONNECTOR: "Microsoft Teams",
|
||||
DISCORD_CONNECTOR: "Discord",
|
||||
JIRA_CONNECTOR: "Jira",
|
||||
CONFLUENCE_CONNECTOR: "Confluence",
|
||||
BOOKSTACK_CONNECTOR: "BookStack",
|
||||
CLICKUP_CONNECTOR: "ClickUp",
|
||||
AIRTABLE_CONNECTOR: "Airtable",
|
||||
LUMA_CONNECTOR: "Luma",
|
||||
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
||||
WEBCRAWLER_CONNECTOR: "Web Crawler",
|
||||
YOUTUBE_CONNECTOR: "YouTube",
|
||||
CIRCLEBACK_CONNECTOR: "Circleback",
|
||||
MCP_CONNECTOR: "MCP",
|
||||
TAVILY_API: "Tavily",
|
||||
SEARXNG_API: "SearXNG",
|
||||
LINKUP_API: "Linkup",
|
||||
BAIDU_SEARCH_API: "Baidu",
|
||||
};
|
||||
|
||||
return (
|
||||
displayNames[connectorType] ||
|
||||
connectorType
|
||||
.replace(/_/g, " ")
|
||||
.replace(/CONNECTOR|API/gi, "")
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
type InboxTab = "mentions" | "status";
|
||||
type InboxFilter = "all" | "unread";
|
||||
|
||||
interface InboxSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
inboxItems: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
export function InboxSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
inboxItems,
|
||||
unreadCount,
|
||||
loading,
|
||||
loadingMore = false,
|
||||
hasMore = false,
|
||||
loadMore,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
onCloseMobileSidebar,
|
||||
}: InboxSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
|
||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Dropdown state for filter menu (desktop only)
|
||||
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
||||
// Drawer state for filter menu (mobile only)
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||
|
||||
// Prefetch trigger ref - placed on item near the end
|
||||
const prefetchTriggerRef = useRef<HTMLDivElement>(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]);
|
||||
|
||||
// Reset connector filter when switching away from status tab
|
||||
useEffect(() => {
|
||||
if (activeTab !== "status") {
|
||||
setSelectedConnector(null);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// 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 unique connector types from status items for filtering
|
||||
const uniqueConnectorTypes = useMemo(() => {
|
||||
const connectorTypes = new Set<string>();
|
||||
|
||||
statusItems
|
||||
.filter((item) => item.type === "connector_indexing")
|
||||
.forEach((item) => {
|
||||
// Use type guard for safe metadata access
|
||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
||||
connectorTypes.add(item.metadata.connector_type);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(connectorTypes).map((type) => ({
|
||||
type,
|
||||
displayName: getConnectorTypeDisplayName(type),
|
||||
}));
|
||||
}, [statusItems]);
|
||||
|
||||
// Get items for current tab
|
||||
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
|
||||
|
||||
// Filter items based on filter type, connector filter, and search query
|
||||
const filteredItems = useMemo(() => {
|
||||
let items = currentTabItems;
|
||||
|
||||
// Apply read/unread filter
|
||||
if (activeFilter === "unread") {
|
||||
items = items.filter((item) => !item.read);
|
||||
}
|
||||
|
||||
// Apply connector filter (only for status tab)
|
||||
if (activeTab === "status" && selectedConnector) {
|
||||
items = items.filter((item) => {
|
||||
if (item.type === "connector_indexing") {
|
||||
// Use type guard for safe metadata access
|
||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
||||
return item.metadata.connector_type === selectedConnector;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false; // Hide document_processing when a specific connector is selected
|
||||
});
|
||||
}
|
||||
|
||||
// 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, activeTab, selectedConnector, searchQuery]);
|
||||
|
||||
// Intersection Observer for infinite scroll with prefetching
|
||||
// Only active when not searching (search results are client-side filtered)
|
||||
useEffect(() => {
|
||||
if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// When trigger element is visible, load more
|
||||
if (entries[0]?.isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null, // viewport
|
||||
rootMargin: "100px", // Start loading 100px before visible
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
if (prefetchTriggerRef.current) {
|
||||
observer.observe(prefetchTriggerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]);
|
||||
|
||||
// Count unread items per tab
|
||||
const unreadMentionsCount = useMemo(() => {
|
||||
return mentionItems.filter((item) => !item.read).length;
|
||||
}, [mentionItems]);
|
||||
|
||||
const unreadStatusCount = useMemo(() => {
|
||||
return 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") {
|
||||
// Use type guard for safe metadata access
|
||||
if (isNewMentionMetadata(item.metadata)) {
|
||||
const searchSpaceId = item.search_space_id;
|
||||
const threadId = item.metadata.thread_id;
|
||||
const commentId = item.metadata.comment_id;
|
||||
|
||||
if (searchSpaceId && threadId) {
|
||||
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 handleMarkAllAsRead = useCallback(async () => {
|
||||
await markAllAsRead();
|
||||
}, [markAllAsRead]);
|
||||
|
||||
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") {
|
||||
// Use type guard for safe metadata access
|
||||
if (isNewMentionMetadata(item.metadata)) {
|
||||
const authorName = item.metadata.author_name;
|
||||
const avatarUrl = item.metadata.author_avatar_url;
|
||||
const authorEmail = item.metadata.author_email;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
// Fallback for invalid metadata
|
||||
return (
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||
{getInitials(null, null)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
// For status items (connector/document), show status icons
|
||||
// Safely access status from metadata
|
||||
const metadata = item.metadata as Record<string, unknown>;
|
||||
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
||||
|
||||
switch (status) {
|
||||
case "in_progress":
|
||||
return (
|
||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
|
||||
<Spinner size="sm" className="text-foreground" />
|
||||
</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-90 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">
|
||||
{/* Mobile: Button that opens bottom drawer */}
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => setFilterDrawerOpen(true)}
|
||||
>
|
||||
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Drawer
|
||||
open={filterDrawerOpen}
|
||||
onOpenChange={setFilterDrawerOpen}
|
||||
shouldScaleBackground={false}
|
||||
>
|
||||
<DrawerContent className="max-h-[70vh] z-80" overlayClassName="z-80">
|
||||
<DrawerHandle />
|
||||
<DrawerHeader className="px-4 pb-3 pt-2">
|
||||
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<ListFilter className="size-5" />
|
||||
{t("filter") || "Filter"}
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Filter section */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||
{t("filter") || "Filter"}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFilter("all");
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
activeFilter === "all"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4" />
|
||||
<span>{t("all") || "All"}</span>
|
||||
</span>
|
||||
{activeFilter === "all" && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFilter("unread");
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
activeFilter === "unread"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<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" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Connectors section - only for status tab */}
|
||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||
{t("connectors") || "Connectors"}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnector(null);
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
selectedConnector === null
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
</span>
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
<button
|
||||
key={connector.type}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnector(connector.type);
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
selectedConnector === connector.type
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
) : (
|
||||
/* Desktop: Dropdown menu */
|
||||
<DropdownMenu
|
||||
open={openDropdown === "filter"}
|
||||
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
|
||||
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
|
||||
>
|
||||
<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("all") || "All"}</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>
|
||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
||||
{t("connectors") || "Connectors"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedConnector(null)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
</span>
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
<DropdownMenuItem
|
||||
key={connector.type}
|
||||
onClick={() => setSelectedConnector(connector.type)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">
|
||||
{t("mark_all_read") || "Mark all as read"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</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>
|
||||
<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">
|
||||
{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>
|
||||
<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">
|
||||
{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">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
const isMarkingAsRead = markingAsReadId === item.id;
|
||||
// Place prefetch trigger on 5th item from end (only if not searching)
|
||||
const isPrefetchTrigger =
|
||||
!searchQuery && hasMore && index === filteredItems.length - 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"transition-colors cursor-pointer",
|
||||
isMarkingAsRead && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={isMarkingAsRead}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
|
||||
>
|
||||
<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 and unread dot - fixed width to prevent content shift */}
|
||||
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
||||
<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 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
||||
{!searchQuery && filteredItems.length < 5 && hasMore && (
|
||||
<div ref={prefetchTriggerRef} className="h-1" />
|
||||
)}
|
||||
</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"
|
||||
onClick={() => onItemClick?.(item)}
|
||||
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",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
item.isActive && "bg-accent text-accent-foreground"
|
||||
|
|
@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
{...joyrideAttr}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function SidebarSection({
|
|||
|
||||
{/* Action button - visible on hover (always visible on mobile) */}
|
||||
{action && (
|
||||
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
|
||||
<div className="shrink-0 opacity-100 md:opacity-0 md:group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
||||
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
||||
export { ChatListItem } from "./ChatListItem";
|
||||
export { InboxSidebar } from "./InboxSidebar";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { NavSection } from "./NavSection";
|
||||
export { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Loader2, User, Users } from "lucide-react";
|
||||
import { User, Users } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
||||
|
|
@ -45,7 +45,6 @@ const visibilityOptions: {
|
|||
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Use Jotai atom for visibility (single source of truth)
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
|
|
@ -62,7 +61,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
// Update Jotai atom immediately for instant UI feedback
|
||||
setThreadVisibility(newVisibility);
|
||||
|
||||
|
|
@ -84,8 +82,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
// Revert Jotai state on error
|
||||
setThreadVisibility(thread.visibility ?? "PRIVATE");
|
||||
toast.error("Failed to update sharing settings");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
|
||||
|
|
@ -128,16 +124,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="p-1.5 space-y-1">
|
||||
{/* Updating overlay */}
|
||||
{isUpdating && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Updating</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibilityOptions.map((option) => {
|
||||
const isSelected = currentVisibility === option.value;
|
||||
const Icon = option.icon;
|
||||
|
|
@ -147,7 +133,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
type="button"
|
||||
key={option.value}
|
||||
onClick={() => handleVisibilityChange(option.value)}
|
||||
disabled={isUpdating}
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ interface ModelSelectorProps {
|
|||
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
|
||||
// Fetch configs
|
||||
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
|
|
@ -137,7 +136,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
return;
|
||||
}
|
||||
|
||||
setIsSwitching(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
|
|
@ -150,8 +148,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
} catch (error) {
|
||||
console.error("Failed to switch model:", error);
|
||||
toast.error("Failed to switch model");
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
},
|
||||
[currentConfig, searchSpaceId, updatePreferences]
|
||||
|
|
@ -216,23 +212,12 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
shouldFilter={false}
|
||||
className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
>
|
||||
{/* Switching overlay */}
|
||||
{isSwitching && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Switching model...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
|
||||
<CommandInput
|
||||
placeholder="Search models"
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
|
||||
disabled={isSwitching}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -551,7 +551,9 @@ export function LLMConfigForm({
|
|||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-xs sm:text-sm font-medium">Enable Citations</FormLabel>
|
||||
<FormLabel className="text-xs sm:text-sm font-medium">
|
||||
Enable Citations
|
||||
</FormLabel>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Include [citation:id] references to source documents
|
||||
</FormDescription>
|
||||
|
|
|
|||
115
surfsense_web/components/ui/drawer.tsx
Normal file
115
surfsense_web/components/ui/drawer.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Drawer({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />;
|
||||
}
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
overlayClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content> & {
|
||||
overlayClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay className={overlayClassName} />
|
||||
<DrawerPrimitive.Content
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />;
|
||||
}
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
|
||||
}
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
function DrawerHandle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted-foreground/40", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerHandle.displayName = "DrawerHandle";
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
DrawerHandle,
|
||||
};
|
||||
|
|
@ -42,13 +42,15 @@ function SheetContent({
|
|||
className,
|
||||
children,
|
||||
side = "right",
|
||||
overlayClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
overlayClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetOverlay className={overlayClassName} />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
|
|
|
|||
33
surfsense_web/components/ui/spinner.tsx
Normal file
33
surfsense_web/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SpinnerProps {
|
||||
/** Size of the spinner */
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
/** Whether to hide the track behind the spinner arc */
|
||||
hideTrack?: boolean;
|
||||
/** Additional classes to apply */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: "h-3 w-3 border-[1.5px]",
|
||||
sm: "h-4 w-4 border-2",
|
||||
md: "h-6 w-6 border-2",
|
||||
lg: "h-8 w-8 border-[3px]",
|
||||
xl: "h-10 w-10 border-4",
|
||||
};
|
||||
|
||||
export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) {
|
||||
return (
|
||||
<output
|
||||
aria-label="Loading"
|
||||
className={cn(
|
||||
"block animate-spin rounded-full",
|
||||
hideTrack ? "border-transparent" : "border-current/20",
|
||||
"border-t-current",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,11 +5,11 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS
|
|||
|
||||
# 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?
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
- **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
|
||||
- **Connector status syncs automatically** - See when connectors finish syncing
|
||||
- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible
|
||||
|
|
|
|||
281
surfsense_web/contracts/types/inbox.types.ts
Normal file
281
surfsense_web/contracts/types/inbox.types.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import { z } from "zod";
|
||||
import { searchSourceConnectorTypeEnum } from "./connector.types";
|
||||
import { documentTypeEnum } from "./document.types";
|
||||
|
||||
/**
|
||||
* Inbox item type enum - matches backend notification types
|
||||
*/
|
||||
export const inboxItemTypeEnum = z.enum([
|
||||
"connector_indexing",
|
||||
"document_processing",
|
||||
"new_mention",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Inbox item status enum - used in metadata
|
||||
*/
|
||||
export const inboxItemStatusEnum = z.enum(["in_progress", "completed", "failed"]);
|
||||
|
||||
/**
|
||||
* Document processing stage enum
|
||||
*/
|
||||
export const documentProcessingStageEnum = z.enum([
|
||||
"queued",
|
||||
"parsing",
|
||||
"chunking",
|
||||
"embedding",
|
||||
"storing",
|
||||
"completed",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Base metadata schema shared across inbox item types
|
||||
*/
|
||||
export const baseInboxItemMetadata = z.object({
|
||||
operation_id: z.string().optional(),
|
||||
status: inboxItemStatusEnum.optional(),
|
||||
started_at: z.string().optional(),
|
||||
completed_at: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Connector indexing metadata schema
|
||||
*/
|
||||
export const connectorIndexingMetadata = baseInboxItemMetadata.extend({
|
||||
connector_id: z.number(),
|
||||
connector_name: z.string(),
|
||||
connector_type: searchSourceConnectorTypeEnum,
|
||||
start_date: z.string().nullable().optional(),
|
||||
end_date: z.string().nullable().optional(),
|
||||
indexed_count: z.number(),
|
||||
total_count: z.number().optional(),
|
||||
progress_percent: z.number().optional(),
|
||||
error_message: z.string().nullable().optional(),
|
||||
// Google Drive specific fields
|
||||
folder_count: z.number().optional(),
|
||||
file_count: z.number().optional(),
|
||||
folder_names: z.array(z.string()).optional(),
|
||||
file_names: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Document processing metadata schema
|
||||
*/
|
||||
export const documentProcessingMetadata = baseInboxItemMetadata.extend({
|
||||
document_type: documentTypeEnum,
|
||||
document_name: z.string(),
|
||||
processing_stage: documentProcessingStageEnum,
|
||||
file_size: z.number().optional(),
|
||||
chunks_count: z.number().optional(),
|
||||
document_id: z.number().optional(),
|
||||
error_message: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* New mention metadata schema
|
||||
*/
|
||||
export const newMentionMetadata = z.object({
|
||||
mention_id: z.number(),
|
||||
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(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Union of all inbox item metadata types
|
||||
* Use this when the inbox item type is unknown
|
||||
*/
|
||||
export const inboxItemMetadata = z.union([
|
||||
connectorIndexingMetadata,
|
||||
documentProcessingMetadata,
|
||||
newMentionMetadata,
|
||||
baseInboxItemMetadata,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Main inbox item schema
|
||||
*/
|
||||
export const inboxItem = z.object({
|
||||
id: z.number(),
|
||||
user_id: z.string(),
|
||||
search_space_id: z.number().nullable(),
|
||||
type: inboxItemTypeEnum,
|
||||
title: z.string(),
|
||||
message: z.string(),
|
||||
read: z.boolean(),
|
||||
metadata: z.record(z.string(), z.unknown()),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Typed inbox item schemas for specific types
|
||||
*/
|
||||
export const connectorIndexingInboxItem = inboxItem.extend({
|
||||
type: z.literal("connector_indexing"),
|
||||
metadata: connectorIndexingMetadata,
|
||||
});
|
||||
|
||||
export const documentProcessingInboxItem = inboxItem.extend({
|
||||
type: z.literal("document_processing"),
|
||||
metadata: documentProcessingMetadata,
|
||||
});
|
||||
|
||||
export const newMentionInboxItem = inboxItem.extend({
|
||||
type: z.literal("new_mention"),
|
||||
metadata: newMentionMetadata,
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// API Request/Response Schemas
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Request schema for getting notifications
|
||||
*/
|
||||
export const getNotificationsRequest = z.object({
|
||||
queryParams: z.object({
|
||||
search_space_id: z.number().optional(),
|
||||
type: inboxItemTypeEnum.optional(),
|
||||
before_date: z.string().optional(),
|
||||
limit: z.number().min(1).max(100).optional(),
|
||||
offset: z.number().min(0).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response schema for listing notifications
|
||||
*/
|
||||
export const getNotificationsResponse = z.object({
|
||||
items: z.array(inboxItem),
|
||||
total: z.number(),
|
||||
has_more: z.boolean(),
|
||||
next_offset: z.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Request schema for marking a single notification as read
|
||||
*/
|
||||
export const markNotificationReadRequest = z.object({
|
||||
notificationId: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response schema for mark as read operations
|
||||
*/
|
||||
export const markNotificationReadResponse = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response schema for mark all as read operation
|
||||
*/
|
||||
export const markAllNotificationsReadResponse = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
updated_count: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Request schema for getting unread count
|
||||
*/
|
||||
export const getUnreadCountRequest = z.object({
|
||||
search_space_id: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response schema for unread count
|
||||
* Returns both total and recent counts for split tracking
|
||||
*/
|
||||
export const getUnreadCountResponse = z.object({
|
||||
total_unread: z.number(),
|
||||
recent_unread: z.number(), // Within SYNC_WINDOW_DAYS (14 days)
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Type Guards for Metadata
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Type guard for ConnectorIndexingMetadata
|
||||
*/
|
||||
export function isConnectorIndexingMetadata(
|
||||
metadata: unknown
|
||||
): metadata is ConnectorIndexingMetadata {
|
||||
return connectorIndexingMetadata.safeParse(metadata).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for DocumentProcessingMetadata
|
||||
*/
|
||||
export function isDocumentProcessingMetadata(
|
||||
metadata: unknown
|
||||
): metadata is DocumentProcessingMetadata {
|
||||
return documentProcessingMetadata.safeParse(metadata).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for NewMentionMetadata
|
||||
*/
|
||||
export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionMetadata {
|
||||
return newMentionMetadata.safeParse(metadata).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe metadata parser - returns typed metadata or null
|
||||
*/
|
||||
export function parseInboxItemMetadata(
|
||||
type: InboxItemTypeEnum,
|
||||
metadata: unknown
|
||||
): ConnectorIndexingMetadata | DocumentProcessingMetadata | NewMentionMetadata | null {
|
||||
switch (type) {
|
||||
case "connector_indexing": {
|
||||
const result = connectorIndexingMetadata.safeParse(metadata);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
case "document_processing": {
|
||||
const result = documentProcessingMetadata.safeParse(metadata);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
case "new_mention": {
|
||||
const result = newMentionMetadata.safeParse(metadata);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Inferred types
|
||||
// =============================================================================
|
||||
|
||||
export type InboxItemTypeEnum = z.infer<typeof inboxItemTypeEnum>;
|
||||
export type InboxItemStatusEnum = z.infer<typeof inboxItemStatusEnum>;
|
||||
export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>;
|
||||
export type BaseInboxItemMetadata = z.infer<typeof baseInboxItemMetadata>;
|
||||
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
|
||||
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
|
||||
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
|
||||
export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
|
||||
export type InboxItem = z.infer<typeof inboxItem>;
|
||||
export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxItem>;
|
||||
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
|
||||
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
|
||||
|
||||
// API Request/Response types
|
||||
export type GetNotificationsRequest = z.infer<typeof getNotificationsRequest>;
|
||||
export type GetNotificationsResponse = z.infer<typeof getNotificationsResponse>;
|
||||
export type MarkNotificationReadRequest = z.infer<typeof markNotificationReadRequest>;
|
||||
export type MarkNotificationReadResponse = z.infer<typeof markNotificationReadResponse>;
|
||||
export type MarkAllNotificationsReadResponse = z.infer<typeof markAllNotificationsReadResponse>;
|
||||
export type GetUnreadCountRequest = z.infer<typeof getUnreadCountRequest>;
|
||||
export type GetUnreadCountResponse = z.infer<typeof getUnreadCountResponse>;
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
import { z } from "zod";
|
||||
import { searchSourceConnectorTypeEnum } from "./connector.types";
|
||||
import { documentTypeEnum } from "./document.types";
|
||||
|
||||
/**
|
||||
* Notification type enum - matches backend notification types
|
||||
*/
|
||||
export const notificationTypeEnum = z.enum([
|
||||
"connector_indexing",
|
||||
"document_processing",
|
||||
"new_mention",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Notification status enum - used in metadata
|
||||
*/
|
||||
export const notificationStatusEnum = z.enum(["in_progress", "completed", "failed"]);
|
||||
|
||||
/**
|
||||
* Document processing stage enum
|
||||
*/
|
||||
export const documentProcessingStageEnum = z.enum([
|
||||
"queued",
|
||||
"parsing",
|
||||
"chunking",
|
||||
"embedding",
|
||||
"storing",
|
||||
"completed",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Base metadata schema shared across notification types
|
||||
*/
|
||||
export const baseNotificationMetadata = z.object({
|
||||
operation_id: z.string().optional(),
|
||||
status: notificationStatusEnum.optional(),
|
||||
started_at: z.string().optional(),
|
||||
completed_at: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Connector indexing metadata schema
|
||||
*/
|
||||
export const connectorIndexingMetadata = baseNotificationMetadata.extend({
|
||||
connector_id: z.number(),
|
||||
connector_name: z.string(),
|
||||
connector_type: searchSourceConnectorTypeEnum,
|
||||
start_date: z.string().nullable().optional(),
|
||||
end_date: z.string().nullable().optional(),
|
||||
indexed_count: z.number(),
|
||||
total_count: z.number().optional(),
|
||||
progress_percent: z.number().optional(),
|
||||
error_message: z.string().nullable().optional(),
|
||||
// Google Drive specific fields
|
||||
folder_count: z.number().optional(),
|
||||
file_count: z.number().optional(),
|
||||
folder_names: z.array(z.string()).optional(),
|
||||
file_names: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Document processing metadata schema
|
||||
*/
|
||||
export const documentProcessingMetadata = baseNotificationMetadata.extend({
|
||||
document_type: documentTypeEnum,
|
||||
document_name: z.string(),
|
||||
processing_stage: documentProcessingStageEnum,
|
||||
file_size: z.number().optional(),
|
||||
chunks_count: z.number().optional(),
|
||||
document_id: z.number().optional(),
|
||||
error_message: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* New mention metadata schema
|
||||
*/
|
||||
export const newMentionMetadata = z.object({
|
||||
mention_id: z.number(),
|
||||
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(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Union of all notification metadata types
|
||||
* Use this when the notification type is unknown
|
||||
*/
|
||||
export const notificationMetadata = z.union([
|
||||
connectorIndexingMetadata,
|
||||
documentProcessingMetadata,
|
||||
newMentionMetadata,
|
||||
baseNotificationMetadata,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Main notification schema
|
||||
*/
|
||||
export const notification = z.object({
|
||||
id: z.number(),
|
||||
user_id: z.string(),
|
||||
search_space_id: z.number().nullable(),
|
||||
type: notificationTypeEnum,
|
||||
title: z.string(),
|
||||
message: z.string(),
|
||||
read: z.boolean(),
|
||||
metadata: z.record(z.string(), z.unknown()),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Typed notification schemas for specific notification types
|
||||
*/
|
||||
export const connectorIndexingNotification = notification.extend({
|
||||
type: z.literal("connector_indexing"),
|
||||
metadata: connectorIndexingMetadata,
|
||||
});
|
||||
|
||||
export const documentProcessingNotification = notification.extend({
|
||||
type: z.literal("document_processing"),
|
||||
metadata: documentProcessingMetadata,
|
||||
});
|
||||
|
||||
export const newMentionNotification = notification.extend({
|
||||
type: z.literal("new_mention"),
|
||||
metadata: newMentionMetadata,
|
||||
});
|
||||
|
||||
// Inferred types
|
||||
export type NotificationTypeEnum = z.infer<typeof notificationTypeEnum>;
|
||||
export type NotificationStatusEnum = z.infer<typeof notificationStatusEnum>;
|
||||
export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>;
|
||||
export type BaseNotificationMetadata = z.infer<typeof baseNotificationMetadata>;
|
||||
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
|
||||
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
|
||||
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
|
||||
export type NotificationMetadata = z.infer<typeof notificationMetadata>;
|
||||
export type Notification = z.infer<typeof notification>;
|
||||
export type ConnectorIndexingNotification = z.infer<typeof connectorIndexingNotification>;
|
||||
export type DocumentProcessingNotification = z.infer<typeof documentProcessingNotification>;
|
||||
export type NewMentionNotification = z.infer<typeof newMentionNotification>;
|
||||
523
surfsense_web/hooks/use-inbox.ts
Normal file
523
surfsense_web/hooks/use-inbox.ts
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
import type { SyncHandle } from "@/lib/electric/client";
|
||||
import { useElectricClient } from "@/lib/electric/context";
|
||||
|
||||
export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const SYNC_WINDOW_DAYS = 14;
|
||||
|
||||
/**
|
||||
* Check if an item is older than the sync window
|
||||
*/
|
||||
function isOlderThanSyncWindow(createdAt: string): boolean {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - SYNC_WINDOW_DAYS);
|
||||
return new Date(createdAt) < cutoffDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate by ID and sort by created_at descending.
|
||||
* This is the SINGLE source of truth for deduplication - prevents race conditions.
|
||||
*/
|
||||
function deduplicateAndSort(items: InboxItem[]): InboxItem[] {
|
||||
const seen = new Map<number, InboxItem>();
|
||||
for (const item of items) {
|
||||
if (!seen.has(item.id)) {
|
||||
seen.set(item.id, item);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values()).sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the cutoff date for sync window
|
||||
*/
|
||||
function getSyncCutoffDate(): string {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS);
|
||||
return cutoff.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a date value to ISO string format
|
||||
*/
|
||||
function toISOString(date: string | Date | null | undefined): string | null {
|
||||
if (!date) return null;
|
||||
if (date instanceof Date) return date.toISOString();
|
||||
if (typeof date === "string") {
|
||||
if (date.includes("T")) return date;
|
||||
try {
|
||||
return new Date(date).toISOString();
|
||||
} catch {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing inbox items with Electric SQL real-time sync + API fallback
|
||||
*
|
||||
* Architecture (Simplified & Race-Condition Free):
|
||||
* - Electric SQL: Syncs recent items (within SYNC_WINDOW_DAYS) for real-time updates
|
||||
* - Live Query: Provides reactive first page from PGLite
|
||||
* - API: Handles all pagination (more reliable than mixing with Electric)
|
||||
*
|
||||
* Key Design Decisions:
|
||||
* 1. No mutable refs for cursor - cursor computed from current state
|
||||
* 2. Single deduplicateAndSort function - prevents inconsistencies
|
||||
* 3. Filter-based preservation in live query - prevents data loss
|
||||
* 4. Auto-fetch from API when Electric returns 0 items
|
||||
*
|
||||
* @param userId - The user ID to fetch inbox items for
|
||||
* @param searchSpaceId - The search space ID to filter inbox items
|
||||
* @param typeFilter - Optional inbox item type to filter by
|
||||
*/
|
||||
export function useInbox(
|
||||
userId: string | null,
|
||||
searchSpaceId: number | null,
|
||||
typeFilter: InboxItemTypeEnum | null = null
|
||||
) {
|
||||
const electricClient = useElectricClient();
|
||||
|
||||
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Split unread count tracking for accurate counts with 14-day sync window
|
||||
// olderUnreadCount = unread items OLDER than sync window (from server, static until reconciliation)
|
||||
// recentUnreadCount = unread items within sync window (from live query, real-time)
|
||||
const [olderUnreadCount, setOlderUnreadCount] = useState(0);
|
||||
const [recentUnreadCount, setRecentUnreadCount] = useState(0);
|
||||
|
||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
const userSyncKeyRef = useRef<string | null>(null);
|
||||
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
|
||||
// Total unread = older (static from server) + recent (live from Electric)
|
||||
const totalUnreadCount = olderUnreadCount + recentUnreadCount;
|
||||
|
||||
// EFFECT 1: Electric SQL sync for real-time updates
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) {
|
||||
setLoading(!electricClient);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = electricClient;
|
||||
let mounted = true;
|
||||
|
||||
async function startSync() {
|
||||
try {
|
||||
const cutoffDate = getSyncCutoffDate();
|
||||
const userSyncKey = `inbox_${userId}_${cutoffDate}`;
|
||||
|
||||
// Skip if already syncing with this key
|
||||
if (userSyncKeyRef.current === userSyncKey) return;
|
||||
|
||||
// Clean up previous sync
|
||||
if (syncHandleRef.current) {
|
||||
syncHandleRef.current.unsubscribe();
|
||||
syncHandleRef.current = null;
|
||||
}
|
||||
|
||||
console.log("[useInbox] Starting sync for:", userId);
|
||||
userSyncKeyRef.current = userSyncKey;
|
||||
|
||||
const handle = await client.syncShape({
|
||||
table: "notifications",
|
||||
where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`,
|
||||
primaryKey: ["id"],
|
||||
});
|
||||
|
||||
// Wait for initial sync with timeout
|
||||
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||
await Promise.race([
|
||||
handle.initialSyncPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, 3000)),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
handle.unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
syncHandleRef.current = handle;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
console.error("[useInbox] Sync failed:", err);
|
||||
setError(err instanceof Error ? err : new Error("Sync failed"));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
startSync();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
userSyncKeyRef.current = null;
|
||||
if (syncHandleRef.current) {
|
||||
syncHandleRef.current.unsubscribe();
|
||||
syncHandleRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, electricClient]);
|
||||
|
||||
// Reset when filters change
|
||||
useEffect(() => {
|
||||
setHasMore(true);
|
||||
setInboxItems([]);
|
||||
// Reset count states - will be refetched by the unread count effect
|
||||
setOlderUnreadCount(0);
|
||||
setRecentUnreadCount(0);
|
||||
}, [userId, searchSpaceId, typeFilter]);
|
||||
|
||||
// EFFECT 2: Live query for real-time updates + auto-fetch from API if empty
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) return;
|
||||
|
||||
const client = electricClient;
|
||||
let mounted = true;
|
||||
|
||||
async function setupLiveQuery() {
|
||||
// Clean up previous live query
|
||||
if (liveQueryRef.current) {
|
||||
liveQueryRef.current.unsubscribe();
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const cutoff = getSyncCutoffDate();
|
||||
|
||||
const query = `SELECT * FROM notifications
|
||||
WHERE user_id = $1
|
||||
AND (search_space_id = $2 OR search_space_id IS NULL)
|
||||
AND created_at > '${cutoff}'
|
||||
${typeFilter ? "AND type = $3" : ""}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${PAGE_SIZE}`;
|
||||
|
||||
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
|
||||
|
||||
const db = client.db as any;
|
||||
|
||||
// Initial fetch from PGLite - no validation needed, schema is enforced by Electric SQL sync
|
||||
const result = await client.db.query<InboxItem>(query, params);
|
||||
|
||||
if (mounted && result.rows) {
|
||||
const items = deduplicateAndSort(result.rows);
|
||||
setInboxItems(items);
|
||||
|
||||
// AUTO-FETCH: If Electric returned 0 items, check API for older items
|
||||
// This handles the edge case where user has no recent notifications
|
||||
// but has older ones outside the sync window
|
||||
if (items.length === 0) {
|
||||
console.log(
|
||||
"[useInbox] Electric returned 0 items, checking API for older notifications"
|
||||
);
|
||||
try {
|
||||
// Use the API service with proper Zod validation for API responses
|
||||
const data = await notificationsApiService.getNotifications({
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId ?? undefined,
|
||||
type: typeFilter ?? undefined,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
if (data.items.length > 0) {
|
||||
setInboxItems(data.items);
|
||||
}
|
||||
setHasMore(data.has_more);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useInbox] API fallback failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up live query for real-time updates
|
||||
if (db.live?.query) {
|
||||
const liveQuery = await db.live.query(query, params);
|
||||
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (liveQuery.subscribe) {
|
||||
// Live query data comes from PGlite - no validation needed
|
||||
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
|
||||
if (mounted && result.rows) {
|
||||
const liveItems = result.rows;
|
||||
|
||||
setInboxItems((prev) => {
|
||||
const liveItemIds = new Set(liveItems.map((item) => item.id));
|
||||
|
||||
// FIXED: Keep ALL items not in live result (not just slice)
|
||||
// This prevents data loss when new notifications push items
|
||||
// out of the LIMIT window
|
||||
const itemsToKeep = prev.filter((item) => !liveItemIds.has(item.id));
|
||||
|
||||
return deduplicateAndSort([...liveItems, ...itemsToKeep]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (liveQuery.unsubscribe) {
|
||||
liveQueryRef.current = liveQuery;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useInbox] Live query error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
setupLiveQuery();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (liveQueryRef.current) {
|
||||
liveQueryRef.current.unsubscribe();
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
||||
|
||||
// EFFECT 3: Dedicated unread count sync with split tracking
|
||||
// - Fetches server count on mount (accurate total)
|
||||
// - Sets up live query for recent count (real-time updates)
|
||||
// - Handles items older than sync window separately
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) return;
|
||||
|
||||
const client = electricClient;
|
||||
let mounted = true;
|
||||
|
||||
async function setupUnreadCountSync() {
|
||||
// Cleanup previous live query
|
||||
if (unreadCountLiveQueryRef.current) {
|
||||
unreadCountLiveQueryRef.current.unsubscribe();
|
||||
unreadCountLiveQueryRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// STEP 1: Fetch server counts (total and recent) - guaranteed accurate
|
||||
console.log("[useInbox] Fetching unread count from server");
|
||||
const serverCounts = await notificationsApiService.getUnreadCount(
|
||||
searchSpaceId ?? undefined
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
// Calculate older count = total - recent
|
||||
const olderCount = serverCounts.total_unread - serverCounts.recent_unread;
|
||||
setOlderUnreadCount(olderCount);
|
||||
setRecentUnreadCount(serverCounts.recent_unread);
|
||||
console.log(
|
||||
`[useInbox] Server counts: total=${serverCounts.total_unread}, recent=${serverCounts.recent_unread}, older=${olderCount}`
|
||||
);
|
||||
}
|
||||
|
||||
// STEP 2: Set up PGLite live query for RECENT unread count only
|
||||
// This provides real-time updates for notifications within sync window
|
||||
const db = client.db as any;
|
||||
const cutoff = getSyncCutoffDate();
|
||||
|
||||
// Count query - NO LIMIT, counts all unread in synced window
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as count FROM notifications
|
||||
WHERE user_id = $1
|
||||
AND (search_space_id = $2 OR search_space_id IS NULL)
|
||||
AND created_at > '${cutoff}'
|
||||
AND read = false
|
||||
${typeFilter ? "AND type = $3" : ""}
|
||||
`;
|
||||
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
|
||||
|
||||
if (db.live?.query) {
|
||||
const liveQuery = await db.live.query(countQuery, params);
|
||||
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (liveQuery.subscribe) {
|
||||
liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
|
||||
if (mounted && result.rows?.[0]) {
|
||||
const liveCount = Number(result.rows[0].count) || 0;
|
||||
// Update recent count from live query
|
||||
// This fires in real-time when Electric syncs new/updated notifications
|
||||
setRecentUnreadCount(liveCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (liveQuery.unsubscribe) {
|
||||
unreadCountLiveQueryRef.current = liveQuery;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useInbox] Unread count sync error:", err);
|
||||
// On error, counts will remain at 0 or previous values
|
||||
// The items-based count will be the fallback
|
||||
}
|
||||
}
|
||||
|
||||
setupUnreadCountSync();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (unreadCountLiveQueryRef.current) {
|
||||
unreadCountLiveQueryRef.current.unsubscribe();
|
||||
unreadCountLiveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
||||
|
||||
// loadMore - Pure cursor-based pagination, no race conditions
|
||||
// Cursor is computed from current state, not stored in refs
|
||||
const loadMore = useCallback(async () => {
|
||||
// Removed inboxItems.length === 0 check to allow loading older items
|
||||
// when Electric returns 0 items
|
||||
if (!userId || loadingMore || !hasMore) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
|
||||
try {
|
||||
// Cursor is computed from current state - no stale refs possible
|
||||
const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null;
|
||||
const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null;
|
||||
|
||||
console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)");
|
||||
|
||||
// Use the API service with proper Zod validation
|
||||
const data = await notificationsApiService.getNotifications({
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId ?? undefined,
|
||||
type: typeFilter ?? undefined,
|
||||
before_date: beforeDate ?? undefined,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.items.length > 0) {
|
||||
// Functional update ensures we always merge with latest state
|
||||
// Items are already validated by the API service
|
||||
setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items]));
|
||||
}
|
||||
|
||||
// Use API's has_more flag
|
||||
setHasMore(data.has_more);
|
||||
} catch (err) {
|
||||
console.error("[useInbox] Load more failed:", err);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
|
||||
|
||||
// Mark inbox item as read with optimistic update
|
||||
// Handles both recent items (live query updates count) and older items (manual count decrement)
|
||||
const markAsRead = useCallback(
|
||||
async (itemId: number) => {
|
||||
// Find the item to check if it's older than sync window
|
||||
const item = inboxItems.find((i) => i.id === itemId);
|
||||
const isOlderItem = item && !item.read && isOlderThanSyncWindow(item.created_at);
|
||||
|
||||
// Optimistic update: mark as read immediately for instant UI feedback
|
||||
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i)));
|
||||
|
||||
// If older item, manually decrement older count
|
||||
// (live query won't see items outside sync window)
|
||||
if (isOlderItem) {
|
||||
setOlderUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the API service with proper Zod validation
|
||||
const result = await notificationsApiService.markAsRead({ notificationId: itemId });
|
||||
|
||||
if (!result.success) {
|
||||
// Rollback on error
|
||||
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
|
||||
if (isOlderItem) {
|
||||
setOlderUnreadCount((prev) => prev + 1);
|
||||
}
|
||||
}
|
||||
// If successful, Electric SQL will sync the change and live query will update
|
||||
// This ensures eventual consistency even if optimistic update was wrong
|
||||
return result.success;
|
||||
} catch (err) {
|
||||
console.error("Failed to mark as read:", err);
|
||||
// Rollback on error
|
||||
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
|
||||
if (isOlderItem) {
|
||||
setOlderUnreadCount((prev) => prev + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[inboxItems]
|
||||
);
|
||||
|
||||
// Mark all inbox items as read with optimistic update
|
||||
// Resets both older and recent counts to 0
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
// Store previous counts for potential rollback
|
||||
const prevOlderCount = olderUnreadCount;
|
||||
const prevRecentCount = recentUnreadCount;
|
||||
|
||||
// Optimistic update: mark all as read immediately for instant UI feedback
|
||||
setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
|
||||
setOlderUnreadCount(0);
|
||||
setRecentUnreadCount(0);
|
||||
|
||||
try {
|
||||
// Use the API service with proper Zod validation
|
||||
const result = await notificationsApiService.markAllAsRead();
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Failed to mark all as read");
|
||||
// Rollback counts on error
|
||||
setOlderUnreadCount(prevOlderCount);
|
||||
setRecentUnreadCount(prevRecentCount);
|
||||
}
|
||||
// Electric SQL will sync and live query will ensure consistency
|
||||
return result.success;
|
||||
} catch (err) {
|
||||
console.error("Failed to mark all as read:", err);
|
||||
// Rollback counts on error
|
||||
setOlderUnreadCount(prevOlderCount);
|
||||
setRecentUnreadCount(prevRecentCount);
|
||||
return false;
|
||||
}
|
||||
}, [olderUnreadCount, recentUnreadCount]);
|
||||
|
||||
return {
|
||||
inboxItems,
|
||||
unreadCount: totalUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
isUsingApiFallback: true, // Always use API for pagination
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,334 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import type { SyncHandle } from "@/lib/electric/client";
|
||||
import { useElectricClient } from "@/lib/electric/context";
|
||||
|
||||
export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
|
||||
|
||||
/**
|
||||
* Hook for managing notifications with Electric SQL real-time sync
|
||||
*
|
||||
* Uses the Electric client from context (provided by ElectricProvider)
|
||||
* instead of initializing its own - prevents race conditions and memory leaks
|
||||
*
|
||||
* Architecture:
|
||||
* - User-level sync: Syncs ALL notifications for a user (runs once per user)
|
||||
* - Search-space-level query: Filters notifications by searchSpaceId (updates on search space change)
|
||||
*
|
||||
* This separation ensures smooth transitions when switching search spaces (no flash).
|
||||
*
|
||||
* @param userId - The user ID to fetch notifications for
|
||||
* @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only)
|
||||
* @param typeFilter - Optional notification type to filter by (null shows all types)
|
||||
*/
|
||||
export function useNotifications(
|
||||
userId: string | null,
|
||||
searchSpaceId: number | null,
|
||||
typeFilter: NotificationTypeEnum | null = null
|
||||
) {
|
||||
// Get Electric client from context - ElectricProvider handles initialization
|
||||
const electricClient = useElectricClient();
|
||||
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
|
||||
// Track user-level sync key to prevent duplicate sync subscriptions
|
||||
const userSyncKeyRef = useRef<string | null>(null);
|
||||
|
||||
// EFFECT 1: User-level sync - runs once per user, syncs ALL notifications
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) {
|
||||
setLoading(!electricClient);
|
||||
return;
|
||||
}
|
||||
|
||||
const userSyncKey = `notifications_${userId}`;
|
||||
if (userSyncKeyRef.current === userSyncKey) {
|
||||
// Already syncing for this user
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
userSyncKeyRef.current = userSyncKey;
|
||||
|
||||
async function startUserSync() {
|
||||
try {
|
||||
console.log("[useNotifications] Starting user-level sync for:", userId);
|
||||
|
||||
// Sync ALL notifications for this user (cached via syncShape caching)
|
||||
const handle = await electricClient.syncShape({
|
||||
table: "notifications",
|
||||
where: `user_id = '${userId}'`,
|
||||
primaryKey: ["id"],
|
||||
});
|
||||
|
||||
console.log("[useNotifications] User sync started:", {
|
||||
isUpToDate: handle.isUpToDate,
|
||||
});
|
||||
|
||||
// Wait for initial sync with timeout
|
||||
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||
try {
|
||||
await Promise.race([
|
||||
handle.initialSyncPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, 2000)),
|
||||
]);
|
||||
} catch (syncErr) {
|
||||
console.error("[useNotifications] Initial sync failed:", syncErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
handle.unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
syncHandleRef.current = handle;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
console.error("[useNotifications] Failed to start user sync:", err);
|
||||
setError(err instanceof Error ? err : new Error("Failed to sync notifications"));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
startUserSync();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
userSyncKeyRef.current = null;
|
||||
|
||||
if (syncHandleRef.current) {
|
||||
syncHandleRef.current.unsubscribe();
|
||||
syncHandleRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, electricClient]);
|
||||
|
||||
// EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes
|
||||
// This runs independently of sync, allowing smooth transitions between search spaces
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
async function updateQuery() {
|
||||
// Clean up previous live query (but DON'T clear notifications - keep showing old until new arrive)
|
||||
if (liveQueryRef.current) {
|
||||
liveQueryRef.current.unsubscribe();
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
"[useNotifications] Updating query for searchSpace:",
|
||||
searchSpaceId,
|
||||
"typeFilter:",
|
||||
typeFilter
|
||||
);
|
||||
|
||||
// Build query with optional type filter
|
||||
const baseQuery = `SELECT * FROM notifications
|
||||
WHERE user_id = $1
|
||||
AND (search_space_id = $2 OR search_space_id IS NULL)`;
|
||||
const typeClause = typeFilter ? ` AND type = $3` : "";
|
||||
const orderClause = ` ORDER BY created_at DESC`;
|
||||
const fullQuery = baseQuery + typeClause + orderClause;
|
||||
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
|
||||
|
||||
// Fetch notifications for current search space immediately
|
||||
const result = await electricClient.db.query<Notification>(fullQuery, params);
|
||||
|
||||
if (mounted) {
|
||||
setNotifications(result.rows || []);
|
||||
}
|
||||
|
||||
// Set up live query for real-time updates
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = electricClient.db as any;
|
||||
|
||||
if (db.live?.query && typeof db.live.query === "function") {
|
||||
const liveQuery = await db.live.query(fullQuery, params);
|
||||
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial results from live query
|
||||
if (liveQuery.initialResults?.rows) {
|
||||
setNotifications(liveQuery.initialResults.rows);
|
||||
} else if (liveQuery.rows) {
|
||||
setNotifications(liveQuery.rows);
|
||||
}
|
||||
|
||||
// Subscribe to changes
|
||||
if (typeof liveQuery.subscribe === "function") {
|
||||
liveQuery.subscribe((result: { rows: Notification[] }) => {
|
||||
if (mounted && result.rows) {
|
||||
setNotifications(result.rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof liveQuery.unsubscribe === "function") {
|
||||
liveQueryRef.current = liveQuery;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useNotifications] Failed to update query:", err);
|
||||
}
|
||||
}
|
||||
|
||||
updateQuery();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (liveQueryRef.current) {
|
||||
liveQueryRef.current.unsubscribe();
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
||||
|
||||
// EFFECT 3: Total unread count - independent of type filter
|
||||
// This ensures the badge count stays consistent regardless of active filter
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
async function updateUnreadCount() {
|
||||
// Clean up previous live query
|
||||
if (unreadCountLiveQueryRef.current) {
|
||||
unreadCountLiveQueryRef.current.unsubscribe();
|
||||
unreadCountLiveQueryRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const countQuery = `SELECT COUNT(*) as count FROM notifications
|
||||
WHERE user_id = $1
|
||||
AND (search_space_id = $2 OR search_space_id IS NULL)
|
||||
AND read = false`;
|
||||
|
||||
// Fetch initial count
|
||||
const result = await electricClient.db.query<{ count: number }>(countQuery, [
|
||||
userId,
|
||||
searchSpaceId,
|
||||
]);
|
||||
|
||||
if (mounted && result.rows?.[0]) {
|
||||
setTotalUnreadCount(Number(result.rows[0].count) || 0);
|
||||
}
|
||||
|
||||
// Set up live query for real-time updates
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = electricClient.db as any;
|
||||
|
||||
if (db.live?.query && typeof db.live.query === "function") {
|
||||
const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
|
||||
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial results from live query
|
||||
if (liveQuery.initialResults?.rows?.[0]) {
|
||||
setTotalUnreadCount(Number(liveQuery.initialResults.rows[0].count) || 0);
|
||||
} else if (liveQuery.rows?.[0]) {
|
||||
setTotalUnreadCount(Number(liveQuery.rows[0].count) || 0);
|
||||
}
|
||||
|
||||
// Subscribe to changes
|
||||
if (typeof liveQuery.subscribe === "function") {
|
||||
liveQuery.subscribe((result: { rows: { count: number }[] }) => {
|
||||
if (mounted && result.rows?.[0]) {
|
||||
setTotalUnreadCount(Number(result.rows[0].count) || 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof liveQuery.unsubscribe === "function") {
|
||||
unreadCountLiveQueryRef.current = liveQuery;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useNotifications] Failed to update unread count:", err);
|
||||
}
|
||||
}
|
||||
|
||||
updateUnreadCount();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (unreadCountLiveQueryRef.current) {
|
||||
unreadCountLiveQueryRef.current.unsubscribe();
|
||||
unreadCountLiveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, searchSpaceId, electricClient]);
|
||||
|
||||
// Mark notification as read via backend API
|
||||
const markAsRead = useCallback(async (notificationId: number) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`,
|
||||
{ method: "PATCH" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Failed to mark as read" }));
|
||||
throw new Error(error.detail || "Failed to mark notification as read");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to mark notification as read:", err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Mark all notifications as read via backend API
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
|
||||
{ method: "PATCH" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
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");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to mark all notifications as read:", err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount: totalUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
110
surfsense_web/lib/apis/notifications-api.service.ts
Normal file
110
surfsense_web/lib/apis/notifications-api.service.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import {
|
||||
type GetNotificationsRequest,
|
||||
type GetNotificationsResponse,
|
||||
type GetUnreadCountResponse,
|
||||
type MarkAllNotificationsReadResponse,
|
||||
type MarkNotificationReadRequest,
|
||||
type MarkNotificationReadResponse,
|
||||
getNotificationsRequest,
|
||||
getNotificationsResponse,
|
||||
getUnreadCountResponse,
|
||||
markAllNotificationsReadResponse,
|
||||
markNotificationReadRequest,
|
||||
markNotificationReadResponse,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class NotificationsApiService {
|
||||
/**
|
||||
* Get notifications with pagination
|
||||
*/
|
||||
getNotifications = async (
|
||||
request: GetNotificationsRequest
|
||||
): Promise<GetNotificationsResponse> => {
|
||||
const parsedRequest = getNotificationsRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { queryParams } = parsedRequest.data;
|
||||
|
||||
// Build query string from params
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (queryParams.search_space_id !== undefined) {
|
||||
params.append("search_space_id", String(queryParams.search_space_id));
|
||||
}
|
||||
if (queryParams.type) {
|
||||
params.append("type", queryParams.type);
|
||||
}
|
||||
if (queryParams.before_date) {
|
||||
params.append("before_date", queryParams.before_date);
|
||||
}
|
||||
if (queryParams.limit !== undefined) {
|
||||
params.append("limit", String(queryParams.limit));
|
||||
}
|
||||
if (queryParams.offset !== undefined) {
|
||||
params.append("offset", String(queryParams.offset));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/notifications${queryString ? `?${queryString}` : ""}`,
|
||||
getNotificationsResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark a single notification as read
|
||||
*/
|
||||
markAsRead = async (
|
||||
request: MarkNotificationReadRequest
|
||||
): Promise<MarkNotificationReadResponse> => {
|
||||
const parsedRequest = markNotificationReadRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { notificationId } = parsedRequest.data;
|
||||
|
||||
return baseApiService.patch(
|
||||
`/api/v1/notifications/${notificationId}/read`,
|
||||
markNotificationReadResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
*/
|
||||
markAllAsRead = async (): Promise<MarkAllNotificationsReadResponse> => {
|
||||
return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get unread notification count with split between total and recent
|
||||
* - total_unread: All unread notifications
|
||||
* - recent_unread: Unread within sync window (last 14 days)
|
||||
*/
|
||||
getUnreadCount = async (searchSpaceId?: number): Promise<GetUnreadCountResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (searchSpaceId !== undefined) {
|
||||
params.append("search_space_id", String(searchSpaceId));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/notifications/unread-count${queryString ? `?${queryString}` : ""}`,
|
||||
getUnreadCountResponse
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const notificationsApiService = new NotificationsApiService();
|
||||
|
|
@ -53,8 +53,9 @@ const activeSyncHandles = new Map<string, SyncHandle>();
|
|||
const pendingSyncs = new Map<string, Promise<SyncHandle>>();
|
||||
|
||||
// Version for sync state - increment this to force fresh sync when Electric config changes
|
||||
// Set to v2 for user-specific database architecture
|
||||
const SYNC_VERSION = 2;
|
||||
// v2: user-specific database architecture
|
||||
// v3: consistent cutoff date for sync+queries, visibility refresh support
|
||||
const SYNC_VERSION = 3;
|
||||
|
||||
// Database name prefix for identifying SurfSense databases
|
||||
const DB_PREFIX = "surfsense-";
|
||||
|
|
|
|||
|
|
@ -692,7 +692,23 @@
|
|||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"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",
|
||||
"all": "All",
|
||||
"unread": "Unread",
|
||||
"connectors": "Connectors",
|
||||
"all_connectors": "All connectors"
|
||||
},
|
||||
"errors": {
|
||||
"something_went_wrong": "Something went wrong",
|
||||
|
|
|
|||
|
|
@ -677,7 +677,23 @@
|
|||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"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": "文档和连接器更新将显示在这里",
|
||||
"filter": "筛选",
|
||||
"all": "全部",
|
||||
"unread": "未读",
|
||||
"connectors": "连接器",
|
||||
"all_connectors": "所有连接器"
|
||||
},
|
||||
"errors": {
|
||||
"something_went_wrong": "出错了",
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@
|
|||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
|
|
|
|||
18
surfsense_web/pnpm-lock.yaml
generated
18
surfsense_web/pnpm-lock.yaml
generated
|
|
@ -260,6 +260,9 @@ importers:
|
|||
unist-util-visit:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
vaul:
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
zod:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
|
|
@ -6384,6 +6387,12 @@ packages:
|
|||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
||||
vaul@1.1.2:
|
||||
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
vfile-location@5.0.3:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
|
|
@ -13451,6 +13460,15 @@ snapshots:
|
|||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
vfile-location@5.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue