adilaj commited on
Commit
0b87982
·
1 Parent(s): b70f212

refactor: improve spacing for speech controls

Browse files
frontend/src/components/chat/MessageBubble.tsx CHANGED
@@ -1,12 +1,12 @@
1
  "use client";
2
 
3
- import { useState, useRef } from "react";
4
  import ReactMarkdown, { type Components } from "react-markdown";
5
  import rehypeHighlight from "rehype-highlight";
6
  import remarkGfm from "remark-gfm";
7
  import type { ChatMsg } from "@/store/chat-store";
8
  import { api } from "@/lib/api";
9
- import { Brain, User, Copy, Check, Share2, Link2, X,Play,Pause } from "lucide-react";
10
  import { Button } from "@/components/ui/button";
11
 
12
  interface Props {
@@ -41,7 +41,6 @@ const markdownComponents: Components = {
41
  ),
42
  code: ({ className, children, ...props }) => {
43
  const language = /language-(\w+)/.exec(className ?? "")?.[1];
44
-
45
  return (
46
  <code className={className} data-language={language} {...props}>
47
  {children}
@@ -58,6 +57,16 @@ export default function MessageBubble({ message }: Props) {
58
  const [isSpeaking, setIsSpeaking] = useState(false);
59
  const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60
  const sharedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
 
 
 
 
 
 
 
 
 
61
 
62
  const handleCopy = async () => {
63
  if (!message.content) return;
@@ -73,7 +82,6 @@ export default function MessageBubble({ message }: Props) {
73
 
74
  const handleShare = async () => {
75
  if (!message.content || message.isStreaming) return;
76
-
77
  try {
78
  const data = await api.post<{ message_id: string; share_url: string }>(
79
  `/api/v1/chat/share/${message.id}`
@@ -95,23 +103,34 @@ export default function MessageBubble({ message }: Props) {
95
  }, 2000);
96
  }
97
  };
98
-
99
  const handleSpeech = () => {
100
- if (!message.content) return;
101
 
102
- if (isSpeaking) {
103
- window.speechSynthesis.cancel();
104
- setIsSpeaking(false);
105
- return;
106
- }
 
 
107
 
108
- const utterance = new SpeechSynthesisUtterance(message.content);
 
109
 
110
- utterance.onstart = () => setIsSpeaking(true);
111
- utterance.onend = () => setIsSpeaking(false);
 
 
 
 
 
 
112
 
113
- window.speechSynthesis.speak(utterance);
114
- };
 
 
115
 
116
  return (
117
  <div
@@ -136,6 +155,7 @@ export default function MessageBubble({ message }: Props) {
136
  <>
137
  {message.content && (
138
  <>
 
139
  {!message.isStreaming && (
140
  <Button
141
  type="button"
@@ -158,6 +178,8 @@ export default function MessageBubble({ message }: Props) {
158
  )}
159
  </Button>
160
  )}
 
 
161
  <Button
162
  type="button"
163
  variant="ghost"
@@ -176,24 +198,31 @@ export default function MessageBubble({ message }: Props) {
176
  <Copy className="w-3.5 h-3.5" />
177
  )}
178
  </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  </>
180
-
181
  )}
182
- <Button
183
- type="button"
184
- variant="ghost"
185
- size="icon-xs"
186
- className="absolute top-2 right-16 text-muted-foreground hover:text-foreground"
187
- onClick={handleSpeech}
188
- aria-label={isSpeaking ? "Pause speech" : "Play speech"}
189
- >
190
- {isSpeaking ? (
191
- <Pause className="w-3.5 h-3.5" />
192
- ) : (
193
- <Play className="w-3.5 h-3.5" />
194
- )}
195
- </Button>
196
- <div className={`prose-chat text-sm ${message.content ? "pr-14" : ""}`}>
197
  {message.content ? (
198
  <ReactMarkdown
199
  remarkPlugins={[remarkGfm]}
@@ -224,4 +253,4 @@ export default function MessageBubble({ message }: Props) {
224
  )}
225
  </div>
226
  );
227
- }
 
1
  "use client";
2
 
3
+ import { useState, useRef, useEffect } from "react";
4
  import ReactMarkdown, { type Components } from "react-markdown";
5
  import rehypeHighlight from "rehype-highlight";
6
  import remarkGfm from "remark-gfm";
7
  import type { ChatMsg } from "@/store/chat-store";
8
  import { api } from "@/lib/api";
9
+ import { Brain, User, Copy, Check, Share2, Link2, X, Play, Pause } from "lucide-react";
10
  import { Button } from "@/components/ui/button";
11
 
12
  interface Props {
 
41
  ),
42
  code: ({ className, children, ...props }) => {
43
  const language = /language-(\w+)/.exec(className ?? "")?.[1];
 
44
  return (
45
  <code className={className} data-language={language} {...props}>
46
  {children}
 
57
  const [isSpeaking, setIsSpeaking] = useState(false);
58
  const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
59
  const sharedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60
+ const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
61
+
62
+ // Component unmount ആകുമ്പോൾ speech cancel ചെയ്യും
63
+ useEffect(() => {
64
+ return () => {
65
+ if (utteranceRef.current) {
66
+ window.speechSynthesis.cancel();
67
+ }
68
+ };
69
+ }, []);
70
 
71
  const handleCopy = async () => {
72
  if (!message.content) return;
 
82
 
83
  const handleShare = async () => {
84
  if (!message.content || message.isStreaming) return;
 
85
  try {
86
  const data = await api.post<{ message_id: string; share_url: string }>(
87
  `/api/v1/chat/share/${message.id}`
 
103
  }, 2000);
104
  }
105
  };
106
+
107
  const handleSpeech = () => {
108
+ if (!message.content || message.isStreaming) return;
109
 
110
+ // Already speaking — cancel ചെയ്യും
111
+ if (isSpeaking) {
112
+ window.speechSynthesis.cancel();
113
+ setIsSpeaking(false);
114
+ utteranceRef.current = null;
115
+ return;
116
+ }
117
 
118
+ const utterance = new SpeechSynthesisUtterance(message.content);
119
+ utteranceRef.current = utterance;
120
 
121
+ utterance.onend = () => {
122
+ setIsSpeaking(false);
123
+ utteranceRef.current = null;
124
+ };
125
+ utterance.onerror = () => {
126
+ setIsSpeaking(false);
127
+ utteranceRef.current = null;
128
+ };
129
 
130
+ // Chrome bug fix: onstart reliable അല്ല, speak() മുൻപ് set ചെയ്യണം
131
+ setIsSpeaking(true);
132
+ window.speechSynthesis.speak(utterance);
133
+ };
134
 
135
  return (
136
  <div
 
155
  <>
156
  {message.content && (
157
  <>
158
+ {/* Share button */}
159
  {!message.isStreaming && (
160
  <Button
161
  type="button"
 
178
  )}
179
  </Button>
180
  )}
181
+
182
+ {/* Copy button */}
183
  <Button
184
  type="button"
185
  variant="ghost"
 
198
  <Copy className="w-3.5 h-3.5" />
199
  )}
200
  </Button>
201
+
202
+ {/* Play / Pause button */}
203
+ <Button
204
+ type="button"
205
+ variant="ghost"
206
+ size="icon-xs"
207
+ className={`absolute top-2 right-16 text-muted-foreground hover:text-foreground transition-opacity ${
208
+ isSpeaking
209
+ ? "opacity-100"
210
+ : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
211
+ }`}
212
+ onClick={handleSpeech}
213
+ disabled={message.isStreaming}
214
+ aria-label={isSpeaking ? "Stop speech" : "Play speech"}
215
+ >
216
+ {isSpeaking ? (
217
+ <Pause className="w-3.5 h-3.5 text-primary" />
218
+ ) : (
219
+ <Play className="w-3.5 h-3.5" />
220
+ )}
221
+ </Button>
222
  </>
 
223
  )}
224
+
225
+ <div className={`prose-chat text-sm ${message.content ? "pr-20" : ""}`}>
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  {message.content ? (
227
  <ReactMarkdown
228
  remarkPlugins={[remarkGfm]}
 
253
  )}
254
  </div>
255
  );
256
+ }