From c6aa70a64731b608fade3acab48c1d1d2df280b6 Mon Sep 17 00:00:00 2001 From: 0t4u <61939142+0t4u@users.noreply.github.com> Date: Mon, 5 Dec 2022 20:33:28 +0000 Subject: Add files via upload --- modules/ChatReplay/ChatMessageRender.tsx | 171 +++++++++++++++++++++++++++++++ modules/ChatReplay/ChatReplay.tsx | 74 +++++++++++++ modules/ChatReplay/ChatReplayPanel.tsx | 170 ++++++++++++++++++++++++++++++ modules/ChatReplay/parser/default.ts | 21 ++++ modules/ChatReplay/parser/index.ts | 32 ++++++ modules/ChatReplay/parser/yt-dlp.ts | 169 ++++++++++++++++++++++++++++++ 6 files changed, 637 insertions(+) create mode 100644 modules/ChatReplay/ChatMessageRender.tsx create mode 100644 modules/ChatReplay/ChatReplay.tsx create mode 100644 modules/ChatReplay/ChatReplayPanel.tsx create mode 100644 modules/ChatReplay/parser/default.ts create mode 100644 modules/ChatReplay/parser/index.ts create mode 100644 modules/ChatReplay/parser/yt-dlp.ts (limited to 'modules/ChatReplay') diff --git a/modules/ChatReplay/ChatMessageRender.tsx b/modules/ChatReplay/ChatMessageRender.tsx new file mode 100644 index 0000000..1207457 --- /dev/null +++ b/modules/ChatReplay/ChatMessageRender.tsx @@ -0,0 +1,171 @@ +import React from "react"; +import { ChatMessage } from "../database.d"; +import { formatSeconds } from "../format"; +import { proxyYT3 as proxyURL } from "../util"; + +export type ChatMessageRenderProps = { + message: ChatMessage; +}; + +const regexEmoji = /(:[^:]+:)/g; + +const ChatMessageRender = React.memo((props: ChatMessageRenderProps) => { + const msg = props.message; + + const generateMessageContent = (msg: ChatMessage) => { + if (!msg.emotes) return msg.message; + + // Process emotes + const tokens = msg.message.split(regexEmoji); + return tokens.map((token) => { + if (!token.startsWith(":")) return token; + + let images = msg.emotes.find((emote) => emote.name === token)?.images; + if (!images) + images = msg.emotes.find((emote) => emote.shortcuts.includes(token)) + ?.images; + if (!images) return token; + + const emoteURL = + images.find((image) => image.id === "source")?.url || images[0]?.url; + + if (!emoteURL) return token; + return ( + {token} + ); + }); + }; + + switch (msg.message_type) { + case "paid_message": + return ( +
+
+
+
+ {msg.author.name} + {msg.author.badges && msg.author.badges[0].icons ? ( + {msg.author.badges[0].title} + ) : null} +
+
{msg.money.text}
+
+
+ [{formatSeconds(msg.time_in_seconds)}] +
+
+
{generateMessageContent(msg)}
+
+ ); + case "membership_item": + return ( +
+
+
+
{msg.author.name}
+ {msg.author.badges && msg.author.badges[0].icons ? ( + {msg.author.badges[0].title} + ) : null} +
+
+ [{formatSeconds(msg.time_in_seconds)}] +
+
+ {generateMessageContent(msg)} +
+ ); + case "text_message": + const authorType = + msg.author.badges?.map(({ title }) => + title === "Owner" + ? "owner" + : title === "Moderator" + ? "moderator" + : title.toLowerCase().includes("member") + ? "member" + : "" + ) || []; + return ( +
+
+ + {msg.author.name} + {authorType.includes("moderator") && ( + + + + )} + {msg.author.badges?.map((badge) => + Array.isArray(badge.icons) ? ( + {badge.title} + ) : null + )} + + [{formatSeconds(msg.time_in_seconds)}] +
+ {generateMessageContent(msg)} +
+ ); + } + + return null; +}); + +export default ChatMessageRender; diff --git a/modules/ChatReplay/ChatReplay.tsx b/modules/ChatReplay/ChatReplay.tsx new file mode 100644 index 0000000..b18b68b --- /dev/null +++ b/modules/ChatReplay/ChatReplay.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { ChatMessage } from "../database.d"; +import ChatMessageRender from "./ChatMessageRender"; + +export type ChatReplayProps = { + replayData: ChatMessage[]; + currentTimeSeconds: number; +}; + +function bsearch( + arr: T[], + search: number, + transform: (item: T) => number +): number { + let iL = 0, + iR = arr.length - 1, + iM = Math.floor(arr.length / 2); + + while (iR - iL > 1) { + iM = Math.floor((iL + iR) / 2); + const m = transform(arr[iM]); + + if (m < search) iL = iM; + else if (m > search) iR = iM; + else if (m === search || iL === iM) return iM; + } + return iM; +} + +const ChatReplay = (props: ChatReplayProps) => { + const { replayData, currentTimeSeconds } = props; + const [messages, setMessages] = React.useState([]); + + React.useEffect(() => { + /** + * Find the current replayData index where + * time_in_seconds <= currentTimeSeconds + * using binary search + */ + const index = bsearch( + replayData, + currentTimeSeconds, + (message) => message.time_in_seconds + ); + + /** + * Once index is acquired, slice the array + * to get the previous `maxMessages` items + */ + const maxMessages = 50; + const messages = replayData.slice(Math.max(0, index - maxMessages), index); + + // Make sure there are no duplicate messages + const filtered = []; + const messageIds = []; + + for (const message of messages) { + if (messageIds.includes(message.message_id)) continue; + messageIds.push(message.message_id); + filtered.push(message); + } + + setMessages(filtered); + }, [replayData, currentTimeSeconds]); + return ( +
+ {messages.map((msg) => ( + + ))} +
+ ); +}; + +export default ChatReplay; diff --git a/modules/ChatReplay/ChatReplayPanel.tsx b/modules/ChatReplay/ChatReplayPanel.tsx new file mode 100644 index 0000000..b5cfa7c --- /dev/null +++ b/modules/ChatReplay/ChatReplayPanel.tsx @@ -0,0 +1,170 @@ +import React from "react"; +import axios from "axios"; +import { ChatMessage } from "../database.d"; +import ChatReplay from "./ChatReplay"; +import { IconChevronDown, IconFilter } from "../icons"; +import { useDebounce } from "../hooks/useDebounce"; +import { parseChatReplay } from "./parser"; + +export type ChatReplayPanelProps = { + src: string; + currentTimeSeconds: number; + onChatToggle?: (isVisible: boolean) => any; +}; + +const ChatReplayPanel = (props: ChatReplayPanelProps) => { + const [replayData, setReplayData] = React.useState(null); + const [filteredReplayData, setFilteredReplayData] = React.useState< + ChatMessage[] + >(null); + const [downloadProgress, setDownloadProgress] = React.useState(-1); + const [isFilterVisible, setIsFilterVisible] = React.useState(false); + const [chatFilter, setChatFilter] = React.useState(""); + const [isChatVisible, setIsChatVisible] = React.useState(false); + const [isErrored, setIsErrored] = React.useState(false); + const refChatScrollDiv = React.useRef(null); + + const activeChatFilter = useDebounce(chatFilter, 250); + + const downloadChatData = async () => { + setDownloadProgress(0); + setReplayData(null); + setIsErrored(false); + try { + const data = await axios.get(props.src, { + onDownloadProgress: ({ loaded }) => setDownloadProgress(loaded), + transformResponse: (res) => res, + }); + + setReplayData(parseChatReplay(data.data)); + setIsChatVisible(true); + } catch (ex) { + console.log("[chat] error parsing chat:", ex); + setIsErrored(true); + } + }; + + React.useEffect(() => { + if (!replayData) return; + if (!activeChatFilter) return setFilteredReplayData(replayData); + + setFilteredReplayData( + replayData.filter( + (message) => + !!message.message && + message.message.toLowerCase().includes(activeChatFilter.toLowerCase()) + ) + ); + }, [activeChatFilter, replayData]); + + /** + * Automatically download chat replay + */ + React.useEffect(() => { + downloadChatData(); + }, [props.src]); + + React.useEffect(() => { + if (refChatScrollDiv.current) + refChatScrollDiv.current.scrollTo({ + top: refChatScrollDiv.current.scrollHeight, + behavior: "smooth", + }); + }, [props.currentTimeSeconds]); + + React.useEffect(() => { + props.onChatToggle?.(isChatVisible); + }, [isChatVisible]); + + if (replayData === null) + return ( +
{ + if (isErrored || downloadProgress < 0) downloadChatData(); + }} + > + {isErrored ? ( +

Error loading chat, click to retry

+ ) : downloadProgress < 0 ? ( + <> +

Chat replay available!

+

Click to Enable

+ + ) : downloadProgress === 0 ? ( +

Loading chat replay...

+ ) : ( +

Loaded {(downloadProgress / 1024 / 1024).toFixed(2)}MB

+ )} +
+ ); + + return ( +
+
+
+
Chat replay
+
+ + +
+
+ {isFilterVisible && ( +
+
+ setChatFilter(e.target.value)} + /> +
+
+ )} +
+ {isChatVisible && ( +
+
+ +
+
+ )} +
+ ); +}; + +export default ChatReplayPanel; diff --git a/modules/ChatReplay/parser/default.ts b/modules/ChatReplay/parser/default.ts new file mode 100644 index 0000000..acf1aef --- /dev/null +++ b/modules/ChatReplay/parser/default.ts @@ -0,0 +1,21 @@ +import { ChatReplayParser } from "."; +import { ChatMessage } from "../../database.d"; + +export default class DefaultChatParser implements ChatReplayParser { + name: "DefaultChatParser"; + chatData: string = ""; + + constructor(chatData: string) { + this.chatData = chatData.trim(); + } + + canParse(): boolean { + return this.chatData.startsWith("[") && this.chatData.endsWith("]"); + } + + parse(): ChatMessage[] { + return (JSON.parse(this.chatData) as ChatMessage[]).sort( + (a, b) => a.time_in_seconds - b.time_in_seconds + ); + } +} diff --git a/modules/ChatReplay/parser/index.ts b/modules/ChatReplay/parser/index.ts new file mode 100644 index 0000000..d921603 --- /dev/null +++ b/modules/ChatReplay/parser/index.ts @@ -0,0 +1,32 @@ +import { ChatMessage } from "../../database.d"; +import DefaultChatParser from "./default"; +import YtDlpChatParser from "./yt-dlp"; + +export abstract class ChatReplayParser { + name: string = ""; + + constructor(chatData: string) { + if (this.constructor === ChatReplayParser) + throw new Error("Abstract classes can't be instantiated."); + } + + canParse(): boolean { + return false; + } + + parse(): ChatMessage[] { + return []; + } +} + +export const parseChatReplay = (input: string) => { + console.log("[chat] parsing chat data"); + const parser = [DefaultChatParser, YtDlpChatParser] + .map((Parser) => new Parser(input)) + .find((parser) => parser.canParse()); + if (!parser) throw new Error("No suitable chat parser found"); + console.log("[chat] using", parser.name); + const parsed = parser.parse(); + console.log("[chat] found", parsed.length, "messages"); + return parsed; +}; diff --git a/modules/ChatReplay/parser/yt-dlp.ts b/modules/ChatReplay/parser/yt-dlp.ts new file mode 100644 index 0000000..0647961 --- /dev/null +++ b/modules/ChatReplay/parser/yt-dlp.ts @@ -0,0 +1,169 @@ +import { ChatReplayParser } from "."; +import { + ChatMessage, + ChatMessageAuthor, + ChatMessageImage, +} from "../../database.d"; + +export default class YtDlpChatParser implements ChatReplayParser { + name = "YtDlpChatParser"; + chatData: string = ""; + + constructor(chatData: string) { + this.chatData = chatData.trim(); + } + + canParse(): boolean { + return ( + this.chatData.startsWith("{") && + this.chatData.endsWith("}") && + this.chatData.includes("\n") && + this.chatData.includes("clickTrackingParams") + ); + } + + _formatMsec(ms: number): string { + const secs = Math.abs(Math.floor(ms / 1000)), + ss = secs % 60, + mm = Math.floor(secs / 60), + hh = Math.floor(secs / 3600); + return ( + (ms < 0 ? "-" : "") + + (hh > 0 ? [hh, mm, ss] : [mm, ss]) + .map((x) => x.toString().padStart(2, "0")) + .join(":") + ); + } + + parse(): ChatMessage[] { + return this.chatData + .split("\n") + .map((line) => { + try { + return JSON.parse(line); + } catch (ex) { + return null; + } + }) + .filter(Boolean) + .map((event) => { + try { + const actionBase = event.replayChatItemAction.actions[0]; + const action_type = + "addChatItemAction" in actionBase + ? "add_chat_item" + : "addLiveChatTickerItemAction" in actionBase + ? "add_live_chat_ticker_item" + : "unknown"; + + // Skip handling tickers for now + if (action_type !== "add_chat_item") return null; + + const actionItem = actionBase.addChatItemAction.item; + const message_type = + "liveChatMembershipItemRenderer" in actionItem + ? "membership_item" + : "liveChatTextMessageRenderer" in actionItem + ? "text_message" + : "liveChatPaidMessageRenderer" in actionItem + ? "paid_message" + : "unknown"; + + if (message_type === "unknown") return null; + + const messageItem = + actionItem.liveChatMembershipItemRenderer || + actionItem.liveChatTextMessageRenderer || + actionItem.liveChatPaidMessageRenderer; + + if (!messageItem) return null; + + const author: ChatMessageAuthor & { + name_text_colour?: string; + } = { + name: messageItem.authorName?.simpleText || "", + id: messageItem.authorExternalChannelId, + images: messageItem.authorPhoto?.thumbnails.map( + (thumb: Partial) => ({ + id: String(thumb.height), + ...thumb, + }) + ), + badges: messageItem.authorBadges?.map( + ({ liveChatAuthorBadgeRenderer: badge }: any) => ({ + title: badge.tooltip, + icons: badge.customThumbnail?.thumbnails, + }) + ), + name_text_colour: + "#" + messageItem.authorNameTextColor?.toString(16).substr(2), + }; + + const timeMsec = Number( + event.replayChatItemAction.videoOffsetTimeMsec || + event.videoOffsetTimeMsec + ); + + return { + time_in_seconds: timeMsec / 1000, + action_type, + message_type, + author, + message_id: messageItem.id, + timestamp: Number(messageItem.timestampUsec), + time_text: + messageItem.timestampText?.simpleText || + this._formatMsec(timeMsec), + message: + messageItem.message || messageItem.headerSubtext + ? (messageItem.message || messageItem.headerSubtext).runs + .map((run: any) => run?.emoji?.shortcuts?.[0] ?? run.text) + .join("") + : "", + emotes: messageItem.message?.runs + .map((run: any) => run.emoji) + .filter((e: any) => Boolean(e?.shortcuts)) + .map((e: any) => ({ + id: e.emojiId, + name: e.shortcuts[0], + shortcuts: e.shortcuts, + search_terms: e.searchTerms, + is_custom_emoji: e.isCustomEmoji, + images: e.image?.thumbnails.map( + (thumb: Partial) => ({ + id: String(thumb.height), + ...thumb, + }) + ), + })), + ...(message_type === "paid_message" + ? { + money: { + text: messageItem.purchaseAmountText.simpleText, + amount: 0, + currency: "-", + currency_symbol: "-", + }, + timestamp_colour: + "#" + messageItem.timestampColor?.toString(16).substr(2), + body_background_colour: + "#" + + messageItem.bodyBackgroundColor?.toString(16).substr(2), + header_text_colour: + "#" + messageItem.headerTextColor?.toString(16).substr(2), + header_background_colour: + "#" + + messageItem.headerBackgroundColor?.toString(16).substr(2), + body_text_colour: + "#" + messageItem.bodyTextColor?.toString(16).substr(2), + } + : {}), + }; + } catch (ex) { + return null; + } + }) + .filter(Boolean) + .sort((a, b) => a.time_in_seconds - b.time_in_seconds) as ChatMessage[]; + } +} -- cgit v1.2.3