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