mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
refactor: remove archived functionality from notifications and related components
- Removed the archived column from the Notification model and database schema, simplifying the notification structure. - Deleted ArchiveRequest and ArchiveResponse models, along with associated API endpoints for archiving notifications. - Updated InboxSidebar and related components to eliminate archiving functionality, streamlining the user experience. - Adjusted filtering logic in the InboxSidebar to focus solely on unread notifications, enhancing clarity and usability.
This commit is contained in:
parent
8dcdd27d10
commit
112f6ec4cc
8 changed files with 24 additions and 279 deletions
|
|
@ -784,9 +784,6 @@ class Notification(BaseModel, TimestampMixin):
|
|||
read = Column(
|
||||
Boolean, nullable=False, default=False, server_default=text("false"), index=True
|
||||
)
|
||||
archived = Column(
|
||||
Boolean, nullable=False, default=False, server_default=text("false"), index=True
|
||||
)
|
||||
notification_metadata = Column("metadata", JSONB, nullable=True, default={})
|
||||
updated_at = Column(
|
||||
TIMESTAMP(timezone=True),
|
||||
|
|
|
|||
|
|
@ -30,19 +30,6 @@ class MarkAllReadResponse(BaseModel):
|
|||
updated_count: int
|
||||
|
||||
|
||||
class ArchiveRequest(BaseModel):
|
||||
"""Request body for archive/unarchive operations."""
|
||||
|
||||
archived: bool
|
||||
|
||||
|
||||
class ArchiveResponse(BaseModel):
|
||||
"""Response for archive operations."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read", response_model=MarkReadResponse)
|
||||
async def mark_notification_as_read(
|
||||
notification_id: int,
|
||||
|
|
@ -113,41 +100,3 @@ async def mark_all_notifications_as_read(
|
|||
message=f"Marked {updated_count} notification(s) as read",
|
||||
updated_count=updated_count,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/archive", response_model=ArchiveResponse)
|
||||
async def archive_notification(
|
||||
notification_id: int,
|
||||
request: ArchiveRequest,
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> ArchiveResponse:
|
||||
"""
|
||||
Archive or unarchive a notification.
|
||||
|
||||
Electric SQL will automatically sync this change to all connected clients.
|
||||
"""
|
||||
# Verify the notification belongs to the user
|
||||
result = await session.execute(
|
||||
select(Notification).where(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == user.id,
|
||||
)
|
||||
)
|
||||
notification = result.scalar_one_or_none()
|
||||
|
||||
if not notification:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found",
|
||||
)
|
||||
|
||||
# Update the notification
|
||||
notification.archived = request.archived
|
||||
await session.commit()
|
||||
|
||||
action = "archived" if request.archived else "unarchived"
|
||||
return ArchiveResponse(
|
||||
success=True,
|
||||
message=f"Notification {action}",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Inbox, 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";
|
||||
|
|
@ -87,7 +87,7 @@ export function LayoutDataProvider({
|
|||
|
||||
// Inbox hook
|
||||
const userId = user?.id ? String(user.id) : null;
|
||||
const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead, archiveItem } = useInbox(
|
||||
const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead } = useInbox(
|
||||
userId,
|
||||
Number(searchSpaceId) || null,
|
||||
null
|
||||
|
|
@ -551,7 +551,6 @@ export function LayoutDataProvider({
|
|||
loading={inboxLoading}
|
||||
markAsRead={markAsRead}
|
||||
markAllAsRead={markAllAsRead}
|
||||
archiveItem={archiveItem}
|
||||
/>
|
||||
|
||||
{/* Create Search Space Dialog */}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
AtSign,
|
||||
BellDot,
|
||||
Check,
|
||||
|
|
@ -12,7 +11,6 @@ import {
|
|||
Inbox,
|
||||
ListFilter,
|
||||
MoreHorizontal,
|
||||
RotateCcw,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
|
@ -29,7 +27,6 @@ import {
|
|||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -59,7 +56,7 @@ function getInitials(name: string | null | undefined, email: string | null | und
|
|||
}
|
||||
|
||||
type InboxTab = "mentions" | "status";
|
||||
type InboxFilter = "all" | "unread" | "archived";
|
||||
type InboxFilter = "all" | "unread";
|
||||
|
||||
interface InboxSidebarProps {
|
||||
open: boolean;
|
||||
|
|
@ -69,7 +66,6 @@ interface InboxSidebarProps {
|
|||
loading: boolean;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
archiveItem: (id: number, archived: boolean) => Promise<boolean>;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +77,6 @@ export function InboxSidebar({
|
|||
loading,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
archiveItem,
|
||||
onCloseMobileSidebar,
|
||||
}: InboxSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
|
@ -91,10 +86,9 @@ export function InboxSidebar({
|
|||
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
|
||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Unified dropdown state: "filter" | "options" | number (item id) | null
|
||||
const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | number | null>(null);
|
||||
// Dropdown state for filter and options menus
|
||||
const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | null>(null);
|
||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||
const [archivingItemId, setArchivingItemId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
|
@ -143,16 +137,8 @@ export function InboxSidebar({
|
|||
let items = currentTabItems;
|
||||
|
||||
// Apply filter
|
||||
// Note: Use `item.archived === true` to handle undefined/null as false
|
||||
if (activeFilter === "all") {
|
||||
// "Unread & read" shows all non-archived items
|
||||
items = items.filter((item) => item.archived !== true);
|
||||
} else if (activeFilter === "unread") {
|
||||
// "Unread" shows only unread non-archived items
|
||||
items = items.filter((item) => !item.read && item.archived !== true);
|
||||
} else if (activeFilter === "archived") {
|
||||
// "Archived" shows only archived items (must be explicitly true)
|
||||
items = items.filter((item) => item.archived === true);
|
||||
if (activeFilter === "unread") {
|
||||
items = items.filter((item) => !item.read);
|
||||
}
|
||||
|
||||
// Apply search query
|
||||
|
|
@ -168,24 +154,14 @@ export function InboxSidebar({
|
|||
return items;
|
||||
}, [currentTabItems, activeFilter, searchQuery]);
|
||||
|
||||
// Count unread items per tab (filter-aware)
|
||||
// Count unread items per tab
|
||||
const unreadMentionsCount = useMemo(() => {
|
||||
if (activeFilter === "archived") {
|
||||
// In archived view, show unread archived items
|
||||
return mentionItems.filter((item) => !item.read && item.archived === true).length;
|
||||
}
|
||||
// For "all" and "unread" filters, show unread non-archived items
|
||||
return mentionItems.filter((item) => !item.read && item.archived !== true).length;
|
||||
}, [mentionItems, activeFilter]);
|
||||
return mentionItems.filter((item) => !item.read).length;
|
||||
}, [mentionItems]);
|
||||
|
||||
const unreadStatusCount = useMemo(() => {
|
||||
if (activeFilter === "archived") {
|
||||
// In archived view, show unread archived items
|
||||
return statusItems.filter((item) => !item.read && item.archived === true).length;
|
||||
}
|
||||
// For "all" and "unread" filters, show unread non-archived items
|
||||
return statusItems.filter((item) => !item.read && item.archived !== true).length;
|
||||
}, [statusItems, activeFilter]);
|
||||
return statusItems.filter((item) => !item.read).length;
|
||||
}, [statusItems]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
|
|
@ -217,28 +193,10 @@ export function InboxSidebar({
|
|||
[markAsRead, router, onOpenChange, onCloseMobileSidebar]
|
||||
);
|
||||
|
||||
const handleMarkAsRead = useCallback(
|
||||
async (itemId: number) => {
|
||||
setMarkingAsReadId(itemId);
|
||||
await markAsRead(itemId);
|
||||
setMarkingAsReadId(null);
|
||||
},
|
||||
[markAsRead]
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
await markAllAsRead();
|
||||
}, [markAllAsRead]);
|
||||
|
||||
const handleToggleArchive = useCallback(
|
||||
async (itemId: number, currentlyArchived: boolean) => {
|
||||
setArchivingItemId(itemId);
|
||||
await archiveItem(itemId, !currentlyArchived);
|
||||
setArchivingItemId(null);
|
||||
},
|
||||
[archiveItem]
|
||||
);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
|
@ -385,7 +343,7 @@ export function InboxSidebar({
|
|||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4" />
|
||||
<span>{t("unread_and_read") || "Unread & read"}</span>
|
||||
<span>{t("all") || "All"}</span>
|
||||
</span>
|
||||
{activeFilter === "all" && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -399,16 +357,6 @@ export function InboxSidebar({
|
|||
</span>
|
||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setActiveFilter("archived")}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Archive className="h-4 w-4" />
|
||||
<span>{t("archived") || "Archived"}</span>
|
||||
</span>
|
||||
{activeFilter === "archived" && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
|
|
@ -509,18 +457,15 @@ export function InboxSidebar({
|
|||
<div className="space-y-2">
|
||||
{filteredItems.map((item) => {
|
||||
const isMarkingAsRead = markingAsReadId === item.id;
|
||||
const isArchiving = archivingItemId === item.id;
|
||||
const isBusy = isMarkingAsRead || isArchiving;
|
||||
const isArchived = item.archived === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-lg px-3 py-2 text-sm h-[72px] overflow-hidden",
|
||||
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm min-h-[72px] overflow-hidden",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"transition-colors cursor-pointer",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
isMarkingAsRead && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
|
|
@ -528,8 +473,8 @@ export function InboxSidebar({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={isBusy}
|
||||
className="flex items-start gap-3 flex-1 min-w-0 text-left overflow-hidden self-start"
|
||||
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">
|
||||
|
|
@ -544,43 +489,6 @@ export function InboxSidebar({
|
|||
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{convertRenderedToDisplay(item.message)}
|
||||
</p>
|
||||
{/* Mobile action buttons - shown below description on mobile only */}
|
||||
<div className="inline-flex items-center gap-0.5 mt-2 md:hidden bg-primary/20 rounded-md px-1 py-0.5">
|
||||
{!item.read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsRead(item.id);
|
||||
}}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("mark_as_read") || "Mark as read"}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleArchive(item.id, isArchived);
|
||||
}}
|
||||
disabled={isArchiving}
|
||||
>
|
||||
{isArchiving ? (
|
||||
<Spinner size="xs" />
|
||||
) : isArchived ? (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="sr-only">{isArchived ? (t("unarchive") || "Restore") : (t("archive") || "Archive")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -592,93 +500,13 @@ export function InboxSidebar({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Time/dot and 3-dot button container - swap on hover (desktop only) */}
|
||||
<div className="relative hidden md:flex items-center shrink-0 w-12 justify-end">
|
||||
{/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 transition-opacity duration-150",
|
||||
"group-hover:opacity-0 group-hover:pointer-events-none",
|
||||
openDropdown === item.id && "opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTime(item.created_at)}
|
||||
</span>
|
||||
{!item.read && (
|
||||
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3-dot menu - hidden by default, visible on hover or when dropdown is open */}
|
||||
<DropdownMenu
|
||||
open={openDropdown === item.id}
|
||||
onOpenChange={(isOpen) =>
|
||||
setOpenDropdown(isOpen ? item.id : null)
|
||||
}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 absolute right-0 transition-opacity duration-150",
|
||||
"opacity-0 pointer-events-none",
|
||||
"group-hover:opacity-100 group-hover:pointer-events-auto",
|
||||
openDropdown === item.id && "!opacity-100 !pointer-events-auto"
|
||||
)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isArchiving ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{t("more_options") || "More options"}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||
{!item.read && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleMarkAsRead(item.id)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<CheckCheck className="mr-2 h-4 w-4" />
|
||||
<span>{t("mark_as_read") || "Mark as read"}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleToggleArchive(item.id, isArchived)}
|
||||
disabled={isArchiving}
|
||||
>
|
||||
{isArchived ? (
|
||||
<>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
<span>{t("unarchive") || "Restore"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
<span>{t("archive") || "Archive"}</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Mobile time and unread dot - always visible on mobile */}
|
||||
<div className="flex md:hidden items-center gap-1.5 shrink-0">
|
||||
{/* 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" />
|
||||
<span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,11 +19,10 @@ const sizeClasses = {
|
|||
|
||||
export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
<output
|
||||
aria-label="Loading"
|
||||
className={cn(
|
||||
"animate-spin rounded-full",
|
||||
"block animate-spin rounded-full",
|
||||
hideTrack ? "border-transparent" : "border-current/20",
|
||||
"border-t-current",
|
||||
sizeClasses[size],
|
||||
|
|
|
|||
|
|
@ -110,7 +110,6 @@ export const inboxItem = z.object({
|
|||
title: z.string(),
|
||||
message: z.string(),
|
||||
read: z.boolean(),
|
||||
archived: z.boolean().default(false),
|
||||
metadata: z.record(z.string(), z.unknown()),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().nullable(),
|
||||
|
|
|
|||
|
|
@ -332,36 +332,11 @@ export function useInbox(
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Archive/unarchive an inbox item via backend API
|
||||
const archiveItem = useCallback(async (itemId: number, archived: boolean) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/archive`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ archived }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Failed to update archive status" }));
|
||||
throw new Error(error.detail || "Failed to update inbox item archive status");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to update inbox item archive status:", err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
inboxItems,
|
||||
unreadCount: totalUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
archiveItem,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ const pendingSyncs = new Map<string, Promise<SyncHandle>>();
|
|||
// Version for sync state - increment this to force fresh sync when Electric config changes
|
||||
// v2: user-specific database architecture
|
||||
// v3: added archived column to notifications
|
||||
const SYNC_VERSION = 3;
|
||||
// v4: removed archived column from notifications
|
||||
const SYNC_VERSION = 4;
|
||||
|
||||
// Database name prefix for identifying SurfSense databases
|
||||
const DB_PREFIX = "surfsense-";
|
||||
|
|
@ -182,7 +183,6 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
|
|||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ
|
||||
|
|
@ -190,7 +190,6 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
|
|||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_archived ON notifications(archived);
|
||||
`);
|
||||
|
||||
// Create the search_source_connectors table schema in PGlite
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue