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

@@ -0,0 +1,54 @@
import { cn } from '@/lib/utils'
/** Hash-tinted initial bubble used across every sharing surface
* (ShareDialog, NotificationBell, sidebar shared rows). The tint
* isn't meaningful — it's just an identity cue so a list of names
* feels less anonymous. Using the same hash across components means
* a given user's bubble stays the same colour everywhere. */
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',
]
export function avatarColor(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) | 0
}
return PALETTE[Math.abs(hash) % PALETTE.length]
}
export function Avatar({
name,
size = 'md',
className,
}: {
name: string
size?: 'xs' | 'sm' | 'md'
className?: string
}) {
const initials = name.slice(0, 2).toUpperCase()
const tint = avatarColor(name)
const dims =
size === 'xs'
? 'h-4 w-4 text-[8px]'
: size === 'sm'
? 'h-5 w-5 text-[9px]'
: 'h-8 w-8 text-[11px]'
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-full font-semibold uppercase tracking-wide',
dims,
tint,
className,
)}
aria-hidden
>
{initials}
</span>
)
}

View File

@@ -0,0 +1,151 @@
import { useMemo, useState } from 'react'
import { Bell, Layers, Folder, Eye, Pencil, Check, X, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
usePendingSharesQuery,
useAcceptShareMutation,
useDeclineShareMutation,
} from '../../hooks/useSharingQueries'
import { toast } from '../ToastContainer'
import type { PendingShare } from '../../services/api'
import { Avatar } from './Avatar'
/** Share invite notification bell. Sits in the LeftSidebar user
* section — polls /sharing/pending every 60s and shows a red-orange
* count badge when there are pending invites. Click opens a popover
* listing each invite with Accept / Decline inline. */
export function NotificationBell() {
const [open, setOpen] = useState(false)
const { data } = usePendingSharesQuery()
const acceptMutation = useAcceptShareMutation()
const declineMutation = useDeclineShareMutation()
const invites = useMemo(() => {
if (!data) return [] as Array<PendingShare & { kind: 'heap' | 'folder' }>
return [
...data.heaps.map((h) => ({ ...h, kind: 'heap' as const })),
...data.folders.map((f) => ({ ...f, kind: 'folder' as const })),
]
}, [data])
const count = invites.length
const isMutating = acceptMutation.isPending || declineMutation.isPending
const handleAccept = (invite: PendingShare & { kind: 'heap' | 'folder' }) => {
acceptMutation.mutate(
{ kind: invite.kind, targetId: invite.target_id },
{
onSuccess: () => {
toast.success(`Joined ${invite.target_name}`)
},
},
)
}
const handleDecline = (invite: PendingShare & { kind: 'heap' | 'folder' }) => {
declineMutation.mutate({ kind: invite.kind, targetId: invite.target_id })
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'relative inline-flex h-7 w-7 items-center justify-center rounded-md text-text-muted transition-colors',
'hover:bg-surface-2 hover:text-text',
count > 0 && 'text-text',
)}
aria-label={count > 0 ? `${count} pending invite${count === 1 ? '' : 's'}` : 'Notifications'}
title={count > 0 ? `${count} pending invite${count === 1 ? '' : 's'}` : 'No pending invites'}
>
<Bell className="h-4 w-4" />
{count > 0 && (
<span className="absolute -right-0.5 -top-0.5 inline-flex min-w-[14px] items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold leading-[14px] tabular-nums text-black shadow-[0_0_0_1.5px_var(--bg,theme(colors.bg))]">
{count}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-80 p-0">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<div className="text-sm font-semibold text-text">Pending invites</div>
{count > 0 && (
<span className="text-[11px] tabular-nums text-text-faint">{count}</span>
)}
</div>
{count === 0 ? (
<div className="px-3 py-4 text-xs text-text-muted">
No pending invites.
</div>
) : (
<ul className="max-h-80 divide-y divide-border overflow-y-auto">
{invites.map((invite) => {
const TypeIcon = invite.kind === 'heap' ? Layers : Folder
return (
<li
key={`${invite.kind}:${invite.share_id}`}
className="flex flex-col gap-2 px-3 py-2.5"
>
<div className="flex items-start gap-2.5">
<Avatar name={invite.owner_username} size="sm" />
<div className="min-w-0 flex-1">
<div className="text-sm leading-tight text-text">
<span className="font-medium">{invite.owner_username}</span>
<span className="text-text-muted"> shared </span>
<span className="inline-flex items-center gap-1 align-baseline">
<TypeIcon className="inline h-3 w-3 text-text-muted" />
<span className="font-medium">{invite.target_name}</span>
</span>
</div>
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-text-muted">
{invite.permission === 'write' ? (
<>
<Pencil className="h-2.5 w-2.5" />
Can edit
</>
) : (
<>
<Eye className="h-2.5 w-2.5" />
Can view
</>
)}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
disabled={isMutating}
onClick={() => handleDecline(invite)}
className="h-7 text-text-muted hover:bg-reject/10 hover:text-reject"
>
<X className="mr-1 h-3.5 w-3.5" />
Decline
</Button>
<Button
size="sm"
disabled={isMutating}
onClick={() => handleAccept(invite)}
className="h-7"
>
{acceptMutation.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Check className="mr-1 h-3.5 w-3.5" />
)}
Accept
</Button>
</div>
</li>
)
})}
</ul>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -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({
>
<Avatar name={share.shared_with_username} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-text">
{share.shared_with_username}
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium text-text">
{share.shared_with_username}
</span>
{share.status === 'pending' && (
<span
className="rounded-sm bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300"
title="Waiting for them to accept"
>
Invited
</span>
)}
</div>
<div className="flex items-center gap-1 text-[11px] text-text-muted">
{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 (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-full font-semibold uppercase tracking-wide',
dims,
tint
)}
aria-hidden
>
{initials}
</span>
)
}