From bc741633b41e45fc98cda00575c1f79ab81f85b0 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 11 Dec 2025 13:38:49 -0700 Subject: [PATCH 1/2] sync chat to transcript, video --- src/components/call/CallPage.tsx | 128 ++++++++++++++++++++++++++++++- src/components/call/ChatLog.tsx | 46 ++++++++--- 2 files changed, 163 insertions(+), 11 deletions(-) diff --git a/src/components/call/CallPage.tsx b/src/components/call/CallPage.tsx index 7845f71..57c1a82 100644 --- a/src/components/call/CallPage.tsx +++ b/src/components/call/CallPage.tsx @@ -40,9 +40,13 @@ const CallPage: React.FC = () => { const transcriptRef = useRef(null); const chatLogRef = useRef(null); const [isUserScrollingTranscript, setIsUserScrollingTranscript] = useState(false); + const [isChatSynced, setIsChatSynced] = useState(true); const transcriptScrollTimeoutRef = useRef | undefined>(undefined); const isProgrammaticScrollRef = useRef(false); + const isProgrammaticChatScrollRef = useRef(false); const lastHighlightedTimestampRef = useRef(null); + const lastHighlightedChatTimestampRef = useRef(null); + const previousVideoTimeRef = useRef(0); const [player, setPlayer] = useState(null); const [currentVideoTime, setCurrentVideoTime] = useState(0); const [isPlaying, setIsPlaying] = useState(false); @@ -460,6 +464,35 @@ const CallPage: React.FC = () => { }; }, [callData]); + // Detect manual scrolling on chat log - breaks sync until user re-syncs + useEffect(() => { + if (!chatLogRef.current) return; + + const handleChatScroll = () => { + // Ignore programmatic scrolls + if (isProgrammaticChatScrollRef.current) { + return; + } + + // Break sync permanently until user clicks re-sync + setIsChatSynced(false); + }; + + const container = chatLogRef.current; + container.addEventListener('scroll', handleChatScroll); + + return () => { + container.removeEventListener('scroll', handleChatScroll); + }; + }, [callData]); + + // Handler to re-sync chat to video + const handleResyncChat = () => { + setIsChatSynced(true); + // Reset the last highlighted timestamp to force immediate scroll + lastHighlightedChatTimestampRef.current = null; + }; + const scrollTranscriptToEntry = (entryElement: HTMLElement) => { if (!transcriptRef.current) return; @@ -511,11 +544,19 @@ const CallPage: React.FC = () => { const maxScroll = Math.max(0, container.scrollHeight - containerHeight); const finalScrollTop = Math.max(0, Math.min(targetScrollTop, maxScroll)); + // Mark as programmatic scroll + isProgrammaticChatScrollRef.current = true; + // Smooth scroll within container only container.scrollTo({ top: finalScrollTop, behavior: 'smooth' }); + + // Reset flag after scroll completes + setTimeout(() => { + isProgrammaticChatScrollRef.current = false; + }, 500); }; // Auto-scroll transcript to highlighted entry @@ -566,6 +607,61 @@ const CallPage: React.FC = () => { } }, [currentVideoTime, isPlaying, callConfig, isUserScrollingTranscript]); + // Auto-scroll chat log to current entry + useEffect(() => { + // Skip if chat is not synced, not playing, or no sync config + if (!isChatSynced || !isPlaying || !chatLogRef.current || !callConfig?.sync?.transcriptStartTime || !callConfig?.sync?.videoStartTime) return; + + // Find the current chat entry by data attribute + const currentEntry = chatLogRef.current.querySelector('[data-current-chat="true"]') as HTMLElement; + + if (currentEntry) { + const container = chatLogRef.current; + + // Get the timestamp to check if it's a new entry + const currentTimestamp = currentEntry.getAttribute('data-chat-timestamp'); + + // Only scroll if this is a different entry than last time + if (currentTimestamp === lastHighlightedChatTimestampRef.current) { + return; + } + lastHighlightedChatTimestampRef.current = currentTimestamp; + + // Get positions relative to the container, not the document + const containerHeight = container.clientHeight; + const containerRect = container.getBoundingClientRect(); + const entryRect = currentEntry.getBoundingClientRect(); + + // Calculate entry position relative to container's scroll area + const entryOffsetFromContainerTop = entryRect.top - containerRect.top + container.scrollTop; + const entryHeight = currentEntry.offsetHeight; + + // Calculate where the entry currently is in the viewport + const entryRelativeTop = entryOffsetFromContainerTop - container.scrollTop; + const entryRelativeBottom = entryRelativeTop + entryHeight; + + // Check if the entry is visible in a good position (20% to 70% of viewport) + const isInGoodPosition = entryRelativeTop >= (containerHeight * 0.2) && + entryRelativeBottom <= (containerHeight * 0.7); + + if (!isInGoodPosition) { + scrollChatToEntry(currentEntry); + } + } + }, [currentVideoTime, isPlaying, callConfig, isChatSynced]); + + // Detect video seek and re-sync chat (mirrors transcript behavior) + useEffect(() => { + const timeDiff = Math.abs(currentVideoTime - previousVideoTimeRef.current); + + if (timeDiff > 2 && previousVideoTimeRef.current > 0) { + setIsChatSynced(true); + lastHighlightedChatTimestampRef.current = null; + } + + previousVideoTimeRef.current = currentVideoTime; + }, [currentVideoTime]); + // YouTube player handlers const onPlayerReady: YouTubeProps['onReady'] = (event) => { setPlayer(event.target); @@ -640,6 +736,10 @@ const CallPage: React.FC = () => { // Update currentVideoTime immediately to ensure highlighting updates setCurrentVideoTime(adjustedTime); + // Re-sync chat when user explicitly seeks to a timestamp + setIsChatSynced(true); + lastHighlightedChatTimestampRef.current = null; // Force immediate scroll on next render + // Store search result for highlighting if it came from search if (searchResult) { setSelectedSearchResult({ @@ -670,6 +770,16 @@ const CallPage: React.FC = () => { } } + // Scroll chat to corresponding time (with a small delay for the data attribute to update) + if (chatLogRef.current && callConfig?.sync) { + setTimeout(() => { + const currentChatEntry = chatLogRef.current?.querySelector('[data-current-chat="true"]') as HTMLElement; + if (currentChatEntry) { + scrollChatToEntry(currentChatEntry); + } + }, 100); + } + // Update URL with timestamp for sharing const newHash = `#t=${Math.floor(adjustedTime)}`; window.history.replaceState(null, '', newHash); @@ -1134,9 +1244,23 @@ const CallPage: React.FC = () => {
{callData.chatContent && (
-

Chat Logs

+
+

Chat Logs

+ {!isChatSynced && callConfig?.sync && ( + + )} +
- +
)} diff --git a/src/components/call/ChatLog.tsx b/src/components/call/ChatLog.tsx index b49b2cf..891a109 100644 --- a/src/components/call/ChatLog.tsx +++ b/src/components/call/ChatLog.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; interface ChatMessage { timestamp: string; @@ -16,9 +16,11 @@ interface ChatLogProps { } | null; selectedSearchResult?: {timestamp: string, text: string, type: string} | null; onTimestampClick?: (timestamp: string) => void; + currentVideoTime?: number; + isPlaying?: boolean; } -const ChatLog: React.FC = ({ content, syncConfig, selectedSearchResult, onTimestampClick }) => { +const ChatLog: React.FC = ({ content, syncConfig, selectedSearchResult, onTimestampClick, currentVideoTime, isPlaying }) => { // --- Handle clicking on chat entries (but not links) --- const handleChatEntryClick = (event: React.MouseEvent, timestamp: string) => { // Don't trigger video jump if clicking on a link @@ -55,6 +57,25 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe }); }; + // --- Check if a chat entry should be highlighted based on current video time --- + const isCurrentChatEntry = useCallback((entryTimestamp: string, index: number, allEntries: ChatMessage[]): boolean => { + if (!syncConfig?.transcriptStartTime || !syncConfig?.videoStartTime || currentVideoTime === undefined) return false; + if (entryTimestamp === '00:00:00') return false; // Skip virtual/unknown timestamp entries + + const entryVideoTime = getAdjustedVideoTime(entryTimestamp); + + // Find next entry with a valid timestamp + let nextEntryVideoTime = Infinity; + for (let i = index + 1; i < allEntries.length; i++) { + if (allEntries[i].timestamp !== '00:00:00') { + nextEntryVideoTime = getAdjustedVideoTime(allEntries[i].timestamp); + break; + } + } + + return currentVideoTime >= entryVideoTime && currentVideoTime < nextEntryVideoTime; + }, [syncConfig, currentVideoTime]); + // --- Timestamp conversion helpers --- const timestampToSeconds = (timestamp: string): number => { const parts = timestamp.split(':'); @@ -363,11 +384,13 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe const replies = parentToReplies.get(key); const isParentWithReplies = replies && replies.length > 0; const isSelectedSearch = selectedSearchResult?.timestamp === message.timestamp && selectedSearchResult?.type === 'chat'; + const isCurrentEntry = isPlaying && isCurrentChatEntry(message.timestamp, index, standaloneMessages); return (
{/* Message */}
handleChatEntryClick(e, message.timestamp)} className={`group hover:bg-slate-50 dark:hover:bg-slate-700/30 py-1 px-2 -mx-2 rounded transition-colors cursor-pointer ${isSelectedSearch ? 'bg-yellow-50 dark:bg-yellow-900/20 border-l-2 border-yellow-500 rounded-r-md' : ''} @@ -391,9 +414,9 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe ? (isSelectedSearch ? 'text-yellow-600 dark:text-yellow-400 italic' : 'text-slate-500 dark:text-slate-400 italic') : (isSelectedSearch ? 'text-slate-900 dark:text-slate-100' : 'text-slate-600 dark:text-slate-400') }`}> - {message.message.split(/\r\n|\r|\n/).map((line, index) => ( - - {index > 0 &&
} + {message.message.split(/\r\n|\r|\n/).map((line, lineIndex) => ( + + {lineIndex > 0 &&
} {renderTextWithLinks(line)}
))} @@ -453,6 +476,9 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe const replyMatch = englishReplyMatch || dutchReplyMatch; const actualMessage = replyMatch ? replyMatch[2] : reply.message; const isSelectedReply = selectedSearchResult?.timestamp === reply.timestamp && selectedSearchResult?.type === 'chat'; + // Find the reply's index in standaloneMessages for current entry check + const replyIndexInAll = standaloneMessages.findIndex(m => m.timestamp === reply.timestamp && m.message === reply.message); + const isReplyCurrentEntry = isPlaying && replyIndexInAll >= 0 && isCurrentChatEntry(reply.timestamp, replyIndexInAll, standaloneMessages); // Find reactions for the reply's actual message content const replyMessageReactions = Array.from(reactions.entries()) @@ -479,8 +505,10 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe
handleChatEntryClick(e, reply.timestamp)} - className={`ml-20 mt-1 pl-4 border-l-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/30 rounded transition-colors ${isSelectedReply ? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20' : 'border-slate-200 dark:border-slate-600'}`} + className={`ml-20 mt-1 pl-4 border-l-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/30 rounded transition-colors + ${isSelectedReply ? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20' : 'border-slate-200 dark:border-slate-600'}`} >
@@ -494,9 +522,9 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe {reply.speaker}: - {actualMessage.split(/\r\n|\r|\n/).map((line, index) => ( - - {index > 0 &&
} + {actualMessage.split(/\r\n|\r|\n/).map((line, lineIdx) => ( + + {lineIdx > 0 &&
} {renderTextWithLinks(line)}
))} From f61a0d7f74aae2f554de27434d8bb76826495655 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 11 Dec 2025 13:39:42 -0700 Subject: [PATCH 2/2] add support for French to chat log --- src/components/call/ChatLog.tsx | 46 +++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/components/call/ChatLog.tsx b/src/components/call/ChatLog.tsx index 891a109..41b9247 100644 --- a/src/components/call/ChatLog.tsx +++ b/src/components/call/ChatLog.tsx @@ -123,13 +123,15 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe if (targetIndex >= 0) { const lastNonEmptyLine = processedLines[targetIndex]; // Don't merge if the last line is a "Replying to" header - treat the next line as the reply content - // Handle both English and Dutch formats - if (/:\tReplying to/.test(lastNonEmptyLine) || /:\tAntwoord verzenden naar/.test(lastNonEmptyLine)) { + // Handle English, Dutch, and French formats + if (/:\tReplying to/.test(lastNonEmptyLine) || /:\tAntwoord verzenden naar/.test(lastNonEmptyLine) || /:\tRépondre à/.test(lastNonEmptyLine)) { // This is the actual reply content, merge it with the "Replying to" line - // Normalize Dutch format to English + // Normalize Dutch and French formats to English let normalizedLine = lastNonEmptyLine; if (/:\tAntwoord verzenden naar/.test(lastNonEmptyLine)) { normalizedLine = lastNonEmptyLine.replace(':\tAntwoord verzenden naar', ':\tReplying to'); + } else if (/:\tRépondre à/.test(lastNonEmptyLine)) { + normalizedLine = lastNonEmptyLine.replace(':\tRépondre à', ':\tReplying to'); } processedLines[targetIndex] = `${normalizedLine.trimEnd()} → ${line.trim()}`; } else { @@ -172,7 +174,7 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe } // Parse reactions for later display - if (message.startsWith('Reacted to') || message.startsWith('Heeft gereageerd op')) { + if (message.startsWith('Reacted to') || message.startsWith('Heeft gereageerd op') || message.startsWith('A réagi à')) { // Handle multiple formats: // English: // 1. Reacted to "message" with emoji (may have nested quotes) @@ -180,9 +182,11 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe // 3. Reacted to message with "emoji" (no quotes around message) // Dutch: // 4. Heeft gereageerd op "message" met emoji + // French: + // 5. A réagi à "message" avec emoji // // Strategy: Use a greedy match that captures everything between the first quote - // and " with " (for English) or " met " (for Dutch), handling nested quotes and newlines + // and " with " (for English), " met " (for Dutch), or " avec " (for French), handling nested quotes and newlines const reactionPatterns = [ // English: Match from opening quote to last " with ", then capture emoji (with multiline support) /^Reacted to [""](.+?)[""] with [""](.+?)[""]$/s, // Both quoted @@ -192,6 +196,11 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe // Dutch format /^Heeft gereageerd op [""](.+?)[""] met (.+)$/s, /^Heeft gereageerd op (.+?) met [""](.+?)[""]$/s, // Message unquoted, emoji quoted + // French format + /^A réagi à [""](.+?)[""] avec [""](.+?)[""]$/s, // Both quoted + /^A réagi à [""](.+?)[""] avec (.+)$/s, // Message quoted, emoji unquoted + /^A réagi à (.+?) avec [""](.+?)[""]$/s, // Message unquoted, emoji quoted + /^A réagi à (.+?) avec (.+)$/s, // Neither quoted ]; let matched = false; @@ -234,18 +243,20 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe if (matched) continue; } - // Handle "Replying to" messages (English and Dutch) - if (message.startsWith('Replying to') || message.startsWith('Antwoord verzenden naar')) { + // Handle "Replying to" messages (English, Dutch, and French) + if (message.startsWith('Replying to') || message.startsWith('Antwoord verzenden naar') || message.startsWith('Répondre à')) { if (i + 1 < lines.length) { const nextLine = lines[i + 1]; // Check if next line is NOT a new timestamped message if (!nextLine.match(/^\d{2}:\d{2}:\d{2}\t/)) { const actualMessage = nextLine.trim(); if (actualMessage) { - // Convert Dutch format to English format for consistency + // Convert Dutch and French formats to English format for consistency let normalizedMessage = message; if (message.startsWith('Antwoord verzenden naar')) { normalizedMessage = message.replace('Antwoord verzenden naar', 'Replying to'); + } else if (message.startsWith('Répondre à')) { + normalizedMessage = message.replace('Répondre à', 'Replying to'); } messages.push({ timestamp, @@ -289,7 +300,7 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe const parentMessages: ChatMessage[] = []; const replyMessages: ChatMessage[] = []; messages.forEach((msg) => { - if (msg.message.startsWith('Replying to') || msg.message.startsWith('Antwoord verzenden naar')) { + if (msg.message.startsWith('Replying to') || msg.message.startsWith('Antwoord verzenden naar') || msg.message.startsWith('Répondre à')) { replyMessages.push(msg); } else { parentMessages.push(msg); @@ -300,10 +311,11 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe const matchedReplies = new Set(); replyMessages.forEach((reply) => { // Handle quotes in the replied-to text by looking for the pattern more flexibly - // Support both English and Dutch formats + // Support English, Dutch, and French formats const englishMatch = reply.message.match(/^Replying to "(.+?)"(?:\s|$)/); const dutchMatch = reply.message.match(/^Antwoord verzenden naar "(.+?)"(?:\s|$)/); - const match = englishMatch || dutchMatch; + const frenchMatch = reply.message.match(/^Répondre à "(.+?)"(?:\s|$)/); + const match = englishMatch || dutchMatch || frenchMatch; if (match) { const quotedText = match[1]; @@ -335,10 +347,11 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe replyMessages.forEach((reply) => { if (matchedReplies.has(reply)) return; // Handle quotes in the replied-to text by looking for the pattern more flexibly - // Support both English and Dutch formats + // Support English, Dutch, and French formats const englishMatch = reply.message.match(/^Replying to "(.+?)"(?:\s|$)/); const dutchMatch = reply.message.match(/^Antwoord verzenden naar "(.+?)"(?:\s|$)/); - const match = englishMatch || dutchMatch; + const frenchMatch = reply.message.match(/^Répondre à "(.+?)"(?:\s|$)/); + const match = englishMatch || dutchMatch || frenchMatch; if (match) { const quotedText = match[1]; @@ -468,12 +481,13 @@ const ChatLog: React.FC = ({ content, syncConfig, selectedSearchRe })()} {/* Reply Messages */} {isParentWithReplies && replies!.map((reply, replyIndex) => { - // Extract just the actual message content after "Replying to..." or "Antwoord verzenden naar..." - // The message format is: "Replying to "quoted" → actual message" or "Antwoord verzenden naar "quoted" → actual message" + // Extract just the actual message content after "Replying to...", "Antwoord verzenden naar...", or "Répondre à..." + // The message format is: "Replying to "quoted" → actual message" // Handle quotes in the replied-to text by using a more flexible pattern const englishReplyMatch = reply.message.match(/^Replying to "(.+?)"\s*→\s*(.+)$/); const dutchReplyMatch = reply.message.match(/^Antwoord verzenden naar "(.+?)"\s*→\s*(.+)$/); - const replyMatch = englishReplyMatch || dutchReplyMatch; + const frenchReplyMatch = reply.message.match(/^Répondre à "(.+?)"\s*→\s*(.+)$/); + const replyMatch = englishReplyMatch || dutchReplyMatch || frenchReplyMatch; const actualMessage = replyMatch ? replyMatch[2] : reply.message; const isSelectedReply = selectedSearchResult?.timestamp === reply.timestamp && selectedSearchResult?.type === 'chat'; // Find the reply's index in standaloneMessages for current entry check