feat(mobile): edit text messages
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user