| .. | .. |
|---|
| 1 | | -import React, { useCallback, useState } from "react"; |
|---|
| 1 | +import React, { useCallback, useEffect, useState } from "react"; |
|---|
| 2 | 2 | import { Image, Pressable, Text, View } from "react-native"; |
|---|
| 3 | 3 | import { Message } from "../../types"; |
|---|
| 4 | | -import { playAudio, stopPlayback } from "../../services/audio"; |
|---|
| 4 | +import { playAudio, stopPlayback, onPlayingChange } from "../../services/audio"; |
|---|
| 5 | +import { ImageViewer } from "./ImageViewer"; |
|---|
| 6 | +import { useTheme } from "../../contexts/ThemeContext"; |
|---|
| 5 | 7 | |
|---|
| 6 | 8 | interface MessageBubbleProps { |
|---|
| 7 | 9 | message: Message; |
|---|
| .. | .. |
|---|
| 22 | 24 | |
|---|
| 23 | 25 | export function MessageBubble({ message }: MessageBubbleProps) { |
|---|
| 24 | 26 | const [isPlaying, setIsPlaying] = useState(false); |
|---|
| 27 | + const [showViewer, setShowViewer] = useState(false); |
|---|
| 28 | + const { colors, isDark } = useTheme(); |
|---|
| 29 | + |
|---|
| 30 | + useEffect(() => { |
|---|
| 31 | + return onPlayingChange((playing) => { |
|---|
| 32 | + if (!playing) setIsPlaying(false); |
|---|
| 33 | + }); |
|---|
| 34 | + }, []); |
|---|
| 25 | 35 | |
|---|
| 26 | 36 | const isUser = message.role === "user"; |
|---|
| 27 | 37 | const isSystem = message.role === "system"; |
|---|
| .. | .. |
|---|
| 40 | 50 | |
|---|
| 41 | 51 | if (isSystem) { |
|---|
| 42 | 52 | return ( |
|---|
| 43 | | - <View className="items-center my-1 px-4"> |
|---|
| 44 | | - <Text className="text-pai-text-muted text-xs">{message.content}</Text> |
|---|
| 53 | + <View style={{ alignItems: "center", marginVertical: 4, paddingHorizontal: 16 }}> |
|---|
| 54 | + <Text style={{ color: colors.textMuted, fontSize: 12 }}>{message.content}</Text> |
|---|
| 45 | 55 | </View> |
|---|
| 46 | 56 | ); |
|---|
| 47 | 57 | } |
|---|
| 48 | 58 | |
|---|
| 59 | + const bubbleBg = isUser |
|---|
| 60 | + ? colors.accent |
|---|
| 61 | + : isDark ? "#252538" : colors.bgSecondary; |
|---|
| 62 | + const bubbleRadius = isUser |
|---|
| 63 | + ? { borderTopRightRadius: 4 } |
|---|
| 64 | + : { borderTopLeftRadius: 4 }; |
|---|
| 65 | + |
|---|
| 49 | 66 | return ( |
|---|
| 50 | 67 | <View |
|---|
| 51 | | - className={`flex-row my-1 px-3 ${isUser ? "justify-end" : "justify-start"}`} |
|---|
| 68 | + style={{ |
|---|
| 69 | + flexDirection: "row", |
|---|
| 70 | + marginVertical: 4, |
|---|
| 71 | + paddingHorizontal: 12, |
|---|
| 72 | + justifyContent: isUser ? "flex-end" : "flex-start", |
|---|
| 73 | + }} |
|---|
| 52 | 74 | > |
|---|
| 53 | 75 | <View |
|---|
| 54 | | - className={`max-w-[78%] rounded-2xl px-4 py-3 ${ |
|---|
| 55 | | - isUser |
|---|
| 56 | | - ? "bg-pai-accent rounded-tr-sm" |
|---|
| 57 | | - : "bg-pai-surface rounded-tl-sm" |
|---|
| 58 | | - }`} |
|---|
| 76 | + style={{ |
|---|
| 77 | + maxWidth: "78%", |
|---|
| 78 | + borderRadius: 16, |
|---|
| 79 | + paddingHorizontal: 16, |
|---|
| 80 | + paddingVertical: 12, |
|---|
| 81 | + backgroundColor: bubbleBg, |
|---|
| 82 | + ...bubbleRadius, |
|---|
| 83 | + }} |
|---|
| 59 | 84 | > |
|---|
| 60 | 85 | {message.type === "image" && message.imageBase64 ? ( |
|---|
| 61 | | - /* Image message */ |
|---|
| 62 | 86 | <View> |
|---|
| 63 | | - <Image |
|---|
| 64 | | - source={{ uri: `data:image/png;base64,${message.imageBase64}` }} |
|---|
| 65 | | - style={{ |
|---|
| 66 | | - width: 260, |
|---|
| 67 | | - height: 180, |
|---|
| 68 | | - borderRadius: 10, |
|---|
| 69 | | - backgroundColor: "#14141F", |
|---|
| 70 | | - }} |
|---|
| 71 | | - resizeMode="contain" |
|---|
| 72 | | - /> |
|---|
| 87 | + <Pressable onPress={() => setShowViewer(true)}> |
|---|
| 88 | + <Image |
|---|
| 89 | + source={{ uri: `data:image/png;base64,${message.imageBase64}` }} |
|---|
| 90 | + style={{ |
|---|
| 91 | + width: 260, |
|---|
| 92 | + height: 180, |
|---|
| 93 | + borderRadius: 10, |
|---|
| 94 | + backgroundColor: colors.bgTertiary, |
|---|
| 95 | + }} |
|---|
| 96 | + resizeMode="contain" |
|---|
| 97 | + /> |
|---|
| 98 | + </Pressable> |
|---|
| 73 | 99 | {message.content ? ( |
|---|
| 74 | 100 | <Text |
|---|
| 75 | 101 | style={{ |
|---|
| 76 | | - color: isUser ? "#FFF" : "#9898B0", |
|---|
| 102 | + color: isUser ? "#FFF" : colors.textSecondary, |
|---|
| 77 | 103 | fontSize: 12, |
|---|
| 78 | 104 | marginTop: 4, |
|---|
| 79 | 105 | }} |
|---|
| .. | .. |
|---|
| 81 | 107 | {message.content} |
|---|
| 82 | 108 | </Text> |
|---|
| 83 | 109 | ) : null} |
|---|
| 110 | + <ImageViewer |
|---|
| 111 | + visible={showViewer} |
|---|
| 112 | + imageBase64={message.imageBase64} |
|---|
| 113 | + onClose={() => setShowViewer(false)} |
|---|
| 114 | + /> |
|---|
| 84 | 115 | </View> |
|---|
| 85 | 116 | ) : message.type === "voice" ? ( |
|---|
| 86 | 117 | <Pressable |
|---|
| 87 | 118 | onPress={handleVoicePress} |
|---|
| 88 | | - className="flex-row items-center gap-3" |
|---|
| 119 | + style={{ flexDirection: "row", alignItems: "center", gap: 12 }} |
|---|
| 89 | 120 | > |
|---|
| 90 | | - {/* Play/pause icon */} |
|---|
| 91 | 121 | <View |
|---|
| 92 | | - className={`w-9 h-9 rounded-full items-center justify-center ${ |
|---|
| 93 | | - isPlaying ? "bg-pai-voice" : isUser ? "bg-white/20" : "bg-pai-border" |
|---|
| 94 | | - }`} |
|---|
| 122 | + style={{ |
|---|
| 123 | + width: 36, |
|---|
| 124 | + height: 36, |
|---|
| 125 | + borderRadius: 18, |
|---|
| 126 | + alignItems: "center", |
|---|
| 127 | + justifyContent: "center", |
|---|
| 128 | + backgroundColor: isPlaying |
|---|
| 129 | + ? "#FF9F43" |
|---|
| 130 | + : isUser |
|---|
| 131 | + ? "rgba(255,255,255,0.2)" |
|---|
| 132 | + : colors.border, |
|---|
| 133 | + }} |
|---|
| 95 | 134 | > |
|---|
| 96 | | - <Text |
|---|
| 97 | | - className={`text-base ${isUser ? "text-white" : "text-pai-text"}`} |
|---|
| 98 | | - > |
|---|
| 99 | | - {isPlaying ? "⏸" : "▶"} |
|---|
| 135 | + <Text style={{ fontSize: 14, color: isUser ? "#FFF" : colors.text }}> |
|---|
| 136 | + {isPlaying ? "\u23F8" : "\u25B6"} |
|---|
| 100 | 137 | </Text> |
|---|
| 101 | 138 | </View> |
|---|
| 102 | 139 | |
|---|
| 103 | | - {/* Waveform placeholder */} |
|---|
| 104 | | - <View className="flex-1 flex-row items-center gap-px h-8"> |
|---|
| 140 | + <View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 1, height: 32 }}> |
|---|
| 105 | 141 | {Array.from({ length: 20 }).map((_, i) => ( |
|---|
| 106 | 142 | <View |
|---|
| 107 | 143 | key={i} |
|---|
| 108 | | - className={`flex-1 rounded-full ${ |
|---|
| 109 | | - isPlaying && i < 10 |
|---|
| 110 | | - ? "bg-pai-voice" |
|---|
| 111 | | - : isUser |
|---|
| 112 | | - ? "bg-white/50" |
|---|
| 113 | | - : "bg-pai-text-muted" |
|---|
| 114 | | - }`} |
|---|
| 115 | 144 | style={{ |
|---|
| 145 | + flex: 1, |
|---|
| 146 | + borderRadius: 2, |
|---|
| 147 | + backgroundColor: isPlaying && i < 10 |
|---|
| 148 | + ? "#FF9F43" |
|---|
| 149 | + : isUser |
|---|
| 150 | + ? "rgba(255,255,255,0.5)" |
|---|
| 151 | + : colors.textMuted, |
|---|
| 116 | 152 | height: `${20 + Math.sin(i * 0.8) * 60}%`, |
|---|
| 117 | 153 | }} |
|---|
| 118 | 154 | /> |
|---|
| 119 | 155 | ))} |
|---|
| 120 | 156 | </View> |
|---|
| 121 | 157 | |
|---|
| 122 | | - {/* Duration */} |
|---|
| 123 | 158 | <Text |
|---|
| 124 | | - className={`text-xs ${ |
|---|
| 125 | | - isUser ? "text-white/80" : "text-pai-text-secondary" |
|---|
| 126 | | - }`} |
|---|
| 159 | + style={{ |
|---|
| 160 | + fontSize: 11, |
|---|
| 161 | + color: isUser ? "rgba(255,255,255,0.8)" : colors.textSecondary, |
|---|
| 162 | + }} |
|---|
| 127 | 163 | > |
|---|
| 128 | 164 | {formatDuration(message.duration)} |
|---|
| 129 | 165 | </Text> |
|---|
| 130 | 166 | </Pressable> |
|---|
| 131 | 167 | ) : ( |
|---|
| 132 | 168 | <Text |
|---|
| 133 | | - className={`text-base leading-6 ${ |
|---|
| 134 | | - isUser ? "text-white" : "text-pai-text" |
|---|
| 135 | | - }`} |
|---|
| 169 | + style={{ |
|---|
| 170 | + fontSize: 16, |
|---|
| 171 | + lineHeight: 24, |
|---|
| 172 | + color: isUser ? "#FFF" : colors.text, |
|---|
| 173 | + }} |
|---|
| 136 | 174 | > |
|---|
| 137 | 175 | {message.content} |
|---|
| 138 | 176 | </Text> |
|---|
| 139 | 177 | )} |
|---|
| 140 | 178 | |
|---|
| 141 | | - {/* Timestamp + status */} |
|---|
| 142 | | - <View className={`flex-row items-center mt-1 gap-1 ${isUser ? "justify-end" : "justify-start"}`}> |
|---|
| 179 | + <View |
|---|
| 180 | + style={{ |
|---|
| 181 | + flexDirection: "row", |
|---|
| 182 | + alignItems: "center", |
|---|
| 183 | + marginTop: 4, |
|---|
| 184 | + gap: 4, |
|---|
| 185 | + justifyContent: isUser ? "flex-end" : "flex-start", |
|---|
| 186 | + }} |
|---|
| 187 | + > |
|---|
| 143 | 188 | <Text |
|---|
| 144 | | - className={`text-2xs ${ |
|---|
| 145 | | - isUser ? "text-white/60" : "text-pai-text-muted" |
|---|
| 146 | | - }`} |
|---|
| 189 | + style={{ |
|---|
| 190 | + fontSize: 10, |
|---|
| 191 | + color: isUser ? "rgba(255,255,255,0.6)" : colors.textMuted, |
|---|
| 192 | + }} |
|---|
| 147 | 193 | > |
|---|
| 148 | 194 | {formatTime(message.timestamp)} |
|---|
| 149 | 195 | </Text> |
|---|
| 150 | 196 | {isUser && message.status === "error" && ( |
|---|
| 151 | | - <Text className="text-2xs text-pai-error"> !</Text> |
|---|
| 197 | + <Text style={{ fontSize: 10, color: colors.danger }}> !</Text> |
|---|
| 152 | 198 | )} |
|---|
| 153 | 199 | </View> |
|---|
| 154 | 200 | </View> |
|---|