feat(mobile): edit text messages

This commit is contained in:
Arjun Patel
2026-05-26 10:10:49 -07:00
parent ec01ca77e4
commit e7fed5f037
4 changed files with 142 additions and 0 deletions
@@ -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 (
<BottomSheet open={open} onClose={onClose} avoidKeyboard>
<View className="flex-row items-center justify-between px-5 pb-3">
<Pressable onPress={onClose} hitSlop={12}>
<Text className="text-white/70 text-base">Cancel</Text>
</Pressable>
<Text className="text-white text-base font-semibold">Edit</Text>
<Pressable onPress={handleSave} disabled={!canSave} hitSlop={12}>
<Text
className={cn(
"text-base font-semibold",
canSave ? "text-white" : "text-white/30",
)}
>
{saving ? "Saving..." : "Save"}
</Text>
</Pressable>
</View>
<View className="px-5 pb-6">
<TextInput
value={content}
onChangeText={setContent}
autoFocus
multiline
placeholder="Message"
placeholderTextColor="rgba(255,255,255,0.3)"
className="bg-white/10 rounded-xl px-4 py-3 text-white text-lg min-h-[120px]"
textAlignVertical="top"
/>
</View>
</BottomSheet>
);
}
@@ -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 ? (
<ActionRow
icon={<Pencil color="white" size={20} />}
label="Edit particle"
onPress={() => choose("edit-particle")}
/>
) : null}
{canDeleteParticle ? (
<ActionRow
icon={<Trash2 color="#ef4444" size={20} />}
@@ -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 ? (
<EditParticleSheet
open={editOpen}
onClose={() => setEditOpen(false)}
networkId={networkId}
streamId={streamParticle.id}
particleId={editableTextParticle.id}
currentContent={editableTextParticle.properties.content}
/>
) : null}
</Animated.View>
</Animated.View>
);
@@ -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<Particle, { type: "text" }>;
@@ -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 ? (
<View className="mt-3 items-center">
<Text className="text-white/40 text-xs">
edited <RelativeTimestamp date={editedAt} />
</Text>
</View>
) : null;
// Reset when the particle changes.
useEffect(() => {
elapsedRef.current = 0;
@@ -85,6 +95,7 @@ export function TextParticleView({
>
{content}
</Text>
{editedLabel}
</View>
);
}
@@ -108,6 +119,7 @@ export function TextParticleView({
indicatorStyle="white"
>
<Text className="text-white text-lg leading-relaxed">{content}</Text>
{editedLabel}
</ScrollView>
</View>
);