fix: prevent nesting of formatted text reactions

Usage of map was causing issues if the text reaction had special
characters.

Closes #205
This commit is contained in:
talksik
2026-05-17 16:51:37 -07:00
parent 4b468ed1ae
commit 2492188c50
4 changed files with 48 additions and 14 deletions
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Send } from "lucide-react"; import { Send } from "lucide-react";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { sanitizeReactionText } from "@/lib/firestore-particles";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MAX_LENGTH = 40; const MAX_LENGTH = 40;
@@ -44,7 +45,9 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
<input <input
ref={inputRef} ref={inputRef}
value={value} value={value}
onChange={(e) => setValue(e.target.value.slice(0, MAX_LENGTH))} onChange={(e) =>
setValue(sanitizeReactionText(e.target.value).slice(0, MAX_LENGTH))
}
onBlur={onClose} onBlur={onClose}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
+20 -6
View File
@@ -14,6 +14,7 @@ import {
Timestamp, Timestamp,
arrayUnion, arrayUnion,
arrayRemove, arrayRemove,
FieldPath,
type DocumentData, type DocumentData,
type FirestoreDataConverter, type FirestoreDataConverter,
type QueryDocumentSnapshot, 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( export async function toggleParticleReaction(
docPath: string, docPath: string,
emoji: string, emoji: string,
humanId: string, humanId: string,
currentReactions?: Reactions, currentReactions?: Reactions,
): Promise<void> { ): Promise<void> {
const key = sanitizeReactionText(emoji);
if (!key) return;
const particleRef = typedDoc(docPath); const particleRef = typedDoc(docPath);
const field = `reactions.${emoji}`; const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false;
const alreadyReacted = currentReactions?.[emoji]?.includes(humanId) ?? false; await updateDoc(
await updateDoc(particleRef, { particleRef,
[field]: alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId), new FieldPath("reactions", key),
updated_at: serverTimestamp(), alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
}); "updated_at",
serverTimestamp(),
);
} }
@@ -33,6 +33,7 @@ import Animated, {
import { REACTION_EMOJIS, type Reactions } from "@/api/types"; import { REACTION_EMOJIS, type Reactions } from "@/api/types";
import type { Human } from "@/api/types"; import type { Human } from "@/api/types";
import { resolveHumanDisplay } from "@/lib/humans"; import { resolveHumanDisplay } from "@/lib/humans";
import { sanitizeReactionText } from "@/lib/firestore-particles";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
@@ -327,7 +328,9 @@ export function ReactionSheet({
<View className="flex-1 bg-white/10 rounded-full px-4 py-2.5"> <View className="flex-1 bg-white/10 rounded-full px-4 py-2.5">
<TextInput <TextInput
value={text} value={text}
onChangeText={(v) => setText(v.slice(0, TEXT_REACTION_MAX))} onChangeText={(v) =>
setText(sanitizeReactionText(v).slice(0, TEXT_REACTION_MAX))
}
placeholder="Send a quick reply..." placeholder="Send a quick reply..."
placeholderTextColor="rgba(255,255,255,0.4)" placeholderTextColor="rgba(255,255,255,0.4)"
maxLength={TEXT_REACTION_MAX} maxLength={TEXT_REACTION_MAX}
+20 -6
View File
@@ -14,6 +14,7 @@ import {
Timestamp, Timestamp,
arrayUnion, arrayUnion,
arrayRemove, arrayRemove,
FieldPath,
type DocumentData, type DocumentData,
type FirestoreDataConverter, type FirestoreDataConverter,
type QueryDocumentSnapshot, 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( export async function toggleParticleReaction(
docPath: string, docPath: string,
emoji: string, emoji: string,
humanId: string, humanId: string,
currentReactions?: Reactions, currentReactions?: Reactions,
): Promise<void> { ): Promise<void> {
const key = sanitizeReactionText(emoji);
if (!key) return;
const particleRef = typedDoc(docPath); const particleRef = typedDoc(docPath);
const field = `reactions.${emoji}`; const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false;
const alreadyReacted = currentReactions?.[emoji]?.includes(humanId) ?? false; await updateDoc(
await updateDoc(particleRef, { particleRef,
[field]: alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId), new FieldPath("reactions", key),
updated_at: serverTimestamp(), alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
}); "updated_at",
serverTimestamp(),
);
} }