feat(sharing): pending-state invites, notification bell, sidebar polish

Shares used to activate instantly on the owner's side with no notice to
the recipient. Introduce a pending/accepted lifecycle so a recipient
gets a bell notification on login and explicitly Accept or Decline
before the shared item lands in their sidebar.

Backend
- Migration 0014 adds `status` + `accepted_at` to heap_shares and
  folder_shares; pre-existing rows are backfilled to 'accepted' so
  nothing disappears from anyone's current sidebar. One-migration trick:
  server_default 'accepted' during add_column, then strip so new inserts
  fall through to the Python model default 'pending'.
- New recipient-only endpoints: POST /sharing/{heaps|folders}/{id}/accept
  (idempotent) and /decline (hard delete, so re-invites are clean).
- New GET /sharing/pending returning {heaps, folders} of outstanding
  invites with target_name + owner_username + permission.
- list_shared_{heaps,folders} now filter to status='accepted' and carry
  share_id so the recipient can Leave without a second lookup.
- ShareResponse exposes status so the owner sees pending invites.

Frontend
- NotificationBell lives in the LeftSidebar user row: a Popover
  triggered by Bell with a count badge. Each row shows owner avatar,
  "{owner} shared {heap|folder} {name}" with a permission subtitle,
  and Accept / Decline inline. Polls /sharing/pending every 60s.
- Shared Avatar helper extracted to sharing/Avatar.tsx — used by
  ShareDialog, NotificationBell, and the sidebar shared rows so one
  user's identity colour is stable everywhere.
- Sidebar shared-row polish: owner avatar bubble + Eye/Pencil
  permission icon (was uppercase pill). Right-click opens a context
  menu with Open / Leave; Leave calls the existing recipient-revoke
  DELETE and invalidates the shared-{heaps,folders} query.
- ShareDialog shows an amber "Invited" pill next to pending recipients.
- New shadcn context-menu primitive (radix dep already installed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 23:17:53 +02:00
parent 11343c17dc
commit 319be20389
11 changed files with 845 additions and 107 deletions

View File

@@ -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<SharedHeap[]>({
@@ -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<PendingSharesResponse>({
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<typeof useQueryClient>) {
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))
},
})
}