diff --git a/backend/alembic/versions/0014_share_status.py b/backend/alembic/versions/0014_share_status.py new file mode 100644 index 0000000..5654064 --- /dev/null +++ b/backend/alembic/versions/0014_share_status.py @@ -0,0 +1,67 @@ +"""Add status + accepted_at to heap_shares and folder_shares + +Revision ID: 0014_share_status +Revises: 0013_drop_old_ct +Create Date: 2026-04-21 + +Shares used to activate instantly on the owner's side. We now want a +pending/accepted lifecycle so the recipient gets a notification bell and +chooses to accept or decline before the shared item shows up in their +sidebar. + +Backfill note: every pre-existing row is treated as `accepted` with +accepted_at = created_at. This is a pragmatic fiction — it keeps the +sidebar populated after the migration without anyone having to click +accept on shares that were already live. Any future "accepted X ago" UI +inheriting this backfilled timestamp should be aware it's not a real +user-action moment. + +The one-migration trick: we add `status` with `server_default="accepted"` +so the backfill happens in-place, then drop the default so new inserts +fall through to the Python-side model default ("pending"). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0014_share_status" +down_revision: Union[str, None] = "0013_drop_old_ct" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + for table in ("heap_shares", "folder_shares"): + op.add_column( + table, + sa.Column( + "status", + sa.String(), + nullable=False, + server_default="accepted", + ), + ) + op.add_column( + table, + sa.Column("accepted_at", sa.DateTime(), nullable=True), + ) + op.execute( + f"UPDATE {table} SET accepted_at = created_at " + "WHERE accepted_at IS NULL" + ) + # Drop the DB default so new rows inherit the Python-side + # model default ("pending") instead of silently auto-accepting. + op.alter_column(table, "status", server_default=None) + op.create_index( + f"ix_{table}_shared_with_status", + table, + ["shared_with_id", "status"], + ) + + +def downgrade() -> None: + for table in ("heap_shares", "folder_shares"): + op.drop_index(f"ix_{table}_shared_with_status", table_name=table) + op.drop_column(table, "accepted_at") + op.drop_column(table, "status") diff --git a/backend/app/models/sharing.py b/backend/app/models/sharing.py index 29164a8..cd64492 100644 --- a/backend/app/models/sharing.py +++ b/backend/app/models/sharing.py @@ -24,12 +24,19 @@ class HeapShare(Base): owner_id = Column(String, ForeignKey("users.id"), nullable=False) shared_with_id = Column(String, ForeignKey("users.id"), nullable=False) permission = Column(String, nullable=False, default="read") # 'read' | 'write' + # Lifecycle: 'pending' while the recipient hasn't acted, 'accepted' + # once they've Accept'd in the notification bell. Decline deletes the + # row outright — see migration 0014 for the backfill of pre-existing + # rows to 'accepted' so nothing vanishes from existing sidebars. + status = Column(String, nullable=False, default="pending") created_at = Column(DateTime, server_default=func.now()) + accepted_at = Column(DateTime, nullable=True) __table_args__ = ( UniqueConstraint("heap_id", "shared_with_id", name="uq_heap_share"), Index("ix_heap_shares_shared_with", "shared_with_id"), Index("ix_heap_shares_heap_id", "heap_id"), + Index("ix_heap_shares_shared_with_status", "shared_with_id", "status"), ) @@ -43,10 +50,13 @@ class FolderShare(Base): owner_id = Column(String, ForeignKey("users.id"), nullable=False) shared_with_id = Column(String, ForeignKey("users.id"), nullable=False) permission = Column(String, nullable=False, default="read") # 'read' | 'write' + status = Column(String, nullable=False, default="pending") created_at = Column(DateTime, server_default=func.now()) + accepted_at = Column(DateTime, nullable=True) __table_args__ = ( UniqueConstraint("folder_id", "shared_with_id", name="uq_folder_share"), Index("ix_folder_shares_shared_with", "shared_with_id"), Index("ix_folder_shares_folder_id", "folder_id"), + Index("ix_folder_shares_shared_with_status", "shared_with_id", "status"), ) diff --git a/backend/app/routers/sharing.py b/backend/app/routers/sharing.py index 9620f4e..6eb402c 100644 --- a/backend/app/routers/sharing.py +++ b/backend/app/routers/sharing.py @@ -39,11 +39,16 @@ class ShareResponse(BaseModel): shared_with_id: str shared_with_username: str permission: str + status: str # 'pending' | 'accepted' created_at: str class SharedHeapResponse(BaseModel): + # `id` is the heap id (used for navigation). `share_id` is the + # heap_shares row id, needed so the recipient can "Leave" via the + # existing DELETE endpoint without a separate lookup. id: str + share_id: str name: str owner_username: str permission: str @@ -52,6 +57,7 @@ class SharedHeapResponse(BaseModel): class SharedFolderResponse(BaseModel): id: str + share_id: str name: str folder_type: str owner_username: str @@ -59,6 +65,22 @@ class SharedFolderResponse(BaseModel): photo_count: int +class PendingInvite(BaseModel): + """A share that exists in the DB but hasn't been accepted yet. Powers + the notification bell in the left-sidebar user section.""" + share_id: str + target_id: str # heap id or folder id + target_name: str + owner_username: str + permission: str + created_at: str + + +class PendingInvitesResponse(BaseModel): + heaps: list[PendingInvite] + folders: list[PendingInvite] + + class ShareableUser(BaseModel): id: str username: str @@ -87,6 +109,69 @@ async def list_shareable_users( ] +# ── Pending invites (recipient-facing, cross-type) ─────────────────────── + +@router.get("/pending", response_model=PendingInvitesResponse) +async def list_pending_invites( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Every share targeting the current user that's still waiting on + them to accept. Feeds the notification bell in the sidebar.""" + heap_rows = (await db.execute( + select(HeapShare, Heap, User) + .join(Heap, HeapShare.heap_id == Heap.id) + .join(User, HeapShare.owner_id == User.id) + .where(HeapShare.shared_with_id == current_user.id) + .where(HeapShare.status == "pending") + )).all() + + heaps = [ + PendingInvite( + share_id=share.id, + target_id=heap.id, + target_name=heap.name, + owner_username=owner.username, + permission=share.permission, + created_at=share.created_at.isoformat() if share.created_at else "", + ) + for share, heap, owner in heap_rows + ] + + folder_rows = (await db.execute( + select(FolderShare, User) + .join(User, FolderShare.owner_id == User.id) + .where(FolderShare.shared_with_id == current_user.id) + .where(FolderShare.status == "pending") + )).all() + + folders: list[PendingInvite] = [] + for share, owner in folder_rows: + if share.folder_type == "source_root": + entity = (await db.execute( + select(SourceRoot).where(SourceRoot.id == share.folder_id) + )).scalar_one_or_none() + else: + entity = (await db.execute( + select(Folder).where(Folder.id == share.folder_id) + )).scalar_one_or_none() + # If the underlying folder was deleted while an invite was + # still pending, just skip — the share is effectively orphaned + # and the owner's revoke path will clean it up. + if entity is None: + continue + folders.append(PendingInvite( + share_id=share.id, + target_id=share.folder_id, + target_name=entity.name, + owner_username=owner.username, + permission=share.permission, + created_at=share.created_at.isoformat() if share.created_at else "", + )) + + return PendingInvitesResponse(heaps=heaps, folders=folders) + + # ── Heap sharing ───────────────────────────────────────────────────────── @router.get("/heaps/shared-with-me") @@ -94,12 +179,14 @@ async def list_shared_heaps( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """List all heaps that have been shared with the current user.""" + """List all accepted heap shares for the current user. Pending + invites are hidden here and surfaced via /sharing/pending instead.""" result = await db.execute( select(HeapShare, Heap, User) .join(Heap, HeapShare.heap_id == Heap.id) .join(User, HeapShare.owner_id == User.id) .where(HeapShare.shared_with_id == current_user.id) + .where(HeapShare.status == "accepted") ) rows = result.all() @@ -115,6 +202,7 @@ async def list_shared_heaps( items.append(SharedHeapResponse( id=heap.id, + share_id=share.id, name=heap.name, owner_username=owner.username, permission=share.permission, @@ -143,6 +231,7 @@ async def list_heap_shares( shared_with_id=user.id, shared_with_username=user.username, permission=share.permission, + status=share.status, created_at=share.created_at.isoformat() if share.created_at else "", ) for share, user in result.all() @@ -186,6 +275,54 @@ async def share_heap( return {"status": "shared", "share_id": share.id} +@router.post("/heaps/{heap_id}/accept", status_code=200) +async def accept_heap_share( + heap_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Recipient accepts a pending heap invite. Idempotent — if the + share is already accepted, returns 200 anyway so double-clicks in + the notification popover are harmless.""" + result = await db.execute( + select(HeapShare).where( + HeapShare.heap_id == heap_id, + HeapShare.shared_with_id == current_user.id, + ) + ) + share = result.scalar_one_or_none() + if share is None: + raise HTTPException(status_code=404, detail="Invite not found") + if share.status != "accepted": + share.status = "accepted" + share.accepted_at = func.now() + await db.commit() + return {"status": "accepted"} + + +@router.post("/heaps/{heap_id}/decline", status_code=200) +async def decline_heap_share( + heap_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Recipient declines a pending heap invite. The share row is + deleted — there's no separate 'declined' status. A re-invite just + creates a fresh pending row.""" + result = await db.execute( + select(HeapShare).where( + HeapShare.heap_id == heap_id, + HeapShare.shared_with_id == current_user.id, + ) + ) + share = result.scalar_one_or_none() + if share is None: + raise HTTPException(status_code=404, detail="Invite not found") + await db.delete(share) + await db.commit() + return {"status": "declined"} + + @router.delete("/heaps/{heap_id}/{share_id}", status_code=204) async def revoke_heap_share( heap_id: str, @@ -217,11 +354,14 @@ async def list_shared_folders( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """List all folders/source roots shared with the current user.""" + """List all accepted folder/source-root shares for the current + user. Pending invites are hidden here and surfaced via + /sharing/pending instead.""" result = await db.execute( select(FolderShare, User) .join(User, FolderShare.owner_id == User.id) .where(FolderShare.shared_with_id == current_user.id) + .where(FolderShare.status == "accepted") ) rows = result.all() @@ -270,6 +410,7 @@ async def list_shared_folders( count = count_result.scalar() or 0 items.append(SharedFolderResponse( id=share.folder_id, + share_id=share.id, name=name, folder_type=share.folder_type, owner_username=owner.username, @@ -314,6 +455,7 @@ async def list_folder_shares( shared_with_id=user.id, shared_with_username=user.username, permission=share.permission, + status=share.status, created_at=share.created_at.isoformat() if share.created_at else "", ) for share, user in result.all() @@ -370,6 +512,50 @@ async def share_folder( return {"status": "shared", "share_id": share.id} +@router.post("/folders/{folder_id}/accept", status_code=200) +async def accept_folder_share( + folder_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Recipient accepts a pending folder invite. Idempotent.""" + result = await db.execute( + select(FolderShare).where( + FolderShare.folder_id == folder_id, + FolderShare.shared_with_id == current_user.id, + ) + ) + share = result.scalar_one_or_none() + if share is None: + raise HTTPException(status_code=404, detail="Invite not found") + if share.status != "accepted": + share.status = "accepted" + share.accepted_at = func.now() + await db.commit() + return {"status": "accepted"} + + +@router.post("/folders/{folder_id}/decline", status_code=200) +async def decline_folder_share( + folder_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Recipient declines a pending folder invite. Row is deleted.""" + result = await db.execute( + select(FolderShare).where( + FolderShare.folder_id == folder_id, + FolderShare.shared_with_id == current_user.id, + ) + ) + share = result.scalar_one_or_none() + if share is None: + raise HTTPException(status_code=404, detail="Invite not found") + await db.delete(share) + await db.commit() + return {"status": "declined"} + + @router.delete("/folders/{folder_id}/{share_id}", status_code=204) async def revoke_folder_share( folder_id: str, diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx index de16b00..bb561c9 100644 --- a/frontend/src/components/heaps/HeapsPanel.tsx +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -11,13 +11,31 @@ import { Copy, Trash2, Users, + Eye, + LogOut, Download as DownloadIcon, } from 'lucide-react' import { cn } from '@/lib/utils' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' -import { useSharedHeapsQuery } from '../../hooks/useSharingQueries' -import { heaps as heapsApi, downloads, type Heap } from '../../services/api' +import { + useSharedHeapsQuery, + SHARED_HEAPS_KEY, +} from '../../hooks/useSharingQueries' +import { + heaps as heapsApi, + downloads, + sharing as sharingApi, + type Heap, +} from '../../services/api' +import { Avatar } from '../sharing/Avatar' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu' import { useFilterStore } from '../../store/filterStore' import { toast } from '../ToastContainer' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' @@ -479,7 +497,9 @@ export function HeapsPanel() { )} - {/* Shared with me */} + {/* Shared with me — mirrors the folder treatment in LeftSidebar: + * owner avatar + Eye/Pencil permission icon + right-click + * context menu with Open / Leave. */} {sharedHeaps.length > 0 && expanded && (
@@ -487,39 +507,62 @@ export function HeapsPanel() {
{sharedHeaps.map((sh) => { const isFiltered = currentSection === `heap-${sh.id}` + const PermissionIcon = sh.permission === 'write' ? Pencil : Eye return ( -
- navigateToSection(`heap-${sh.id}`, { heapId: sh.id }) - } - > - - - {sh.name} - - - {sh.owner_username} - - - {sh.permission} - - {sh.photo_count > 0 && ( - - {sh.photo_count} - - )} -
+ + +
+ navigateToSection(`heap-${sh.id}`, { heapId: sh.id }) + } + > + + + {sh.name} + + + {sh.photo_count > 0 && ( + + {sh.photo_count} + + )} +
+
+ + + navigateToSection(`heap-${sh.id}`, { heapId: sh.id }) + } + > + + Open + + + { + try { + await sharingApi.revokeHeapShare(sh.id, sh.share_id) + queryClient.invalidateQueries({ queryKey: SHARED_HEAPS_KEY }) + toast.success(`Left ${sh.name}`) + } catch (err) { + toast.error('Could not leave', formatApiError(err)) + } + }} + > + + Leave + + +
) })}
diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 25e537d..a4d8910 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -49,7 +49,20 @@ import type { Photo } from '../../types/photo' import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog' import { ShareDialog } from '../sharing/ShareDialog' import { UploadModal } from '../upload/UploadModal' -import { useSharedFoldersQuery } from '../../hooks/useSharingQueries' +import { sharing as sharingApi } from '../../services/api' +import { + useSharedFoldersQuery, + SHARED_FOLDERS_KEY, +} from '../../hooks/useSharingQueries' +import { NotificationBell } from '../sharing/NotificationBell' +import { Avatar } from '../sharing/Avatar' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu' import { useAuth } from '../../contexts/AuthContext' import { useFeaturesQuery } from '../../hooks/useFeaturesQuery' import { useScanActivity } from '../../hooks/useScanActivity' @@ -860,7 +873,11 @@ export function LeftSidebar() {
{libraryTree.map((item) => renderTreeItem(item))} - {/* Shared with me — folders shared by other users */} + {/* Shared with me — folders shared by other users. Each row + * shows the owner's avatar bubble (same hash-tinted palette + * as ShareDialog + the notification bell) and an Eye/Pencil + * icon for permission, so the vocabulary stays consistent + * across every sharing surface. */} {sharedFolders.length > 0 && (
@@ -868,39 +885,62 @@ export function LeftSidebar() {
{sharedFolders.map((sf) => { const isSelected = currentSection === `folder-${sf.id}` + const PermissionIcon = sf.permission === 'write' ? Pencil : Eye return ( -
- navigateToSection(`folder-${sf.id}`, { folderId: sf.id }) - } - > - - - {sf.name} - - - {sf.owner_username} - - - {sf.permission} - - {sf.photo_count > 0 && ( - - {sf.photo_count} - - )} -
+ + +
+ navigateToSection(`folder-${sf.id}`, { folderId: sf.id }) + } + > + + + {sf.name} + + + {sf.photo_count > 0 && ( + + {sf.photo_count} + + )} +
+
+ + + navigateToSection(`folder-${sf.id}`, { folderId: sf.id }) + } + > + + Open + + + { + try { + await sharingApi.revokeFolderShare(sf.id, sf.share_id) + queryClient.invalidateQueries({ queryKey: SHARED_FOLDERS_KEY }) + toast.success(`Left ${sf.name}`) + } catch (err) { + toast.error('Could not leave', formatApiError(err)) + } + }} + > + + Leave + + +
) })}
@@ -922,6 +962,11 @@ export function LeftSidebar() { )} + {/* Share-invite bell — click opens a popover listing any + * pending invites this user has. Sits here (rather than the + * TopBar) per the user's preference to keep the identity + * controls grouped. */} + + + +
+
Pending invites
+ {count > 0 && ( + {count} + )} +
+ + {count === 0 ? ( +
+ No pending invites. +
+ ) : ( +
    + {invites.map((invite) => { + const TypeIcon = invite.kind === 'heap' ? Layers : Folder + return ( +
  • +
    + +
    +
    + {invite.owner_username} + shared + + + {invite.target_name} + +
    +
    + {invite.permission === 'write' ? ( + <> + + Can edit + + ) : ( + <> + + Can view + + )} +
    +
    +
    +
    + + +
    +
  • + ) + })} +
+ )} +
+ + ) +} diff --git a/frontend/src/components/sharing/ShareDialog.tsx b/frontend/src/components/sharing/ShareDialog.tsx index 0595d87..20b29f9 100644 --- a/frontend/src/components/sharing/ShareDialog.tsx +++ b/frontend/src/components/sharing/ShareDialog.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Users, Eye, Pencil, X, Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' +import { Avatar } from './Avatar' import { sharing, type ShareInfo, @@ -260,8 +261,18 @@ export function ShareDialog({ >
-
- {share.shared_with_username} +
+ + {share.shared_with_username} + + {share.status === 'pending' && ( + + Invited + + )}
{share.permission === 'write' ? ( @@ -303,35 +314,3 @@ export function ShareDialog({ ) } -// ── Helpers ──────────────────────────────────────────────────────────── - -/** Circular initial bubble. Hashes the username into one of a small - * set of stable palette tints so each person's avatar reads - * consistently across the app — the colour isn't meaningful, just an - * identity cue that makes a list of names feel less anonymous. */ -function Avatar({ name, size = 'md' }: { name: string; size?: 'sm' | 'md' }) { - const initials = name.slice(0, 2).toUpperCase() - const palette = [ - 'bg-primary/25 text-primary', - 'bg-pick/25 text-pick', - 'bg-blue-500/25 text-blue-300', - 'bg-purple-500/25 text-purple-300', - 'bg-amber-500/25 text-amber-300', - ] - let hash = 0 - for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0 - const tint = palette[Math.abs(hash) % palette.length] - const dims = size === 'sm' ? 'h-5 w-5 text-[9px]' : 'h-8 w-8 text-[11px]' - return ( - - {initials} - - ) -} diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..fb72142 --- /dev/null +++ b/frontend/src/components/ui/context-menu.tsx @@ -0,0 +1,76 @@ +import * as React from 'react' +import * as ContextMenuPrimitive from '@radix-ui/react-context-menu' + +import { cn } from '@/lib/utils' + +const ContextMenu = ContextMenuPrimitive.Root +const ContextMenuTrigger = ContextMenuPrimitive.Trigger +const ContextMenuGroup = ContextMenuPrimitive.Group +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuLabel, + ContextMenuGroup, + ContextMenuPortal, +} diff --git a/frontend/src/hooks/useSharingQueries.ts b/frontend/src/hooks/useSharingQueries.ts index 26516ee..0815899 100644 --- a/frontend/src/hooks/useSharingQueries.ts +++ b/frontend/src/hooks/useSharingQueries.ts @@ -1,8 +1,17 @@ -import { useQuery } from '@tanstack/react-query' -import { sharing, type SharedHeap, type SharedFolder } from '../services/api' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + sharing, + type PendingSharesResponse, + type SharedFolder, + type SharedHeap, +} from '../services/api' +import { useAuth } from '../contexts/AuthContext' +import { formatApiError } from '../lib/apiError' +import { toast } from '../components/ToastContainer' export const SHARED_HEAPS_KEY = ['sharing', 'heaps'] as const export const SHARED_FOLDERS_KEY = ['sharing', 'folders'] as const +export const PENDING_SHARES_KEY = ['sharing', 'pending'] as const export function useSharedHeapsQuery() { return useQuery({ @@ -19,3 +28,76 @@ export function useSharedFoldersQuery() { staleTime: 30_000, }) } + +/** Pending invites powering the notification bell in the sidebar. + * Polls at 60s — invites don't need real-time, and the user can also + * manually trigger the popover to force a refetch. Gated on the + * auth'd user so we don't hammer /sharing/pending from the login + * screen before any tokens are available. */ +export function usePendingSharesQuery() { + const { user } = useAuth() + return useQuery({ + queryKey: PENDING_SHARES_KEY, + queryFn: sharing.pendingShares, + enabled: !!user, + staleTime: 30_000, + refetchInterval: 60_000, + // Keep the stale data visible while the refetch is in flight — + // prevents the bell badge from flickering to 0 on every poll. + placeholderData: (prev) => prev, + }) +} + +interface ShareAction { + kind: 'heap' | 'folder' + targetId: string +} + +/** Invalidate both the pending-list and the accepted-list keys after + * accept/decline/revoke-self so the bell count and the sidebar + * "Shared with me" section update together. */ +function invalidateShareKeys(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: PENDING_SHARES_KEY }) + queryClient.invalidateQueries({ queryKey: SHARED_HEAPS_KEY }) + queryClient.invalidateQueries({ queryKey: SHARED_FOLDERS_KEY }) +} + +export function useAcceptShareMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ kind, targetId }: ShareAction) => + kind === 'heap' + ? sharing.acceptHeapShare(targetId) + : sharing.acceptFolderShare(targetId), + onSuccess: () => invalidateShareKeys(queryClient), + onError: (err: any) => { + // 404 = the owner revoked (or we raced a previous decline). + // Surface a softer message and refresh the list instead of a + // hard error toast. + if (err?.response?.status === 404) { + toast.info('Invite no longer available', 'The sender may have revoked it.') + invalidateShareKeys(queryClient) + return + } + toast.error('Could not accept', formatApiError(err)) + }, + }) +} + +export function useDeclineShareMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ kind, targetId }: ShareAction) => + kind === 'heap' + ? sharing.declineHeapShare(targetId) + : sharing.declineFolderShare(targetId), + onSuccess: () => invalidateShareKeys(queryClient), + onError: (err: any) => { + if (err?.response?.status === 404) { + invalidateShareKeys(queryClient) + return + } + toast.error('Could not decline', formatApiError(err)) + }, + }) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0facf51..b40a48d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -785,6 +785,9 @@ export const downloads = { export interface SharedHeap { id: string + /** heap_shares row id — used by the "Leave" action to call the + * existing DELETE /sharing/heaps/{heap_id}/{share_id} endpoint. */ + share_id: string name: string owner_username: string permission: 'read' | 'write' @@ -793,6 +796,7 @@ export interface SharedHeap { export interface SharedFolder { id: string + share_id: string name: string folder_type: 'folder' | 'source_root' owner_username: string @@ -805,6 +809,9 @@ export interface ShareInfo { shared_with_id: string shared_with_username: string permission: string + /** 'pending' until the recipient accepts in the notification bell, + * then 'accepted'. Decline deletes the row outright. */ + status: 'pending' | 'accepted' created_at: string } @@ -813,6 +820,23 @@ export interface ShareableUser { username: string } +/** One entry in the combined pending-shares response. `target_id` is + * the heap or folder id — heap vs folder is distinguished by which + * bucket (`heaps` or `folders`) the entry appears in. */ +export interface PendingShare { + share_id: string + target_id: string + target_name: string + owner_username: string + permission: 'read' | 'write' + created_at: string +} + +export interface PendingSharesResponse { + heaps: PendingShare[] + folders: PendingShare[] +} + export const sharing = { // Shareable user directory for the share-dialog picker. listUsers: async (): Promise => { @@ -853,6 +877,27 @@ export const sharing = { const response = await api.get('/sharing/folders/shared-with-me') return response.data }, + + // Acceptance flow — pending invites + accept/decline. The notification + // bell polls pendingShares; Accept/Decline call the respective + // endpoint with just the target id (backend resolves the share row + // from (target_id, current_user)). + pendingShares: async (): Promise => { + const response = await api.get('/sharing/pending') + return response.data + }, + acceptHeapShare: async (heapId: string) => { + await api.post(`/sharing/heaps/${heapId}/accept`) + }, + declineHeapShare: async (heapId: string) => { + await api.post(`/sharing/heaps/${heapId}/decline`) + }, + acceptFolderShare: async (folderId: string) => { + await api.post(`/sharing/folders/${folderId}/accept`) + }, + declineFolderShare: async (folderId: string) => { + await api.post(`/sharing/folders/${folderId}/decline`) + }, } // Tags API