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:
@@ -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))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user