From 2492188c5051d66b1134df7864ae14651d200abc Mon Sep 17 00:00:00 2001 From: talksik Date: Sun, 17 May 2026 16:51:37 -0700 Subject: [PATCH] fix: prevent nesting of formatted text reactions Usage of map was causing issues if the text reaction had special characters. Closes #205 --- .../particles/text-reaction-input.tsx | 5 +++- js/desktop/src/lib/firestore-particles.ts | 26 ++++++++++++++----- .../features/stream-view/ReactionSheet.tsx | 5 +++- js/mobile/src/lib/firestore-particles.ts | 26 ++++++++++++++----- 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/js/desktop/src/features/particles/text-reaction-input.tsx b/js/desktop/src/features/particles/text-reaction-input.tsx index 5f2c172..9820064 100644 --- a/js/desktop/src/features/particles/text-reaction-input.tsx +++ b/js/desktop/src/features/particles/text-reaction-input.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Send } from "lucide-react"; import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; +import { sanitizeReactionText } from "@/lib/firestore-particles"; import { cn } from "@/lib/utils"; const MAX_LENGTH = 40; @@ -44,7 +45,9 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput setValue(e.target.value.slice(0, MAX_LENGTH))} + onChange={(e) => + setValue(sanitizeReactionText(e.target.value).slice(0, MAX_LENGTH)) + } onBlur={onClose} onKeyDown={(e) => { if (e.key === "Enter") { diff --git a/js/desktop/src/lib/firestore-particles.ts b/js/desktop/src/lib/firestore-particles.ts index efa8349..2c6fe12 100644 --- a/js/desktop/src/lib/firestore-particles.ts +++ b/js/desktop/src/lib/firestore-particles.ts @@ -14,6 +14,7 @@ import { Timestamp, arrayUnion, arrayRemove, + FieldPath, type DocumentData, type FirestoreDataConverter, type QueryDocumentSnapshot, @@ -399,17 +400,30 @@ export async function updateStreamPlaybackMarker( }); } +// Strip characters that Firestore's dotted field-path syntax treats specially. +// Using FieldPath bypasses the parser, but we also forbid these in stored keys +// so reaction maps stay portable across any future read path. +const RESERVED_REACTION_CHARS = /[~*/[\]]/g; + +export function sanitizeReactionText(text: string): string { + return text.replace(RESERVED_REACTION_CHARS, ""); +} + export async function toggleParticleReaction( docPath: string, emoji: string, humanId: string, currentReactions?: Reactions, ): Promise { + const key = sanitizeReactionText(emoji); + if (!key) return; const particleRef = typedDoc(docPath); - const field = `reactions.${emoji}`; - const alreadyReacted = currentReactions?.[emoji]?.includes(humanId) ?? false; - await updateDoc(particleRef, { - [field]: alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId), - updated_at: serverTimestamp(), - }); + const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false; + await updateDoc( + particleRef, + new FieldPath("reactions", key), + alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId), + "updated_at", + serverTimestamp(), + ); } diff --git a/js/mobile/src/features/stream-view/ReactionSheet.tsx b/js/mobile/src/features/stream-view/ReactionSheet.tsx index 609f29e..4cdd6b2 100644 --- a/js/mobile/src/features/stream-view/ReactionSheet.tsx +++ b/js/mobile/src/features/stream-view/ReactionSheet.tsx @@ -33,6 +33,7 @@ import Animated, { import { REACTION_EMOJIS, type Reactions } from "@/api/types"; import type { Human } from "@/api/types"; import { resolveHumanDisplay } from "@/lib/humans"; +import { sanitizeReactionText } from "@/lib/firestore-particles"; import { cn } from "@/lib/utils"; import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; @@ -327,7 +328,9 @@ export function ReactionSheet({ setText(v.slice(0, TEXT_REACTION_MAX))} + onChangeText={(v) => + setText(sanitizeReactionText(v).slice(0, TEXT_REACTION_MAX)) + } placeholder="Send a quick reply..." placeholderTextColor="rgba(255,255,255,0.4)" maxLength={TEXT_REACTION_MAX} diff --git a/js/mobile/src/lib/firestore-particles.ts b/js/mobile/src/lib/firestore-particles.ts index ce1d19e..0044169 100644 --- a/js/mobile/src/lib/firestore-particles.ts +++ b/js/mobile/src/lib/firestore-particles.ts @@ -14,6 +14,7 @@ import { Timestamp, arrayUnion, arrayRemove, + FieldPath, type DocumentData, type FirestoreDataConverter, type QueryDocumentSnapshot, @@ -419,17 +420,30 @@ export async function updateStreamPlaybackMarker( }); } +// Strip characters that Firestore's dotted field-path syntax treats specially. +// Using FieldPath bypasses the parser, but we also forbid these in stored keys +// so reaction maps stay portable across any future read path. +const RESERVED_REACTION_CHARS = /[~*/[\]]/g; + +export function sanitizeReactionText(text: string): string { + return text.replace(RESERVED_REACTION_CHARS, ""); +} + export async function toggleParticleReaction( docPath: string, emoji: string, humanId: string, currentReactions?: Reactions, ): Promise { + const key = sanitizeReactionText(emoji); + if (!key) return; const particleRef = typedDoc(docPath); - const field = `reactions.${emoji}`; - const alreadyReacted = currentReactions?.[emoji]?.includes(humanId) ?? false; - await updateDoc(particleRef, { - [field]: alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId), - updated_at: serverTimestamp(), - }); + const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false; + await updateDoc( + particleRef, + new FieldPath("reactions", key), + alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId), + "updated_at", + serverTimestamp(), + ); }