import { useMemo, useState } from 'react' import type { RecordData } from 'deepspace' import { useMutations, useQuery, useUser } from 'deepspace' import { Mail, ShieldCheck, UserMinus, Users } from 'lucide-react' import { Modal, useToast } from '@/components/ui' import { parseDocumentsIdList, type DocumentsDocumentFields, type InviteAclDiff, } from './documents-library-types' interface UserFields { email?: string name?: string imageUrl?: string } type InviteRole = 'viewer' | 'editor' interface InviteDialogProps { open: boolean onOpenChange: (open: boolean) => void doc: RecordData isOwner: boolean /** * Called after a save mutation that changed the ACL. The parent uses it to * publish a one-shot permission-change signal over the doc's presence room * so peers (including ones who just lost read access to the doc record) * can react immediately instead of waiting for the next refresh. */ onAclChange?: (diff: InviteAclDiff) => void } function uniqueIds(ids: string[]): string[] { return [...new Set(ids.filter(Boolean))] } function initialsFor(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean) if (parts.length === 0) return '?' return parts .slice(0, 2) .map((p) => p[0]?.toUpperCase() ?? '') .join('') } export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }: InviteDialogProps) { const { user } = useUser() const { records: users, status: usersStatus } = useQuery('users') const { putConfirmed, ready: documentsMutationsReady } = useMutations('documents') const toast = useToast() const [email, setEmail] = useState('') const [role, setRole] = useState('editor') const [saving, setSaving] = useState(false) const ready = usersStatus === 'ready' && documentsMutationsReady const collaborators = useMemo( () => parseDocumentsIdList(doc.data.collaborators), [doc.data.collaborators], ) const editors = useMemo(() => parseDocumentsIdList(doc.data.editors), [doc.data.editors]) const ownerRecord = useMemo( () => users.find((u) => u.recordId === doc.data.ownerId), [doc.data.ownerId, users], ) const collaboratorRecords = useMemo( () => collaborators .map((id) => users.find((u) => u.recordId === id)) .filter((u): u is RecordData => Boolean(u)), [collaborators, users], ) if (!isOwner) return null const saveAccess = async ( nextCollaborators: string[], nextEditors: string[], ): Promise => { if (!ready) return false const prevCollaborators = parseDocumentsIdList(doc.data.collaborators) const prevEditors = parseDocumentsIdList(doc.data.editors) const nextCollabList = uniqueIds(nextCollaborators) const nextCollabSet = new Set(nextCollabList) const nextEditorList = uniqueIds(nextEditors).filter((id) => nextCollabSet.has(id)) const nextEditorSet = new Set(nextEditorList) setSaving(true) try { await putConfirmed(doc.recordId, { ...doc.data, collaborators: JSON.stringify(nextCollabList), editors: JSON.stringify(nextEditorList), }) if (onAclChange) { const removedUserIds = prevCollaborators.filter((id) => !nextCollabSet.has(id)) const demotedUserIds = prevEditors.filter( (id) => nextCollabSet.has(id) && !nextEditorSet.has(id), ) const promotedUserIds = nextEditorList.filter( (id) => prevCollaborators.includes(id) && !prevEditors.includes(id), ) if (removedUserIds.length || demotedUserIds.length || promotedUserIds.length) { onAclChange({ removedUserIds, demotedUserIds, promotedUserIds }) } } return true } catch { toast.error( 'Access not saved', 'DeepSpace did not confirm the change. Reconnect and try again.', ) return false } finally { setSaving(false) } } const addInvite = async () => { const normalized = email.trim().toLowerCase() if (!normalized) return const target = users.find((u) => u.data.email?.trim().toLowerCase() === normalized) if (!target) { toast.error( 'User not found', 'Ask them to sign in to this app once, then invite the same email again.', ) return } if (target.recordId === doc.data.ownerId || target.recordId === user?.id) { toast.info('Already has access', 'That user is the document owner.') return } if (collaborators.includes(target.recordId)) { toast.info('Already invited', `${target.data.email ?? normalized} already has access.`) return } const nextCollaborators = [...collaborators, target.recordId] const nextEditors = role === 'editor' ? [...editors, target.recordId] : editors if (!(await saveAccess(nextCollaborators, nextEditors))) return setEmail('') toast.success('Invite added', `${target.data.email ?? normalized} now has ${role} access.`) } const setCollaboratorRole = async (userId: string, nextRole: InviteRole) => { const nextEditors = nextRole === 'editor' ? uniqueIds([...editors, userId]) : editors.filter((id) => id !== userId) await saveAccess(collaborators, nextEditors) } const removeCollaborator = async (userId: string) => { await saveAccess( collaborators.filter((id) => id !== userId), editors.filter((id) => id !== userId), ) } const ownerName = ownerRecord?.data.name?.trim() || ownerRecord?.data.email?.trim() || user?.name || user?.email || 'Owner' return ( onOpenChange(false)} size="lg" className="documents-feature-scope" > Share document Invite DeepSpace users by the email address on their account.
setEmail(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void addInvite() }} placeholder="person@gmail.com" className="h-10 w-full rounded-lg border bg-transparent pl-9 pr-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30" style={{ borderColor: 'var(--documents-el-line)', color: 'var(--documents-el-text)', }} />

People with access

{initialsFor(ownerName)}
{ownerName}
{ownerRecord?.data.email ?? user?.email ?? 'Owner'}
Owner
{collaboratorRecords.map((u) => { const name = u.data.name?.trim() || u.data.email?.trim() || 'Collaborator' const userRole: InviteRole = editors.includes(u.recordId) ? 'editor' : 'viewer' return (
{u.data.imageUrl ? ( ) : (
{initialsFor(name)}
)}
{name}
{u.data.email ?? 'No email'}
) })} {collaboratorRecords.length === 0 ? (

Only the owner can access this document.

) : null}
) }