diff options
| author | 0t4u <61939142+0t4u@users.noreply.github.com> | 2022-12-05 20:33:28 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-12-05 20:33:28 +0000 |
| commit | c6aa70a64731b608fade3acab48c1d1d2df280b6 (patch) | |
| tree | a92d130d6b927e520ab40692bc504db7606f2092 /modules/ChatReplay | |
Add files via upload
Diffstat (limited to 'modules/ChatReplay')
| -rw-r--r-- | modules/ChatReplay/ChatMessageRender.tsx | 171 | ||||
| -rw-r--r-- | modules/ChatReplay/ChatReplay.tsx | 74 | ||||
| -rw-r--r-- | modules/ChatReplay/ChatReplayPanel.tsx | 170 | ||||
| -rw-r--r-- | modules/ChatReplay/parser/default.ts | 21 | ||||
| -rw-r--r-- | modules/ChatReplay/parser/index.ts | 32 | ||||
| -rw-r--r-- | modules/ChatReplay/parser/yt-dlp.ts | 169 |
6 files changed, 637 insertions, 0 deletions
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 ( + <img + src={proxyURL(emoteURL)} + alt={token} + title={token} + className="inline-block w-6 h-6" + /> + ); + }); + }; + + switch (msg.message_type) { + case "paid_message": + return ( + <div + key={msg.message_id} + className="my-4 rounded overflow-hidden" + style={{ + color: msg.body_text_colour, + background: msg.body_background_colour, + }} + > + <div + style={{ + color: msg.header_text_colour, + background: msg.header_background_colour, + }} + className="flex flex-row justify-between px-4 py-2" + > + <div> + <div style={{ color: msg.author.name_text_colour }}> + {msg.author.name} + {msg.author.badges && msg.author.badges[0].icons ? ( + <img + src={proxyURL(msg.author.badges[0].icons[1].url)} + alt={msg.author.badges[0].title} + title={msg.author.badges[0].title} + className="inline-block ml-2 w-4 h-4" + /> + ) : null} + </div> + <div className="font-bold">{msg.money.text}</div> + </div> + <div + className="text-sm" + style={{ color: msg.author.name_text_colour }} + > + [{formatSeconds(msg.time_in_seconds)}] + </div> + </div> + <div className="px-4 py-2">{generateMessageContent(msg)}</div> + </div> + ); + case "membership_item": + return ( + <div + key={msg.message_id} + className="px-4 py-2 my-4 rounded bg-green-600 text-white" + > + <div className="flex flex-row justify-between pb-2"> + <div className="font-bold"> + <div className="inline-block">{msg.author.name}</div> + {msg.author.badges && msg.author.badges[0].icons ? ( + <img + src={proxyURL(msg.author.badges[0].icons[1].url)} + alt={msg.author.badges[0].title} + title={msg.author.badges[0].title} + className="inline-block ml-2 w-4 h-4" + /> + ) : null} + </div> + <div className="text-sm"> + [{formatSeconds(msg.time_in_seconds)}] + </div> + </div> + {generateMessageContent(msg)} + </div> + ); + case "text_message": + const authorType = + msg.author.badges?.map(({ title }) => + title === "Owner" + ? "owner" + : title === "Moderator" + ? "moderator" + : title.toLowerCase().includes("member") + ? "member" + : "" + ) || []; + return ( + <div key={msg.message_id} className="px-2 mb-2"> + <div className="text-gray-400 text-xs flex justify-between"> + <span + className={[ + "mr-2", + authorType.includes("owner") + ? "bg-blue-600 text-white font-bold px-2 rounded" + : authorType.includes("moderator") + ? "text-blue-600 font-bold" + : authorType.includes("member") + ? "text-green-500" + : "", + ].join(" ")} + > + {msg.author.name} + {authorType.includes("moderator") && ( + <svg + viewBox="0 0 16 16" + className="text-blue-600 w-4 h-4 inline-block ml-2" + > + <path + fill="currentColor" + d="M9.64589146,7.05569719 C9.83346524,6.562372 9.93617022,6.02722257 9.93617022,5.46808511 C9.93617022,3.00042984 7.93574038,1 5.46808511,1 C4.90894765,1 4.37379823,1.10270499 3.88047304,1.29027875 L6.95744681,4.36725249 L4.36725255,6.95744681 L1.29027875,3.88047305 C1.10270498,4.37379824 1,4.90894766 1,5.46808511 C1,7.93574038 3.00042984,9.93617022 5.46808511,9.93617022 C6.02722256,9.93617022 6.56237198,9.83346524 7.05569716,9.64589147 L12.4098057,15 L15,12.4098057 L9.64589146,7.05569719 Z" + /> + </svg> + )} + {msg.author.badges?.map((badge) => + Array.isArray(badge.icons) ? ( + <img + key={badge.title} + alt={badge.title} + title={badge.title} + src={proxyURL(badge.icons?.[1]?.url)} + className="inline-block ml-2 w-4 h-4" + /> + ) : null + )} + </span> + <span>[{formatSeconds(msg.time_in_seconds)}]</span> + </div> + {generateMessageContent(msg)} + </div> + ); + } + + 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<T>( + 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<ChatMessage[]>([]); + + 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 ( + <div className="w-full break-words"> + {messages.map((msg) => ( + <ChatMessageRender key={msg.message_id} message={msg} /> + ))} + </div> + ); +}; + +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<ChatMessage[]>(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<HTMLDivElement>(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 ( + <div + className="border border-gray-800 rounded p-4 text-center cursor-pointer" + onClick={() => { + if (isErrored || downloadProgress < 0) downloadChatData(); + }} + > + {isErrored ? ( + <p>Error loading chat, click to retry</p> + ) : downloadProgress < 0 ? ( + <> + <p>Chat replay available!</p> + <p>Click to Enable</p> + </> + ) : downloadProgress === 0 ? ( + <p>Loading chat replay...</p> + ) : ( + <p>Loaded {(downloadProgress / 1024 / 1024).toFixed(2)}MB</p> + )} + </div> + ); + + return ( + <div className="h-full flex flex-col"> + <div className="flex flex-col text-white bg-gray-900"> + <div className="flex"> + <div className="flex-1 flex items-center px-4">Chat replay</div> + <div> + <button + type="button" + onClick={() => setIsFilterVisible((now) => !now)} + className="px-4 py-2" + > + <IconFilter width="1em" height="1em" /> + </button> + <button + type="button" + onClick={() => setIsChatVisible((now) => !now)} + className="text-lg px-4 py-2" + > + <IconChevronDown + width="1em" + height="1em" + style={{ transform: isChatVisible ? "rotate(180deg)" : "" }} + /> + </button> + </div> + </div> + {isFilterVisible && ( + <div className="flex flex-col"> + <div className="flex"> + <input + type="text" + placeholder="Filter text" + className=" + w-full rounded px-4 py-1 md:mx-2 + bg-gray-800 hover:bg-gray-700 focus:outline-none focus:ring + transition duration-100 z-20 + " + value={chatFilter} + onChange={(e) => setChatFilter(e.target.value)} + /> + </div> + </div> + )} + </div> + {isChatVisible && ( + <div className="relative flex-1"> + <div + className={[ + "px-2 border border-gray-800 rounded", + "overflow-y-scroll absolute inset-0", + "transition-all duration-200", + ].join(" ")} + style={{ + overscrollBehavior: "contain", + }} + ref={refChatScrollDiv} + > + <ChatReplay + currentTimeSeconds={props.currentTimeSeconds} + replayData={filteredReplayData} + /> + </div> + </div> + )} + </div> + ); +}; + +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<ChatMessageImage>) => ({ + 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<ChatMessageImage>) => ({ + 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[]; + } +} |
