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"
|
| "toggle-status"
|
||||||
| "rename"
|
| "rename"
|
||||||
| "members"
|
| "members"
|
||||||
|
| "edit-particle"
|
||||||
| "delete-particle";
|
| "delete-particle";
|
||||||
|
|
||||||
interface StreamActionsSheetProps {
|
interface StreamActionsSheetProps {
|
||||||
@@ -22,6 +23,8 @@ interface StreamActionsSheetProps {
|
|||||||
onSelect: (action: StreamActionId) => void;
|
onSelect: (action: StreamActionId) => void;
|
||||||
streamStatus: "open" | "closed";
|
streamStatus: "open" | "closed";
|
||||||
isCreator: boolean;
|
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. */
|
/** True when the *current* particle is one this user can soft-delete. */
|
||||||
canDeleteParticle: boolean;
|
canDeleteParticle: boolean;
|
||||||
}
|
}
|
||||||
@@ -32,6 +35,7 @@ export function StreamActionsSheet({
|
|||||||
onSelect,
|
onSelect,
|
||||||
streamStatus,
|
streamStatus,
|
||||||
isCreator,
|
isCreator,
|
||||||
|
canEditParticle,
|
||||||
canDeleteParticle,
|
canDeleteParticle,
|
||||||
}: StreamActionsSheetProps) {
|
}: StreamActionsSheetProps) {
|
||||||
// Hold the picked action until the sheet's Modal has fully unmounted, then
|
// Hold the picked action until the sheet's Modal has fully unmounted, then
|
||||||
@@ -82,6 +86,13 @@ export function StreamActionsSheet({
|
|||||||
onPress={() => choose("rename")}
|
onPress={() => choose("rename")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canEditParticle ? (
|
||||||
|
<ActionRow
|
||||||
|
icon={<Pencil color="white" size={20} />}
|
||||||
|
label="Edit particle"
|
||||||
|
onPress={() => choose("edit-particle")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{canDeleteParticle ? (
|
{canDeleteParticle ? (
|
||||||
<ActionRow
|
<ActionRow
|
||||||
icon={<Trash2 color="#ef4444" size={20} />}
|
icon={<Trash2 color="#ef4444" size={20} />}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { StreamTopActions } from "./StreamTopActions";
|
|||||||
import { StreamActionsSheet, type StreamActionId } from "./StreamActionsSheet";
|
import { StreamActionsSheet, type StreamActionId } from "./StreamActionsSheet";
|
||||||
import { StreamMembersSheet } from "./StreamMembersSheet";
|
import { StreamMembersSheet } from "./StreamMembersSheet";
|
||||||
import { RenameStreamSheet } from "./RenameStreamSheet";
|
import { RenameStreamSheet } from "./RenameStreamSheet";
|
||||||
|
import { EditParticleSheet } from "./EditParticleSheet";
|
||||||
import { ReactionStack } from "./ReactionStack";
|
import { ReactionStack } from "./ReactionStack";
|
||||||
|
|
||||||
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
||||||
@@ -125,6 +126,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
const [actionsOpen, setActionsOpen] = useState(false);
|
const [actionsOpen, setActionsOpen] = useState(false);
|
||||||
const [membersOpen, setMembersOpen] = useState(false);
|
const [membersOpen, setMembersOpen] = useState(false);
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [videoFit, setVideoFit] = useState<"cover" | "contain">("cover");
|
const [videoFit, setVideoFit] = useState<"cover" | "contain">("cover");
|
||||||
|
|
||||||
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
|
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
|
||||||
@@ -135,6 +137,16 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
currentParticle.type !== "stream" &&
|
currentParticle.type !== "stream" &&
|
||||||
currentParticle.type !== "folder" &&
|
currentParticle.type !== "folder" &&
|
||||||
!isParticleDeleted(currentParticle);
|
!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 =
|
const showFitToggle =
|
||||||
!!currentParticle &&
|
!!currentParticle &&
|
||||||
@@ -165,6 +177,10 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
case "members":
|
case "members":
|
||||||
setMembersOpen(true);
|
setMembersOpen(true);
|
||||||
return;
|
return;
|
||||||
|
case "edit-particle":
|
||||||
|
if (!canEditCurrentParticle) return;
|
||||||
|
setEditOpen(true);
|
||||||
|
return;
|
||||||
case "delete-particle": {
|
case "delete-particle": {
|
||||||
if (!currentParticle || !userId) return;
|
if (!currentParticle || !userId) return;
|
||||||
if (!canDeleteCurrentParticle) return;
|
if (!canDeleteCurrentParticle) return;
|
||||||
@@ -203,6 +219,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
currentParticle,
|
currentParticle,
|
||||||
userId,
|
userId,
|
||||||
canDeleteCurrentParticle,
|
canDeleteCurrentParticle,
|
||||||
|
canEditCurrentParticle,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -634,6 +651,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
onSelect={(action) => void handleStreamAction(action)}
|
onSelect={(action) => void handleStreamAction(action)}
|
||||||
streamStatus={streamParticle.status ?? "open"}
|
streamStatus={streamParticle.status ?? "open"}
|
||||||
isCreator={isCreator}
|
isCreator={isCreator}
|
||||||
|
canEditParticle={canEditCurrentParticle}
|
||||||
canDeleteParticle={canDeleteCurrentParticle}
|
canDeleteParticle={canDeleteCurrentParticle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -652,6 +670,17 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
streamId={streamParticle.id}
|
streamId={streamParticle.id}
|
||||||
currentName={streamParticle.properties.name}
|
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>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { ScrollView, Text, View } from "react-native";
|
import { ScrollView, Text, View } from "react-native";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||||
import { useStreamSafeArea } from "./stream-safe-area";
|
import { useStreamSafeArea } from "./stream-safe-area";
|
||||||
|
|
||||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||||
@@ -43,10 +44,19 @@ export function TextParticleView({
|
|||||||
onProgress,
|
onProgress,
|
||||||
}: TextParticleViewProps) {
|
}: TextParticleViewProps) {
|
||||||
const content = particle.properties.content;
|
const content = particle.properties.content;
|
||||||
|
const editedAt = particle.properties.edited_at;
|
||||||
const durationS = computeReadDuration(content);
|
const durationS = computeReadDuration(content);
|
||||||
const elapsedRef = useRef(0);
|
const elapsedRef = useRef(0);
|
||||||
const safe = useStreamSafeArea();
|
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.
|
// Reset when the particle changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
elapsedRef.current = 0;
|
elapsedRef.current = 0;
|
||||||
@@ -85,6 +95,7 @@ export function TextParticleView({
|
|||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Text>
|
</Text>
|
||||||
|
{editedLabel}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,6 +119,7 @@ export function TextParticleView({
|
|||||||
indicatorStyle="white"
|
indicatorStyle="white"
|
||||||
>
|
>
|
||||||
<Text className="text-white text-lg leading-relaxed">{content}</Text>
|
<Text className="text-white text-lg leading-relaxed">{content}</Text>
|
||||||
|
{editedLabel}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user