diff --git a/js/mobile/src/features/stream-view/EditParticleSheet.tsx b/js/mobile/src/features/stream-view/EditParticleSheet.tsx new file mode 100644 index 0000000..2f480df --- /dev/null +++ b/js/mobile/src/features/stream-view/EditParticleSheet.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from "react"; +import { Pressable, Text, TextInput, View } from "react-native"; +import { toast } from "sonner-native"; +import { cn } from "@/lib/utils"; +import { toUserMessage } from "@/lib/errors"; +import { editTextParticleContent } from "@/lib/firestore-particles"; +import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; +import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; +import { BottomSheet } from "@/components/BottomSheet"; + +interface EditParticleSheetProps { + open: boolean; + onClose: () => void; + networkId: string; + streamId: string; + particleId: string; + currentContent: string; +} + +export function EditParticleSheet({ + open, + onClose, + networkId, + streamId, + particleId, + currentContent, +}: EditParticleSheetProps) { + useSuspendPlayback(open, "edit-particle"); + + const [content, setContent] = useState(currentContent); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + setContent(currentContent); + setSaving(false); + } + }, [open, currentContent]); + + const trimmed = content.trim(); + const canSave = !saving && trimmed.length > 0 && trimmed !== currentContent; + + const handleSave = async () => { + if (!canSave) return; + setSaving(true); + try { + const docPath = toFirestoreDocPath( + particlePath(networkId, [streamId, particleId]), + ); + await editTextParticleContent(docPath, trimmed); + onClose(); + } catch (err) { + toast.error(toUserMessage(err)); + setSaving(false); + } + }; + + return ( + + + + Cancel + + Edit + + + {saving ? "Saving..." : "Save"} + + + + + + + + ); +} diff --git a/js/mobile/src/features/stream-view/StreamActionsSheet.tsx b/js/mobile/src/features/stream-view/StreamActionsSheet.tsx index ddb2a2d..948f4de 100644 --- a/js/mobile/src/features/stream-view/StreamActionsSheet.tsx +++ b/js/mobile/src/features/stream-view/StreamActionsSheet.tsx @@ -14,6 +14,7 @@ export type StreamActionId = | "toggle-status" | "rename" | "members" + | "edit-particle" | "delete-particle"; interface StreamActionsSheetProps { @@ -22,6 +23,8 @@ interface StreamActionsSheetProps { onSelect: (action: StreamActionId) => void; streamStatus: "open" | "closed"; isCreator: boolean; + /** True when the *current* particle is a text particle this user authored. */ + canEditParticle: boolean; /** True when the *current* particle is one this user can soft-delete. */ canDeleteParticle: boolean; } @@ -32,6 +35,7 @@ export function StreamActionsSheet({ onSelect, streamStatus, isCreator, + canEditParticle, canDeleteParticle, }: StreamActionsSheetProps) { // Hold the picked action until the sheet's Modal has fully unmounted, then @@ -82,6 +86,13 @@ export function StreamActionsSheet({ onPress={() => choose("rename")} /> ) : null} + {canEditParticle ? ( + } + label="Edit particle" + onPress={() => choose("edit-particle")} + /> + ) : null} {canDeleteParticle ? ( } diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index 33a6da2..348f2a1 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -60,6 +60,7 @@ import { StreamTopActions } from "./StreamTopActions"; import { StreamActionsSheet, type StreamActionId } from "./StreamActionsSheet"; import { StreamMembersSheet } from "./StreamMembersSheet"; import { RenameStreamSheet } from "./RenameStreamSheet"; +import { EditParticleSheet } from "./EditParticleSheet"; import { ReactionStack } from "./ReactionStack"; const SCREEN_HEIGHT = Dimensions.get("window").height; @@ -125,6 +126,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { const [actionsOpen, setActionsOpen] = useState(false); const [membersOpen, setMembersOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); const [videoFit, setVideoFit] = useState<"cover" | "contain">("cover"); const isCreator = !!userId && userId === streamParticle.created_by_human_id; @@ -135,6 +137,16 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { currentParticle.type !== "stream" && currentParticle.type !== "folder" && !isParticleDeleted(currentParticle); + const canEditCurrentParticle = + !!currentParticle && + !!userId && + currentParticle.created_by_human_id === userId && + currentParticle.type === "text" && + !isParticleDeleted(currentParticle); + const editableTextParticle = + canEditCurrentParticle && currentParticle && currentParticle.type === "text" + ? currentParticle + : null; const showFitToggle = !!currentParticle && @@ -165,6 +177,10 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { case "members": setMembersOpen(true); return; + case "edit-particle": + if (!canEditCurrentParticle) return; + setEditOpen(true); + return; case "delete-particle": { if (!currentParticle || !userId) return; if (!canDeleteCurrentParticle) return; @@ -203,6 +219,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { currentParticle, userId, canDeleteCurrentParticle, + canEditCurrentParticle, ], ); @@ -634,6 +651,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { onSelect={(action) => void handleStreamAction(action)} streamStatus={streamParticle.status ?? "open"} isCreator={isCreator} + canEditParticle={canEditCurrentParticle} canDeleteParticle={canDeleteCurrentParticle} /> @@ -652,6 +670,17 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { streamId={streamParticle.id} currentName={streamParticle.properties.name} /> + + {editableTextParticle ? ( + setEditOpen(false)} + networkId={networkId} + streamId={streamParticle.id} + particleId={editableTextParticle.id} + currentContent={editableTextParticle.properties.content} + /> + ) : null} ); diff --git a/js/mobile/src/features/stream-view/TextParticleView.tsx b/js/mobile/src/features/stream-view/TextParticleView.tsx index f4aead1..d15182f 100644 --- a/js/mobile/src/features/stream-view/TextParticleView.tsx +++ b/js/mobile/src/features/stream-view/TextParticleView.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { ScrollView, Text, View } from "react-native"; import type { Particle } from "@/api/types"; import { cn } from "@/lib/utils"; +import { RelativeTimestamp } from "@/components/RelativeTimestamp"; import { useStreamSafeArea } from "./stream-safe-area"; type TextParticle = Extract; @@ -43,10 +44,19 @@ export function TextParticleView({ onProgress, }: TextParticleViewProps) { const content = particle.properties.content; + const editedAt = particle.properties.edited_at; const durationS = computeReadDuration(content); const elapsedRef = useRef(0); const safe = useStreamSafeArea(); + const editedLabel = editedAt ? ( + + + edited + + + ) : null; + // Reset when the particle changes. useEffect(() => { elapsedRef.current = 0; @@ -85,6 +95,7 @@ export function TextParticleView({ > {content} + {editedLabel} ); } @@ -108,6 +119,7 @@ export function TextParticleView({ indicatorStyle="white" > {content} + {editedLabel} );