From e1d2bf11da375db9edabb94d953bd06d08b5f660 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 12 Apr 2026 13:18:10 -0700 Subject: [PATCH] feat: soft-delete particles within a stream (#149) Lets a particle's creator delete their own message from the TopBar dropdown. Other viewers see a "This particle was deleted" tombstone in place and playback auto-advances after ~2s, keeping indices stable for concurrent watchers. - Add optional deleted_at / deleted_by_human_id to non-container particle variants and isParticleDeleted helper. - Add softDeleteParticle Firestore helper. - New DeleteParticleOverlay confirmation and DeletedParticleView tombstone. - Hide reactions (bar + 1-7 keybinding) on tombstoned particles. - Show "Deleted particle" + Trash2 icon in the stream list preview. Closes #146 https://claude.ai/code/session_01M2ShnZPvWfQzzvuu3Xm8b9 Co-authored-by: Claude --- js/src/api/types.ts | 25 ++++- .../particles/delete-particle-overlay.tsx | 94 +++++++++++++++++++ .../particles/deleted-particle-view.tsx | 50 ++++++++++ .../features/particles/particle-list-view.tsx | 5 +- js/src/features/particles/stream-top-bar.tsx | 33 ++++++- js/src/features/particles/stream-view.tsx | 18 +++- js/src/lib/firestore-particles.ts | 22 +++++ 7 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 js/src/features/particles/delete-particle-overlay.tsx create mode 100644 js/src/features/particles/deleted-particle-view.tsx diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 38835a8..5e1ae03 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -160,6 +160,16 @@ export type PaperProperties = z.infer; export const ReactionsSchema = z.record(z.string(), z.array(z.string())).optional(); export type Reactions = z.infer; +// --- Tombstone (soft-delete) --- + +// Fields added to non-container particles when their creator deletes them. +// We keep the doc around so concurrent viewers can see a "This particle was +// deleted" message in place, rather than being jumped to the next particle. +const TombstoneFields = { + deleted_at: z.coerce.date().optional(), + deleted_by_human_id: z.string().optional(), +}; + export const REACTION_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F525}", "\u{1F440}", "\u{2705}", "\u{2753}", "\u{1F602}"] as const; export interface ParticlePropertiesMap { @@ -203,11 +213,11 @@ export const ParticleSchema = z.discriminatedUnion("type", [ // e.g. ["network:123"] - visible to everyone in the network visible_to: z.array(z.string()), }), - ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema }), - ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }), - ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema }), - ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema }), - ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }), + ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema, ...TombstoneFields }), + ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }), + ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema, ...TombstoneFields }), + ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema, ...TombstoneFields }), ]); export type Particle = z.infer; @@ -221,6 +231,11 @@ export function isContainerType(type: ParticleType): boolean { return CONTAINER_TYPES.has(type); } +/** True when a non-container particle has been soft-deleted (tombstoned). */ +export function isParticleDeleted(particle: Particle): boolean { + return "deleted_at" in particle && particle.deleted_at != null; +} + // --- LiveKit types --- export const GetLivekitTokenResponseSchema = z.object({ diff --git a/js/src/features/particles/delete-particle-overlay.tsx b/js/src/features/particles/delete-particle-overlay.tsx new file mode 100644 index 0000000..3287c5c --- /dev/null +++ b/js/src/features/particles/delete-particle-overlay.tsx @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { softDeleteParticle } from "@/lib/firestore-particles"; +import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; +import type { Particle } from "@/api/types"; + +interface DeleteParticleOverlayProps { + networkId: string; + streamId: string; + particle: Particle; + userId: string; + onClose: () => void; +} + +export function DeleteParticleOverlay({ + networkId, + streamId, + particle, + userId, + onClose, +}: DeleteParticleOverlayProps) { + const [deleting, setDeleting] = useState(false); + + const handleDelete = useCallback(async () => { + if (deleting) return; + setDeleting(true); + try { + const docPath = toFirestoreDocPath( + particlePath(networkId, [streamId, particle.id]), + ); + await softDeleteParticle(docPath, userId); + toast.success("Particle deleted"); + onClose(); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to delete particle"; + toast.error(message); + setDeleting(false); + } + }, [deleting, networkId, onClose, particle.id, streamId, userId]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", handler, { capture: true }); + return () => window.removeEventListener("keydown", handler, { capture: true }); + }, [onClose]); + + return createPortal( +
+
+
+
+

Delete this particle?

+ + + Esc + {" "} + to close + +
+ +

+ This cannot be undone. Other viewers will see a "This particle was + deleted" message in its place. +

+ +
+ + +
+
+
, + document.body, + ); +} diff --git a/js/src/features/particles/deleted-particle-view.tsx b/js/src/features/particles/deleted-particle-view.tsx new file mode 100644 index 0000000..885bd1a --- /dev/null +++ b/js/src/features/particles/deleted-particle-view.tsx @@ -0,0 +1,50 @@ +import { useEffect } from "react"; +import { Trash2 } from "lucide-react"; +import type { Particle } from "@/api/types"; +import { useNetwork } from "@/hooks/use-networks"; + +// How long to linger on a tombstone before auto-advancing. Matches the +// "reading" cadence of a short text particle. +const TOMBSTONE_DURATION_MS = 2000; + +interface DeletedParticleViewProps { + particle: Particle; + networkId: string; + paused: boolean; + onEnded: () => void; +} + +export function DeletedParticleView({ + particle, + networkId, + paused, + onEnded, +}: DeletedParticleViewProps) { + const network = useNetwork(networkId); + const deleterId = + "deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined; + const deleter = deleterId + ? network?.humans?.find((h) => h.id === deleterId) + : undefined; + + useEffect(() => { + if (paused) return; + + const timeout = setTimeout(onEnded, TOMBSTONE_DURATION_MS); + return () => clearTimeout(timeout); + }, [paused, onEnded, particle.id]); + + return ( +
+
+ +

