diff --git a/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py b/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py
new file mode 100644
index 000000000..99962e501
--- /dev/null
+++ b/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py
@@ -0,0 +1,51 @@
+"""Add archived column to notifications table
+
+Revision ID: 73
+Revises: 72
+
+Adds an archived boolean column to the notifications table to allow users
+to archive inbox items without deleting them.
+
+NOTE: Electric SQL automatically picks up schema changes when REPLICA IDENTITY FULL
+is set (which was done in migration 66). We re-affirm it here to ensure replication
+continues to work after adding the new column.
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "73"
+down_revision: str | None = "72"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Add archived column to notifications table."""
+ # Add the archived column with a default value
+ op.execute(
+ """
+ ALTER TABLE notifications
+ ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;
+ """
+ )
+
+ # Create index for archived column
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_notifications_archived ON notifications (archived);"
+ )
+
+ # Re-affirm REPLICA IDENTITY FULL for Electric SQL after schema change
+ # This ensures Electric SQL continues to replicate all columns including the new one
+ op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;")
+
+
+def downgrade() -> None:
+ """Remove archived column from notifications table."""
+ op.execute("DROP INDEX IF EXISTS ix_notifications_archived;")
+ op.execute("ALTER TABLE notifications DROP COLUMN IF EXISTS archived;")
+ # Re-affirm REPLICA IDENTITY FULL after removing the column
+ op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;")
+
diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py
index 38e27ecf2..b969f9e55 100644
--- a/surfsense_backend/app/db.py
+++ b/surfsense_backend/app/db.py
@@ -784,6 +784,9 @@ 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),
diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py
index deee748d8..3bf7a4880 100644
--- a/surfsense_backend/app/routes/notifications_routes.py
+++ b/surfsense_backend/app/routes/notifications_routes.py
@@ -30,6 +30,19 @@ 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,
@@ -100,3 +113,41 @@ 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}",
+ )
diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
index fb1a6ed0d..6be4809cf 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
@@ -238,20 +238,9 @@ export function AllPrivateChatsSidebar({
aria-label={t("chats") || "Private Chats"}
>
-
-
-
-
{t("chats") || "Private Chats"}
-
-
onOpenChange(false)}
- >
-
- Close
-
+
+
+
{t("chats") || "Private Chats"}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
index f400e6fc8..ea80cc920 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
@@ -238,20 +238,9 @@ export function AllSharedChatsSidebar({
aria-label={t("shared_chats") || "Shared Chats"}
>
-
-
-
-
{t("shared_chats") || "Shared Chats"}
-
-
onOpenChange(false)}
- >
-
- Close
-
+
+
+
{t("shared_chats") || "Shared Chats"}
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index 4171ac267..9f5a50bc4 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -143,15 +143,16 @@ 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 as InboxItem & { archived?: boolean }).archived);
+ 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 as InboxItem & { archived?: boolean }).archived);
+ items = items.filter((item) => !item.read && item.archived !== true);
} else if (activeFilter === "archived") {
- // "Archived" shows only archived items
- items = items.filter((item) => (item as InboxItem & { archived?: boolean }).archived);
+ // "Archived" shows only archived items (must be explicitly true)
+ items = items.filter((item) => item.archived === true);
}
// Apply search query
@@ -340,7 +341,7 @@ export function InboxSidebar({
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
- className="fixed inset-y-0 left-0 z-70 w-96 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
+ 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"}
@@ -500,7 +501,7 @@ export function InboxSidebar({
const isMarkingAsRead = markingAsReadId === item.id;
const isArchiving = archivingItemId === item.id;
const isBusy = isMarkingAsRead || isArchiving;
- const isArchived = (item as InboxItem & { archived?: boolean }).archived;
+ const isArchived = item.archived === true;
return (
{convertRenderedToDisplay(item.message)}
+ {/* Mobile action buttons - shown below description on mobile only */}
+
+ {!item.read && (
+
{
+ e.stopPropagation();
+ handleMarkAsRead(item.id);
+ }}
+ disabled={isBusy}
+ >
+ {isMarkingAsRead ? (
+
+ ) : (
+
+ )}
+ {t("mark_as_read") || "Mark as read"}
+
+ )}
+
{
+ e.stopPropagation();
+ handleToggleArchive(item.id, isArchived);
+ }}
+ disabled={isArchiving}
+ >
+ {isArchiving ? (
+
+ ) : isArchived ? (
+
+ ) : (
+
+ )}
+ {isArchived ? (t("unarchive") || "Restore") : (t("archive") || "Archive")}
+
+
@@ -544,8 +586,8 @@ export function InboxSidebar({
- {/* Time/dot and 3-dot button container - swap on hover */}
-
+ {/* Time/dot and 3-dot button container - swap on hover (desktop only) */}
+
{/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */}
)}
handleToggleArchive(item.id, !!isArchived)}
+ onClick={() => handleToggleArchive(item.id, isArchived)}
disabled={isArchiving}
>
{isArchived ? (
@@ -623,6 +665,16 @@ export function InboxSidebar({
+
+ {/* Mobile time and unread dot - always visible on mobile */}
+
+
+ {formatTime(item.created_at)}
+
+ {!item.read && (
+
+ )}
+
);
})}
diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts
index 515ba5864..2e80a9909 100644
--- a/surfsense_web/contracts/types/inbox.types.ts
+++ b/surfsense_web/contracts/types/inbox.types.ts
@@ -110,6 +110,7 @@ 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(),
diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts
index 514185d23..222553f32 100644
--- a/surfsense_web/lib/electric/client.ts
+++ b/surfsense_web/lib/electric/client.ts
@@ -53,8 +53,9 @@ const activeSyncHandles = new Map
();
const pendingSyncs = new Map>();
// 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: added archived column to notifications
+const SYNC_VERSION = 3;
// Database name prefix for identifying SurfSense databases
const DB_PREFIX = "surfsense-";
@@ -181,6 +182,7 @@ export async function initElectric(userId: string): Promise {
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
@@ -188,6 +190,7 @@ export async function initElectric(userId: string): Promise {
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