feat: integrate timezone support for message timestamps in chat and overlay components

This commit is contained in:
Yusuf İpek
2025-10-11 20:13:41 +03:00
parent d661e969d9
commit 693ee684ab
7 changed files with 192 additions and 5 deletions
+25 -2
View File
@@ -64,6 +64,11 @@ export async function bootstrapInnertube(videoId: string): Promise<IngestionCont
const emitter: ChatEventEmitter = new EventEmitter(); const emitter: ChatEventEmitter = new EventEmitter();
liveChat.on('chat-update', (action: any) => { liveChat.on('chat-update', (action: any) => {
// Log the complete raw action data from YouTube (commented out for production)
// console.log('=== YOUTUBE RAW MESSAGE DATA ===');
// console.log('Action type:', action?.type);
// console.log('Complete action object:', JSON.stringify(action, null, 2));
const normalized = normalizeAction(action); const normalized = normalizeAction(action);
if (normalized) { if (normalized) {
emitter.emit('message', normalized); emitter.emit('message', normalized);
@@ -156,8 +161,23 @@ function resolveTimestamp(timestamp: number | string | undefined): string {
} }
const numeric = typeof timestamp === 'string' ? Number(timestamp) : timestamp; const numeric = typeof timestamp === 'string' ? Number(timestamp) : timestamp;
if (Number.isFinite(numeric)) { if (Number.isFinite(numeric)) {
const millis = numeric > 1e12 ? numeric / 1000 : numeric; // YouTube timestamps are in microseconds (16 digits) or milliseconds (13 digits)
// If it's microseconds (>= 1e15), divide by 1000 to get milliseconds
// If it's milliseconds (>= 1e12), use as is
let millis: number;
if (numeric >= 1e15) {
// Microseconds - convert to milliseconds
millis = numeric / 1000;
} else if (numeric >= 1e12) {
// Already milliseconds
millis = numeric;
} else {
// Fallback for smaller numbers
millis = numeric;
}
return new Date(millis).toISOString(); return new Date(millis).toISOString();
} }
@@ -389,6 +409,9 @@ function normalizeAction(action: any): ChatMessage | null {
? (item.header_subtext?.text || item.header_primary_text?.text || '') ? (item.header_subtext?.text || item.header_primary_text?.text || '')
: ''; : '';
// Use timestamp_usec (microseconds) as it's more accurate than timestamp (milliseconds)
const timestampToUse = item.timestamp_usec ?? item.timestamp;
return { return {
id: String(item.id ?? item.timestamp_usec ?? Date.now()), id: String(item.id ?? item.timestamp_usec ?? Date.now()),
author: authorName, author: authorName,
@@ -396,7 +419,7 @@ function normalizeAction(action: any): ChatMessage | null {
authorChannelId: authorChannelId ? String(authorChannelId) : undefined, authorChannelId: authorChannelId ? String(authorChannelId) : undefined,
text: resolvedText || membershipFallbackText, text: resolvedText || membershipFallbackText,
runs: (() => { const r = resolveMessageRuns(item); return r.length ? r : undefined; })(), runs: (() => { const r = resolveMessageRuns(item); return r.length ? r : undefined; })(),
publishedAt: resolveTimestamp(item.timestamp ?? item.timestamp_usec), publishedAt: resolveTimestamp(timestampToUse),
badges: badges.length > 0 ? badges : undefined, badges: badges.length > 0 ? badges : undefined,
isModerator, isModerator,
isMember, isMember,
+8 -2
View File
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import type { ChatMessage } from '@shared/chat'; import type { ChatMessage } from '@shared/chat';
import { useTimezone } from '../../lib/TimezoneContext';
import { formatTimestamp } from '../../lib/timezone';
// URL regex for detecting links (http/https) // URL regex for detecting links (http/https)
const URL_REGEX = /(https?:\/\/[^\s]+)/gi; const URL_REGEX = /(https?:\/\/[^\s]+)/gi;
@@ -582,6 +584,8 @@ function ChatListPanel({ messages, renderItem, emptyState }: ChatListPanelProps)
} }
function ChatItem({ message, isSelected, onSelect, onLinkClick }: ChatItemProps) { function ChatItem({ message, isSelected, onSelect, onLinkClick }: ChatItemProps) {
const { timezone } = useTimezone();
return ( return (
<button <button
className={isSelected ? 'chatItem chatItem--active' : 'chatItem'} className={isSelected ? 'chatItem chatItem--active' : 'chatItem'}
@@ -614,7 +618,7 @@ function ChatItem({ message, isSelected, onSelect, onLinkClick }: ChatItemProps)
</span> </span>
)} )}
</div> </div>
<time className="chatItem__time">{new Date(message.publishedAt).toLocaleTimeString()}</time> <time className="chatItem__time">{formatTimestamp(message.publishedAt, timezone)}</time>
</div> </div>
</div> </div>
{message.runs?.length ? ( {message.runs?.length ? (
@@ -637,6 +641,8 @@ function ChatItem({ message, isSelected, onSelect, onLinkClick }: ChatItemProps)
} }
function MemberItem({ message, isSelected, onSelect, onLinkClick }: ChatItemProps) { function MemberItem({ message, isSelected, onSelect, onLinkClick }: ChatItemProps) {
const { timezone } = useTimezone();
return ( return (
<button <button
className={isSelected ? 'memberItem memberItem--active' : 'memberItem'} className={isSelected ? 'memberItem memberItem--active' : 'memberItem'}
@@ -653,7 +659,7 @@ function MemberItem({ message, isSelected, onSelect, onLinkClick }: ChatItemProp
? `Sent ${message.giftCount} gift membership${message.giftCount > 1 ? 's' : ''}` ? `Sent ${message.giftCount} gift membership${message.giftCount > 1 ? 's' : ''}`
: message.membershipLevel || 'New member'} : message.membershipLevel || 'New member'}
</span> </span>
<time className="memberItem__time">{new Date(message.publishedAt).toLocaleTimeString()}</time> <time className="memberItem__time">{formatTimestamp(message.publishedAt, timezone)}</time>
</div> </div>
</div> </div>
{message.runs?.length ? ( {message.runs?.length ? (
+8
View File
@@ -1241,6 +1241,14 @@ main {
word-break: break-word; word-break: break-word;
} }
.overlay__timestamp {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.7);
font-weight: 400;
margin-left: auto;
white-space: nowrap;
}
.overlay__placeholder { .overlay__placeholder {
padding: 1rem 1.4rem; padding: 1rem 1.4rem;
border-radius: 12px; border-radius: 12px;
+6 -1
View File
@@ -1,5 +1,6 @@
import './globals.css'; import './globals.css';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { TimezoneProvider } from '../lib/TimezoneContext';
export const metadata = { export const metadata = {
title: 'YouTube Chat Client', title: 'YouTube Chat Client',
@@ -9,7 +10,11 @@ export const metadata = {
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body>
<TimezoneProvider>
{children}
</TimezoneProvider>
</body>
</html> </html>
); );
} }
+4
View File
@@ -2,6 +2,8 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import type { ChatMessage } from '@shared/chat'; import type { ChatMessage } from '@shared/chat';
import { useTimezone } from '../../lib/TimezoneContext';
import { formatTimestamp } from '../../lib/timezone';
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100'; const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100';
@@ -14,6 +16,7 @@ export default function OverlayPage() {
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [fadingOut, setFadingOut] = useState(false); const [fadingOut, setFadingOut] = useState(false);
const connectionRef = useRef<EventSource | null>(null); const connectionRef = useRef<EventSource | null>(null);
const { timezone } = useTimezone();
useEffect(() => { useEffect(() => {
// Prevent multiple connections // Prevent multiple connections
@@ -143,6 +146,7 @@ export default function OverlayPage() {
</span> </span>
) )
))} ))}
<time className="overlay__timestamp">{formatTimestamp(message.publishedAt, timezone)}</time>
</div> </div>
</div> </div>
</div> </div>
+43
View File
@@ -0,0 +1,43 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { getBrowserTimezone } from './timezone';
interface TimezoneContextType {
timezone: string;
setTimezone: (timezone: string) => void;
}
const TimezoneContext = createContext<TimezoneContextType | undefined>(undefined);
export function TimezoneProvider({ children }: { children: React.ReactNode }) {
// Initialize with detected timezone immediately to avoid empty string
const [timezone, setTimezone] = useState<string>(() => {
try {
return getBrowserTimezone();
} catch (error) {
console.warn('Failed to detect timezone on initialization:', error);
return 'UTC';
}
});
useEffect(() => {
// Re-detect browser timezone on mount (in case it changed)
const detectedTimezone = getBrowserTimezone();
setTimezone(detectedTimezone);
}, []);
return (
<TimezoneContext.Provider value={{ timezone, setTimezone }}>
{children}
</TimezoneContext.Provider>
);
}
export function useTimezone() {
const context = useContext(TimezoneContext);
if (context === undefined) {
throw new Error('useTimezone must be used within a TimezoneProvider');
}
return context;
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Timezone utilities for handling browser timezone detection and formatting
*/
export function getBrowserTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (error) {
console.warn('Failed to detect browser timezone, falling back to UTC:', error);
return 'UTC';
}
}
export function formatTimestamp(
isoString: string,
timezone?: string,
options?: Intl.DateTimeFormatOptions
): string {
const date = new Date(isoString);
if (!date || isNaN(date.getTime())) {
return 'Invalid date';
}
const defaultOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
...options
};
const targetTimezone = timezone || getBrowserTimezone();
// If no valid timezone, fall back to local time
if (!targetTimezone || targetTimezone === '') {
console.warn('No valid timezone provided, using local time');
return date.toLocaleTimeString('en-US', defaultOptions);
}
try {
const result = new Intl.DateTimeFormat('en-US', {
...defaultOptions,
timeZone: targetTimezone
}).format(date);
return result;
} catch (error) {
console.warn('Failed to format timestamp with timezone, falling back to local:', error);
// Try GMT+3 as a fallback if the detected timezone fails
try {
const gmt3Result = new Intl.DateTimeFormat('en-US', {
...defaultOptions,
timeZone: 'Europe/Istanbul' // GMT+3
}).format(date);
return gmt3Result;
} catch (gmt3Error) {
console.warn('GMT+3 fallback also failed:', gmt3Error);
return date.toLocaleTimeString('en-US', defaultOptions);
}
}
}
export function formatTimestampWithDate(
isoString: string,
timezone?: string,
options?: Intl.DateTimeFormatOptions
): string {
const date = new Date(isoString);
if (!date || isNaN(date.getTime())) {
return 'Invalid date';
}
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
...options
};
const targetTimezone = timezone || getBrowserTimezone();
try {
return new Intl.DateTimeFormat('en-US', {
...defaultOptions,
timeZone: targetTimezone
}).format(date);
} catch (error) {
console.warn('Failed to format timestamp with timezone, falling back to local:', error);
return date.toLocaleString('en-US', defaultOptions);
}
}