+ This particle was deleted +

+ {deleter && ( +

by {deleter.email_prefix}

+ )} +
+
+ ); +} diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index 3491f4d..529fd20 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -10,6 +10,7 @@ import { CircleCheck, StickyNote, Headphones, + Trash2, type LucideIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -23,13 +24,14 @@ import { Separator } from "@/components/ui/separator"; import { Progress } from "@/components/ui/progress"; import { Small } from "@/components/ui/typography"; import { Button } from "@/components/ui/button"; -import type { Particle, StreamProperties } from "@/api/types"; +import { isParticleDeleted, type Particle, type StreamProperties } from "@/api/types"; import type { StreamParticle } from "@/hooks/use-stream-particles"; import { useNetwork } from "@/hooks/use-networks"; import { useStreamAutoplay } from "@/hooks/use-stream-autoplay"; import { StreamContextMenu } from "@/features/particles/stream-context-menu"; function getParticleTypeIcon(particle: Particle): LucideIcon { + if (isParticleDeleted(particle)) return Trash2; switch (particle.type) { case "text": return MessageSquare; @@ -52,6 +54,7 @@ function getParticleTypeIcon(particle: Particle): LucideIcon { } function getMessagePreview(particle: Particle): string { + if (isParticleDeleted(particle)) return "Deleted particle"; switch (particle.type) { case "text": return particle.properties.content; diff --git a/js/src/features/particles/stream-top-bar.tsx b/js/src/features/particles/stream-top-bar.tsx index 3d1c7d2..8124a4b 100644 --- a/js/src/features/particles/stream-top-bar.tsx +++ b/js/src/features/particles/stream-top-bar.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAuthStore } from "@/stores/auth-store"; import { apiClient } from "@/api/client"; -import type { Particle } from "@/api/types"; +import { isParticleDeleted, type Particle } from "@/api/types"; import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -14,9 +14,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Settings, CircleCheckBig, CircleDot, EllipsisVertical, Pencil, Globe } from "lucide-react"; +import { Settings, CircleCheckBig, CircleDot, EllipsisVertical, Pencil, Globe, Trash2 } from "lucide-react"; import { updateStreamStatus } from "@/lib/firestore-particles"; import { RenameStreamOverlay } from "@/features/particles/rename-stream-overlay"; +import { DeleteParticleOverlay } from "@/features/particles/delete-particle-overlay"; import { StreamMembersOverlay } from "@/features/particles/stream-members-overlay"; import { parseVisibleTo } from "@/lib/stream-visibility"; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"; @@ -56,6 +57,15 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) { const isCreator = !!userId && userId === streamParticle.created_by_human_id; const [renameOpen, setRenameOpen] = useState(false); const [membersOpen, setMembersOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const canDeleteParticle = + !!particle && + !!userId && + particle.created_by_human_id === userId && + particle.type !== "stream" && + particle.type !== "folder" && + !isParticleDeleted(particle); const huddleParticipants = streamParticle.huddle_active_participants ?? []; const hasActiveHuddle = huddleParticipants.length > 0; @@ -170,6 +180,15 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) { Rename stream )} + {canDeleteParticle && ( + setDeleteOpen(true)} + variant="destructive" + > + + Delete particle + + )} navigate("/settings")}> Settings @@ -185,6 +204,16 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) { /> )} + {deleteOpen && canDeleteParticle && particle && userId && ( + setDeleteOpen(false)} + /> + )} + {membersOpen && ( | undefined { + if (isParticleDeleted(particle)) return undefined; if (particle.type === "media" || particle.type === "text") return particle.reactions; return undefined; } @@ -176,6 +178,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { const handleToggleReaction = useCallback((emoji: string) => { if (!authedUser || !currentParticle) return; + if (isParticleDeleted(currentParticle)) return; const currentParticleDocPath = currentParticle @@ -370,6 +373,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { // Render particle content inline function renderParticle(particle: Particle) { + if (isParticleDeleted(particle)) { + return ( + + ); + } switch (particle.type) { case "media": return ( @@ -432,7 +446,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
{/* Reaction bar — always visible */} - {currentParticle && ( + {currentParticle && !isParticleDeleted(currentParticle) && (
= { toFirestore(particle: Particle): DocumentData { const { id: _id, created_at, updated_at, ...rest } = particle; + const deletedAt = "deleted_at" in particle ? particle.deleted_at : undefined; return { ...rest, created_at: Timestamp.fromDate(created_at), ...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }), + ...(deletedAt && { deleted_at: Timestamp.fromDate(deletedAt) }), }; }, fromFirestore( @@ -99,6 +101,8 @@ const particleConverter: FirestoreDataConverter = { created_by_human_id: raw.created_by_human_id, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, reactions: raw.reactions ?? undefined, + deleted_at: raw.deleted_at ? (raw.deleted_at as Timestamp).toDate() : undefined, + deleted_by_human_id: raw.deleted_by_human_id ?? undefined, }); } default: @@ -364,6 +368,24 @@ export async function updateStreamStatus( await updateDoc(particleRef, { status, updated_at: serverTimestamp() }); } +/** + * Soft-delete (tombstone) a non-container particle. The Firestore doc stays + * in place so concurrent viewers see the deletion inline rather than being + * bumped to an adjacent particle. Idempotent — re-calling on an already + * tombstoned doc just refreshes the timestamp. + */ +export async function softDeleteParticle( + docPath: string, + humanId: string, +): Promise { + const particleRef = typedDoc(docPath); + await updateDoc(particleRef, { + deleted_at: serverTimestamp(), + deleted_by_human_id: humanId, + updated_at: serverTimestamp(), + }); +} + export async function updateStreamPlaybackMarker( docPath: string, humanId: string,