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}
);