From af1543135d42adc2e97dc5243aeef7418cd3b00d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 08:39:26 +0100
Subject: [PATCH] feat: dual address auto-switch, custom icon, notifications, image support
---
components/chat/MessageBubble.tsx | 152 +++++++++++++++++++++++++++++++++-----------------
1 files changed, 99 insertions(+), 53 deletions(-)
diff --git a/components/chat/MessageBubble.tsx b/components/chat/MessageBubble.tsx
index fd8c0b6..8d3bd9a 100644
--- a/components/chat/MessageBubble.tsx
+++ b/components/chat/MessageBubble.tsx
@@ -1,7 +1,9 @@
-import React, { useCallback, useState } from "react";
+import React, { useCallback, useEffect, useState } from "react";
import { Image, Pressable, Text, View } from "react-native";
import { Message } from "../../types";
-import { playAudio, stopPlayback } from "../../services/audio";
+import { playAudio, stopPlayback, onPlayingChange } from "../../services/audio";
+import { ImageViewer } from "./ImageViewer";
+import { useTheme } from "../../contexts/ThemeContext";
interface MessageBubbleProps {
message: Message;
@@ -22,6 +24,14 @@
export function MessageBubble({ message }: MessageBubbleProps) {
const [isPlaying, setIsPlaying] = useState(false);
+ const [showViewer, setShowViewer] = useState(false);
+ const { colors, isDark } = useTheme();
+
+ useEffect(() => {
+ return onPlayingChange((playing) => {
+ if (!playing) setIsPlaying(false);
+ });
+ }, []);
const isUser = message.role === "user";
const isSystem = message.role === "system";
@@ -40,40 +50,56 @@
if (isSystem) {
return (
- <View className="items-center my-1 px-4">
- <Text className="text-pai-text-muted text-xs">{message.content}</Text>
+ <View style={{ alignItems: "center", marginVertical: 4, paddingHorizontal: 16 }}>
+ <Text style={{ color: colors.textMuted, fontSize: 12 }}>{message.content}</Text>
</View>
);
}
+ const bubbleBg = isUser
+ ? colors.accent
+ : isDark ? "#252538" : colors.bgSecondary;
+ const bubbleRadius = isUser
+ ? { borderTopRightRadius: 4 }
+ : { borderTopLeftRadius: 4 };
+
return (
<View
- className={`flex-row my-1 px-3 ${isUser ? "justify-end" : "justify-start"}`}
+ style={{
+ flexDirection: "row",
+ marginVertical: 4,
+ paddingHorizontal: 12,
+ justifyContent: isUser ? "flex-end" : "flex-start",
+ }}
>
<View
- className={`max-w-[78%] rounded-2xl px-4 py-3 ${
- isUser
- ? "bg-pai-accent rounded-tr-sm"
- : "bg-pai-surface rounded-tl-sm"
- }`}
+ style={{
+ maxWidth: "78%",
+ borderRadius: 16,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ backgroundColor: bubbleBg,
+ ...bubbleRadius,
+ }}
>
{message.type === "image" && message.imageBase64 ? (
- /* Image message */
<View>
- <Image
- source={{ uri: `data:image/png;base64,${message.imageBase64}` }}
- style={{
- width: 260,
- height: 180,
- borderRadius: 10,
- backgroundColor: "#14141F",
- }}
- resizeMode="contain"
- />
+ <Pressable onPress={() => setShowViewer(true)}>
+ <Image
+ source={{ uri: `data:image/png;base64,${message.imageBase64}` }}
+ style={{
+ width: 260,
+ height: 180,
+ borderRadius: 10,
+ backgroundColor: colors.bgTertiary,
+ }}
+ resizeMode="contain"
+ />
+ </Pressable>
{message.content ? (
<Text
style={{
- color: isUser ? "#FFF" : "#9898B0",
+ color: isUser ? "#FFF" : colors.textSecondary,
fontSize: 12,
marginTop: 4,
}}
@@ -81,74 +107,94 @@
{message.content}
</Text>
) : null}
+ <ImageViewer
+ visible={showViewer}
+ imageBase64={message.imageBase64}
+ onClose={() => setShowViewer(false)}
+ />
</View>
) : message.type === "voice" ? (
<Pressable
onPress={handleVoicePress}
- className="flex-row items-center gap-3"
+ style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
>
- {/* Play/pause icon */}
<View
- className={`w-9 h-9 rounded-full items-center justify-center ${
- isPlaying ? "bg-pai-voice" : isUser ? "bg-white/20" : "bg-pai-border"
- }`}
+ style={{
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: isPlaying
+ ? "#FF9F43"
+ : isUser
+ ? "rgba(255,255,255,0.2)"
+ : colors.border,
+ }}
>
- <Text
- className={`text-base ${isUser ? "text-white" : "text-pai-text"}`}
- >
- {isPlaying ? "⏸" : "▶"}
+ <Text style={{ fontSize: 14, color: isUser ? "#FFF" : colors.text }}>
+ {isPlaying ? "\u23F8" : "\u25B6"}
</Text>
</View>
- {/* Waveform placeholder */}
- <View className="flex-1 flex-row items-center gap-px h-8">
+ <View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 1, height: 32 }}>
{Array.from({ length: 20 }).map((_, i) => (
<View
key={i}
- className={`flex-1 rounded-full ${
- isPlaying && i < 10
- ? "bg-pai-voice"
- : isUser
- ? "bg-white/50"
- : "bg-pai-text-muted"
- }`}
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>
- {/* Duration */}
<Text
- className={`text-xs ${
- isUser ? "text-white/80" : "text-pai-text-secondary"
- }`}
+ style={{
+ fontSize: 11,
+ color: isUser ? "rgba(255,255,255,0.8)" : colors.textSecondary,
+ }}
>
{formatDuration(message.duration)}
</Text>
</Pressable>
) : (
<Text
- className={`text-base leading-6 ${
- isUser ? "text-white" : "text-pai-text"
- }`}
+ style={{
+ fontSize: 16,
+ lineHeight: 24,
+ color: isUser ? "#FFF" : colors.text,
+ }}
>
{message.content}
</Text>
)}
- {/* Timestamp + status */}
- <View className={`flex-row items-center mt-1 gap-1 ${isUser ? "justify-end" : "justify-start"}`}>
+ <View
+ style={{
+ flexDirection: "row",
+ alignItems: "center",
+ marginTop: 4,
+ gap: 4,
+ justifyContent: isUser ? "flex-end" : "flex-start",
+ }}
+ >
<Text
- className={`text-2xs ${
- isUser ? "text-white/60" : "text-pai-text-muted"
- }`}
+ style={{
+ fontSize: 10,
+ color: isUser ? "rgba(255,255,255,0.6)" : colors.textMuted,
+ }}
>
{formatTime(message.timestamp)}
</Text>
{isUser && message.status === "error" && (
- <Text className="text-2xs text-pai-error"> !</Text>
+ <Text style={{ fontSize: 10, color: colors.danger }}> !</Text>
)}
</View>
</View>
--
Gitblit v1.3.1