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:
@@ -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() {
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
|
||||
{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 && (
|
||||
<div className="mt-1">
|
||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
||||
@@ -868,39 +885,62 @@ export function LeftSidebar() {
|
||||
</div>
|
||||
{sharedFolders.map((sf) => {
|
||||
const isSelected = currentSection === `folder-${sf.id}`
|
||||
const PermissionIcon = sf.permission === 'write' ? Pencil : Eye
|
||||
return (
|
||||
<div
|
||||
key={sf.id}
|
||||
className={cn(
|
||||
'flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
||||
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||
)}
|
||||
style={{ paddingLeft: '20px' }}
|
||||
onClick={() =>
|
||||
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
||||
}
|
||||
>
|
||||
<Users
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 flex-shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-text-muted'
|
||||
)}
|
||||
/>
|
||||
<span className="truncate" title={sf.name}>
|
||||
{sf.name}
|
||||
</span>
|
||||
<span className="ml-0.5 truncate text-[10px] text-text-faint">
|
||||
{sf.owner_username}
|
||||
</span>
|
||||
<span className="ml-auto rounded bg-surface-offset px-1 text-[9px] uppercase text-text-faint">
|
||||
{sf.permission}
|
||||
</span>
|
||||
{sf.photo_count > 0 && (
|
||||
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
|
||||
{sf.photo_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ContextMenu key={sf.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[24px] cursor-pointer items-center gap-1.5 rounded px-2 text-[12px] leading-none',
|
||||
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||
)}
|
||||
style={{ paddingLeft: '20px' }}
|
||||
onClick={() =>
|
||||
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
||||
}
|
||||
>
|
||||
<Avatar name={sf.owner_username} size="xs" />
|
||||
<span className="truncate" title={`${sf.name} (shared by ${sf.owner_username})`}>
|
||||
{sf.name}
|
||||
</span>
|
||||
<PermissionIcon
|
||||
className="ml-auto h-3 w-3 flex-shrink-0 text-text-muted"
|
||||
aria-label={sf.permission === 'write' ? 'Can edit' : 'Can view'}
|
||||
/>
|
||||
{sf.photo_count > 0 && (
|
||||
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
|
||||
{sf.photo_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
||||
}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 text-text-muted" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="text-reject focus:bg-reject/10 focus:text-reject"
|
||||
onSelect={async () => {
|
||||
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))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
Leave
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -922,6 +962,11 @@ export function LeftSidebar() {
|
||||
<Shield className="inline h-2.5 w-2.5" />
|
||||
</span>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<NotificationBell />
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-reject flex-shrink-0"
|
||||
|
||||
Reference in New Issue
Block a user