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 <[email protected]>
This commit was merged in pull request #149.
This commit is contained in:
Arjun Patel
2026-04-12 13:18:10 -07:00
committed by GitHub
co-authored by Claude
parent 6b20fc2ee6
commit e1d2bf11da
7 changed files with 237 additions and 10 deletions
+16 -2
View File
@@ -2,13 +2,14 @@ import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react"
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { apiClient } from "@/api/client";
import { type Particle, REACTION_EMOJIS } from "@/api/types";
import { isParticleDeleted, type Particle, REACTION_EMOJIS } from "@/api/types";
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay";
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
import { TextParticleView } from "@/features/particles/text-particle-view";
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
import { DeletedParticleView } from "@/features/particles/deleted-particle-view";
import { VideoAudioToggle } from "@/components/video-audio-toggle";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay";
@@ -25,6 +26,7 @@ import { cn } from "@/lib/utils";
import { useMount } from "react-use";
function getReactions(particle: Particle): Record<string, string[]> | 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 (
<DeletedParticleView
key={particle.id}
particle={particle}
networkId={networkId}
paused={paused}
onEnded={next}
/>
);
}
switch (particle.type) {
case "media":
return (
@@ -432,7 +446,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
</div>
{/* Reaction bar — always visible */}
{currentParticle && (
{currentParticle && !isParticleDeleted(currentParticle) && (
<div className="absolute right-4 top-1/2 -translate-y-1/2 z-10">
<ReactionBar
reactions={getReactions(currentParticle)}