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(),
+ );
}