From 0e888d62af1434fef231e11a5c307a5b48a8deb1 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 10:49:07 +0100
Subject: [PATCH] feat: singleton audio, transcript reflection, voice persistence

---
 components/chat/MessageBubble.tsx |  125 +++++++++++++++++++++++------------------
 1 files changed, 70 insertions(+), 55 deletions(-)

diff --git a/components/chat/MessageBubble.tsx b/components/chat/MessageBubble.tsx
index 8d3bd9a..5edf2f8 100644
--- a/components/chat/MessageBubble.tsx
+++ b/components/chat/MessageBubble.tsx
@@ -1,7 +1,7 @@
 import React, { useCallback, useEffect, useState } from "react";
 import { Image, Pressable, Text, View } from "react-native";
 import { Message } from "../../types";
-import { playAudio, stopPlayback, onPlayingChange } from "../../services/audio";
+import { playSingle, stopPlayback, onPlayingChange } from "../../services/audio";
 import { ImageViewer } from "./ImageViewer";
 import { useTheme } from "../../contexts/ThemeContext";
 
@@ -27,11 +27,12 @@
   const [showViewer, setShowViewer] = useState(false);
   const { colors, isDark } = useTheme();
 
+  // Track whether THIS bubble's audio is playing via the singleton URI
   useEffect(() => {
-    return onPlayingChange((playing) => {
-      if (!playing) setIsPlaying(false);
+    return onPlayingChange((uri) => {
+      setIsPlaying(uri !== null && uri === message.audioUri);
     });
-  }, []);
+  }, [message.audioUri]);
 
   const isUser = message.role === "user";
   const isSystem = message.role === "system";
@@ -40,11 +41,11 @@
     if (!message.audioUri) return;
 
     if (isPlaying) {
+      // This bubble is playing — stop it
       await stopPlayback();
-      setIsPlaying(false);
     } else {
-      setIsPlaying(true);
-      await playAudio(message.audioUri, () => setIsPlaying(false));
+      // Play this bubble (stops anything else automatically)
+      await playSingle(message.audioUri, () => {});
     }
   }, [isPlaying, message.audioUri]);
 
@@ -114,56 +115,70 @@
             />
           </View>
         ) : message.type === "voice" ? (
-          <Pressable
-            onPress={handleVoicePress}
-            style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
-          >
-            <View
-              style={{
-                width: 36,
-                height: 36,
-                borderRadius: 18,
-                alignItems: "center",
-                justifyContent: "center",
-                backgroundColor: isPlaying
-                  ? "#FF9F43"
-                  : isUser
-                  ? "rgba(255,255,255,0.2)"
-                  : colors.border,
-              }}
+          <View>
+            <Pressable
+              onPress={handleVoicePress}
+              style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
             >
-              <Text style={{ fontSize: 14, color: isUser ? "#FFF" : colors.text }}>
-                {isPlaying ? "\u23F8" : "\u25B6"}
+              <View
+                style={{
+                  width: 36,
+                  height: 36,
+                  borderRadius: 18,
+                  alignItems: "center",
+                  justifyContent: "center",
+                  backgroundColor: isPlaying
+                    ? "#FF9F43"
+                    : isUser
+                    ? "rgba(255,255,255,0.2)"
+                    : colors.border,
+                }}
+              >
+                <Text style={{ fontSize: 14, color: isUser ? "#FFF" : colors.text }}>
+                  {isPlaying ? "\u23F8" : "\u25B6"}
+                </Text>
+              </View>
+
+              <View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 1, height: 32 }}>
+                {Array.from({ length: 20 }).map((_, i) => (
+                  <View
+                    key={i}
+                    style={{
+                      flex: 1,
+                      borderRadius: 2,
+                      backgroundColor: isPlaying && i < 10
+                        ? "#FF9F43"
+                        : isUser
+                        ? "rgba(255,255,255,0.5)"
+                        : colors.textMuted,
+                      height: `${20 + Math.sin(i * 0.8) * 60}%`,
+                    }}
+                  />
+                ))}
+              </View>
+
+              <Text
+                style={{
+                  fontSize: 11,
+                  color: isUser ? "rgba(255,255,255,0.8)" : colors.textSecondary,
+                }}
+              >
+                {formatDuration(message.duration)}
               </Text>
-            </View>
-
-            <View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 1, height: 32 }}>
-              {Array.from({ length: 20 }).map((_, i) => (
-                <View
-                  key={i}
-                  style={{
-                    flex: 1,
-                    borderRadius: 2,
-                    backgroundColor: isPlaying && i < 10
-                      ? "#FF9F43"
-                      : isUser
-                      ? "rgba(255,255,255,0.5)"
-                      : colors.textMuted,
-                    height: `${20 + Math.sin(i * 0.8) * 60}%`,
-                  }}
-                />
-              ))}
-            </View>
-
-            <Text
-              style={{
-                fontSize: 11,
-                color: isUser ? "rgba(255,255,255,0.8)" : colors.textSecondary,
-              }}
-            >
-              {formatDuration(message.duration)}
-            </Text>
-          </Pressable>
+            </Pressable>
+            {message.content ? (
+              <Text
+                style={{
+                  fontSize: 14,
+                  lineHeight: 20,
+                  marginTop: 8,
+                  color: isUser ? "rgba(255,255,255,0.9)" : colors.textSecondary,
+                }}
+              >
+                {message.content}
+              </Text>
+            ) : null}
+          </View>
         ) : (
           <Text
             style={{

--
Gitblit v1.3.1