mirror of
https://github.com/yusufipk/YTChatHub.git
synced 2026-09-11 10:56:17 +00:00
feat: integrate timezone support for message timestamps in chat and overlay components
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user