diff --git a/src/components/Hyperchat.svelte b/src/components/Hyperchat.svelte index 358ad85e..f6eea5c7 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -2,6 +2,7 @@ import '../stylesheets/scrollbar.css'; import { onDestroy, onMount, afterUpdate, tick } from 'svelte'; import { fade } from 'svelte/transition'; + import { get } from 'svelte/store'; import dark from 'smelte/src/dark'; import WelcomeMessage from './WelcomeMessage.svelte'; import Message from './Message.svelte'; @@ -26,6 +27,7 @@ responseIsAction, useReconnect } from '../ts/chat-utils'; + import { handleReplyThreadResponse } from '../ts/chat-actions'; import Button from 'smelte/src/components/Button'; import { theme, @@ -43,6 +45,9 @@ selfChannel, alertDialog, stickySuperchats, + activeReplyThreadId, + liveReplyBuffer, + liveLikeCounts, currentProgress, enableStickySuperchatBar, lastOpenedVersion, @@ -172,6 +177,31 @@ piledMessages = []; } + const stickyLikeKeys = (sticky: Ytc.ParsedTicker[]): Set => { + const keys = new Set(); + sticky.forEach(sc => { + if (sc.likeCountEntityKey) keys.add(sc.likeCountEntityKey); + }); + return keys; + }; + + const pruneLikeCounts = (sticky: Ytc.ParsedTicker[]) => { + const current = get(liveLikeCounts); + if (current.size === 0) return; + const keep = stickyLikeKeys(sticky); + let mutated = false; + const next = new Map(current); + next.forEach((_, k) => { + if (!keep.has(k)) { + next.delete(k); + mutated = true; + } + }); + if (mutated) liveLikeCounts.set(next); + }; + + $: pruneLikeCounts($stickySuperchats); + const onBonk = (bonk: Ytc.ParsedBonk) => { messageActions.forEach((action) => { @@ -182,15 +212,24 @@ }); }; + const LIVE_REPLY_BUFFER_LIMIT = 200; + const filterTickers = (items: Chat.MessageAction[]): Chat.MessageAction[] => { const keep: Chat.MessageAction[] = []; const discard: Ytc.ParsedTicker[] = []; + const newLiveReplies: Ytc.ParsedMessage[] = []; + const trackedThreadId = $activeReplyThreadId; items.forEach(item => { if ('tickerDuration' in item.message) { if (!$stickySuperchats.some(sc => sc.messageId === item.message.messageId)) { discard.push(item.message); } - } else keep.push(item); + } else { + keep.push(item); + if (trackedThreadId && item.message.replyToSuperchat?.threadId === trackedThreadId) { + newLiveReplies.push(item.message); + } + } }); if ($enableStickySuperchatBar && discard.length) { $stickySuperchats = [ @@ -198,6 +237,12 @@ ...$stickySuperchats ]; } + if (newLiveReplies.length > 0) { + const combined = [...$liveReplyBuffer, ...newLiveReplies]; + $liveReplyBuffer = combined.length > LIVE_REPLY_BUFFER_LIMIT + ? combined.slice(combined.length - LIVE_REPLY_BUFFER_LIMIT) + : combined; + } return keep; }; @@ -245,6 +290,19 @@ messageActions = [...messageActions, welcome]; } break; + case 'likeCounts': { + const knownKeys = stickyLikeKeys($stickySuperchats); + if (knownKeys.size === 0) break; + let next: Map | null = null; + for (const [key, count] of Object.entries(action.counts)) { + if (knownKeys.has(key) && $liveLikeCounts.get(key) !== count) { + if (!next) next = new Map($liveLikeCounts); + next.set(key, count); + } + } + if (next) $liveLikeCounts = next; + break; + } } }; @@ -305,6 +363,9 @@ break; case 'ping': break; + case 'replyThreadResponse': + handleReplyThreadResponse(response); + break; default: console.error('Unknown payload type', { port, response }); break; diff --git a/src/components/MembershipItem.svelte b/src/components/MembershipItem.svelte index d5b472f7..de7f41cb 100644 --- a/src/components/MembershipItem.svelte +++ b/src/components/MembershipItem.svelte @@ -2,12 +2,12 @@ import Message from './Message.svelte'; import MessageRun from './MessageRuns.svelte'; import { formatAuthorName } from '../ts/component-utils'; - import { showProfileIcons } from '../ts/storage'; + import { showProfileIcons, showTimestamps } from '../ts/storage'; import { membershipBackground, milestoneChatBackground } from '../ts/chat-constants'; export let message: Ytc.ParsedMessage; - const classes = 'inline-flex flex-col rounded break-words overflow-hidden w-full text-white'; + const classes = 'relative inline-flex flex-col rounded break-words overflow-hidden w-full text-white'; $: membership = message.membership; $: membershipGift = message.membershipGiftPurchase; @@ -22,8 +22,16 @@ {#if membership ?? membershipGift}
+ {#if membershipGift} + + {/if}
{#if $showProfileIcons} @@ -33,28 +41,21 @@ alt={message.author.profileIcon.alt} /> {/if} - + {#if $showTimestamps} + {message.timestamp} + {/if} + {displayAuthorName} - {#if primaryText && primaryText.length > 0} - - {/if} {#if membership} - + {/if} - {#if membershipGift} - {membershipGift.image.alt} + {#if primaryText && primaryText.length > 0} + {/if}
{#if isMilestoneChat} -
+
{/if} diff --git a/src/components/Message.svelte b/src/components/Message.svelte index b576d45d..8204b9d8 100644 --- a/src/components/Message.svelte +++ b/src/components/Message.svelte @@ -9,18 +9,22 @@ showUserBadges, hoveredItem, port, - selfChannelId + selfChannelId, + showSuperchatReplyIndicators, + stickySuperchats, + focusedSuperchat } from '../ts/storage'; import { chatUserActionsItems, ChatUserActions, Theme } from '../ts/chat-constants'; import { useBanHammer } from '../ts/chat-actions'; import { formatAuthorName } from '../ts/component-utils'; - import { mdiGift } from '@mdi/js'; + import { mdiGift, mdiReply } from '@mdi/js'; export let message: Ytc.ParsedMessage; export let deleted: Chat.MessageDeletedObj | null = null; export let forceDark = false; export let hideName = false; export let hideDropdown = false; + export let hideReplyIndicator = false; const nameClass = 'font-bold tracking-wide align-middle'; const generateNameColorClass = (member: boolean, moderator: boolean, owner: boolean, forceDark: boolean) => { @@ -78,6 +82,13 @@ value: d.value.toString(), onClick: () => useBanHammer(message, d.value, $port) })); + + const openReplyTargetSuperchat = () => { + const threadId = message.replyToSuperchat?.threadId; + const match = threadId ? $stickySuperchats.find((s) => s.threadId === threadId) : undefined; + if (!threadId || !match) return; + $focusedSuperchat = match; + }; @@ -141,6 +152,30 @@ {/if} + {#if message.replyToSuperchat && $showSuperchatReplyIndicators && !hideReplyIndicator} + + + + + + + {/if} 0 || !!message.superSticker; $: displayAuthorName = formatAuthorName(message.author.name); $: if (!paid) { @@ -36,7 +37,7 @@ {#if paid}
-
+
{#if $showProfileIcons} {message.author.profileIcon.alt} {/if} - {amount} - + {#if $showTimestamps} + {message.timestamp} + {/if} + {displayAuthorName} - {#if message.superSticker} + {amount} +
+ {#if message.superSticker} +
{message.superSticker.alt} - {/if} -
+
+ {/if} {#if message.message.length > 0}
diff --git a/src/components/SuperchatViewDialog.svelte b/src/components/SuperchatViewDialog.svelte index 639be662..8053826a 100644 --- a/src/components/SuperchatViewDialog.svelte +++ b/src/components/SuperchatViewDialog.svelte @@ -1,22 +1,150 @@ - - {#if ('superChat' in sc || 'superSticker' in sc)} - - {:else} - + + {#if sc} +
+ {#if sc.superChat ?? sc.superSticker} + + {:else} + + {/if} + + {#if replyThreadParams && canExpand} + +
+ + {#if repliesExpanded} + expand_less + {:else} + expand_more + {/if} + + + {#if replyError} + Replies unavailable + {:else} + {replies.length === 1 ? '1 reply' : `${replies.length} replies`} + {#if likeCount != null} + • {likeCount === 1 ? '1 like' : `${likeCount} likes`} + {/if} + {/if} + +
+ {#if repliesExpanded} +
+ {#if loadingReplies} +
Loading…
+ {:else if replyError} +
{replyError}
+ {:else if replies.length === 0} +
No replies yet.
+ {:else} +
+ {#each replies as reply (reply.messageId)} + + {/each} +
+ {/if} +
+ {/if} + {/if} +
{/if}
@@ -26,7 +154,16 @@ } :global(.no-padding) { padding: 0px !important; - margin: 1rem !important; + margin: 3rem 10px auto 10px !important; + align-self: flex-start !important; background-color: transparent !important; } + .sc-stack { + max-height: calc(99vh - 3rem); + } + /* Strip the PaidMessage's own rounding so it merges into the outer rounded box. */ + .sc-stack :global(> :first-child) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } diff --git a/src/components/common/Dialog.svelte b/src/components/common/Dialog.svelte index 0c57f599..4e848973 100644 --- a/src/components/common/Dialog.svelte +++ b/src/components/common/Dialog.svelte @@ -1,5 +1,6 @@ - +
diff --git a/src/components/settings/InterfaceSettings.svelte b/src/components/settings/InterfaceSettings.svelte index 4f5f8f9d..a69cf68d 100644 --- a/src/components/settings/InterfaceSettings.svelte +++ b/src/components/settings/InterfaceSettings.svelte @@ -12,7 +12,8 @@ isDark, enableStickySuperchatBar, enableHighlightedMentions, - showChatSummary + showChatSummary, + showSuperchatReplyIndicators } from '../../ts/storage'; import { themeItems, emojiRenderItems } from '../../ts/chat-constants'; import Card from '../common/Card.svelte'; @@ -62,6 +63,7 @@ + diff --git a/src/ts/chat-actions.ts b/src/ts/chat-actions.ts index c11b3ae8..b476971b 100644 --- a/src/ts/chat-actions.ts +++ b/src/ts/chat-actions.ts @@ -29,3 +29,46 @@ export function useBanHammer( }); } } + +interface ReplyThreadResolver { + resolve: (replies: Ytc.ParsedMessage[]) => void; + reject: (err: Error) => void; + timeoutId: ReturnType; +} + +const REPLY_THREAD_TIMEOUT_MS = 10000; +const pendingReplyThreadRequests = new Map(); + +export function fetchReplyThread( + params: string, + port: Chat.Port | null +): Promise { + if (!port) return Promise.reject(new Error('No port')); + const requestId = `rt_${Date.now()}_${Math.random().toString(36).slice(2)}`; + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + const pending = pendingReplyThreadRequests.get(requestId); + if (!pending) return; + pendingReplyThreadRequests.delete(requestId); + pending.reject(new Error('Reply thread fetch timed out')); + }, REPLY_THREAD_TIMEOUT_MS); + pendingReplyThreadRequests.set(requestId, { resolve, reject, timeoutId }); + port.postMessage({ + type: 'fetchReplyThread', + requestId, + params + }); + }); +} + +export function handleReplyThreadResponse(response: Chat.replyThreadResponse): void { + const pending = pendingReplyThreadRequests.get(response.requestId); + if (!pending) return; + pendingReplyThreadRequests.delete(response.requestId); + clearTimeout(pending.timeoutId); + if (response.success) { + pending.resolve(response.replies); + } else { + pending.reject(new Error(response.error ?? 'Failed to fetch reply thread')); + } +} diff --git a/src/ts/chat-constants.ts b/src/ts/chat-constants.ts index 1fac499e..aa3761d8 100644 --- a/src/ts/chat-constants.ts +++ b/src/ts/chat-constants.ts @@ -85,3 +85,5 @@ export const chatUserActionsItems = [ export const membershipBackground = '0f9d58'; export const milestoneChatBackground = '107516'; +export const replyThreadPanelTag = 'PAreply_thread'; +export const currentDomain = location.protocol.includes('youtube') ? (location.protocol + '//' + location.host) : 'https://www.youtube.com'; diff --git a/src/ts/chat-parser.ts b/src/ts/chat-parser.ts index 920cfa6d..6734eaa5 100644 --- a/src/ts/chat-parser.ts +++ b/src/ts/chat-parser.ts @@ -4,8 +4,7 @@ import { isMembershipRenderer, isMembershipGiftPurchaseRenderer } from './chat-utils'; - -const currentDomain = location.protocol.includes('youtube') ? (location.protocol + '//' + location.host) : 'https://www.youtube.com'; +import { currentDomain, replyThreadPanelTag } from './chat-constants'; // Source: https://stackoverflow.com/a/64396666 const standardEmoji = @@ -18,6 +17,68 @@ const formatTimestamp = (timestampUsec: number): string => { const colorToHex = (color: number): string => color.toString(16).slice(-6); +const SC_THREAD_ID_LENGTH = 35; + +// YT mixes URL-safe (`-`,`_`) and standard (`+`,`/`) base64 across fields; normalize before atob. +const decodeBase64 = (input: string): string | undefined => { + try { + return atob(decodeURIComponent(input).replace(/-/g, '+').replace(/_/g, '/')); + } catch { + return; + } +}; + +// SC entity keys are proto bytes shaped like 0x12 0x23 <35-byte ASCII id> ... . +const extractThreadIdFromEntityKey = (key: string | undefined): string | undefined => { + if (!key) return; + const decoded = decodeBase64(key); + if (!decoded) return; + if (decoded.charCodeAt(0) !== 0x12 || decoded.charCodeAt(1) !== SC_THREAD_ID_LENGTH) return; + return decoded.slice(2, 2 + SC_THREAD_ID_LENGTH); +}; + +// Reply-thread params are proto bytes that contain the SC's thread id as a length-prefixed string +// field, marked by the byte pair 0x0A 0x23 (proto field 1, length 35). +const extractThreadIdFromParams = (params: string | undefined): string | undefined => { + if (!params) return; + const decoded = decodeBase64(params); + if (!decoded) return; + for (let i = 0; i <= decoded.length - (2 + SC_THREAD_ID_LENGTH); i++) { + if (decoded.charCodeAt(i) === 0x0A && decoded.charCodeAt(i + 1) === SC_THREAD_ID_LENGTH) { + return decoded.slice(i + 2, i + 2 + SC_THREAD_ID_LENGTH); + } + } +}; + +interface ReplyButtonInfo { + params: string; + authorName?: string; + bgColor?: string; + fgColor?: string; +} + +const extractReplyButton = ( + button: Ytc.ReplyButtonViewModel | undefined +): ReplyButtonInfo | undefined => { + if (!button) return; + const endpoint = button.onTap?.innertubeCommand?.showEngagementPanelEndpoint; + if (endpoint?.identifier?.tag !== replyThreadPanelTag) return; + const params = endpoint.globalConfiguration?.params; + if (!params) return; + return { + params, + authorName: button.title, + bgColor: button.customBackgroundColor != null ? colorToHex(button.customBackgroundColor) : undefined, + fgColor: button.customFontColor != null ? colorToHex(button.customFontColor) : undefined + }; +}; + +const parseReplyThreadButton = ( + renderer: Ytc.TextMessageRenderer +): ReplyButtonInfo | undefined => + extractReplyButton(renderer.beforeContentButtons?.[0]?.buttonViewModel) ?? + extractReplyButton(renderer.replyButton?.pdgReplyButtonViewModel?.replyButton?.buttonViewModel); + const fixUrl = (url: string): string => { if (url.startsWith('//')) { return 'https:' + url; @@ -204,6 +265,29 @@ const parseAddChatItemAction = (action: Ytc.AddChatItemAction, isReplay = false, item.author.url = `${currentDomain}/channel/${channelId}`; } + const replyButton = parseReplyThreadButton(messageRenderer); + if (replyButton) { + if (isPaidMessageRenderer(renderer) || isPaidStickerRenderer(renderer)) { + item.replyThreadParams = replyButton.params; + } else if (replyButton.authorName) { + item.replyToSuperchat = { + authorName: replyButton.authorName, + params: replyButton.params, + threadId: extractThreadIdFromParams(replyButton.params), + bgColor: replyButton.bgColor, + fgColor: replyButton.fgColor + }; + } + } + + if (isPaidMessageRenderer(renderer) || isPaidStickerRenderer(renderer)) { + const replyEntityKey = messageRenderer.replyButton?.pdgReplyButtonViewModel?.replyCountEntityKey; + const likeEntityKey = messageRenderer.pdgLikeButton?.pdgLikeViewModel?.likeCountEntityKey; + if (likeEntityKey) item.likeCountEntityKey = likeEntityKey; + const entityKey = replyEntityKey ?? likeEntityKey; + if (entityKey) item.threadId = extractThreadIdFromEntityKey(entityKey); + } + if (isPaidMessageRenderer(renderer)) { item.superChat = { amount: renderer.purchaseAmountText.simpleText, @@ -339,6 +423,13 @@ const parseTickerAction = (action: Ytc.AddTickerAction, isReplay: boolean, liveT item: baseRenderer.showItemEndpoint.showLiveChatItemEndpoint.renderer }, isReplay, liveTimeoutOrReplayMs); if (!parsedMessage) return; + // Some tickers carry the reply-thread params at the ticker level instead of on the inner SC renderer. + if (!parsedMessage.replyThreadParams && 'openEngagementPanelCommand' in baseRenderer) { + const endpoint = baseRenderer.openEngagementPanelCommand?.showEngagementPanelEndpoint; + if (endpoint?.identifier?.tag === replyThreadPanelTag && endpoint.globalConfiguration?.params) { + parsedMessage.replyThreadParams = endpoint.globalConfiguration.params; + } + } return { type: 'ticker', ...parsedMessage, @@ -451,12 +542,21 @@ export const parseChatResponse = (response: string, isReplay: boolean): Ytc.Pars const refresh = base.clientMessages != null; if (!isReplay && !refresh) cheatTimestamps(messageArray); + const likeCounts: Record = {}; + parsedResponse.frameworkUpdates?.entityBatchUpdate?.mutations?.forEach((mutation) => { + const entity = mutation.payload?.likeCountEntity; + if (!entity?.key || entity.likeCountIfIndifferentNumber == null) return; + const n = parseInt(entity.likeCountIfIndifferentNumber); + if (!Number.isNaN(n)) likeCounts[entity.key] = n; + }); + return { messages: messageArray, bonks: bonkArray, deletions: deleteArray, miscActions: miscArray, isReplay, - refresh + refresh, + ...(Object.keys(likeCounts).length > 0 ? { likeCounts } : {}) }; }; diff --git a/src/ts/chat-utils.ts b/src/ts/chat-utils.ts index 292e6e6c..3f67d906 100644 --- a/src/ts/chat-utils.ts +++ b/src/ts/chat-utils.ts @@ -52,7 +52,7 @@ export const isValidFrameInfo = (f: Chat.UncheckedFrameInfo, port?: Chat.Port): return check; }; -const actionTypes = new Set(['messages', 'bonk', 'delete', 'pin', 'unpin', 'summary', 'poll', 'redirect', 'playerProgress', 'forceUpdate']); +const actionTypes = new Set(['messages', 'bonk', 'delete', 'pin', 'unpin', 'summary', 'poll', 'redirect', 'playerProgress', 'forceUpdate', 'likeCounts']); export const responseIsAction = (r: Chat.BackgroundResponse): r is Chat.Actions => actionTypes.has(r.type); diff --git a/src/ts/messaging.ts b/src/ts/messaging.ts index 67fcec22..224a07a0 100644 --- a/src/ts/messaging.ts +++ b/src/ts/messaging.ts @@ -1,11 +1,10 @@ import type { Unsubscriber } from './queue'; import { ytcQueue } from './queue'; -import { chatReportUserOptions, ChatUserActions, ChatReportUserOptions } from '../ts/chat-constants'; +import { chatReportUserOptions, ChatUserActions, ChatReportUserOptions, replyThreadPanelTag, currentDomain } from '../ts/chat-constants'; +import { parseChatResponse } from './chat-parser'; import type { Chat } from './typings/chat'; import sha1 from 'sha-1'; -const currentDomain = location.protocol.includes('youtube') ? (location.protocol + '//' + location.host) : 'https://www.youtube.com'; - let interceptor: Chat.Interceptor = { clients: [] }; const isYtcInterceptor = (i: Chat.Interceptors, showError = false, ...debug: any[]): i is Chat.YtcInterceptor => { @@ -21,6 +20,68 @@ interface YtCfg { }; } +const getCookie = (name: string): string => { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return (parts.pop() ?? '').split(';').shift() ?? ''; + return ''; +}; + +const proxyFetch = async (...args: any[]): Promise => { + return await new Promise((resolve, reject) => { + const id = `${Date.now()}_${Math.random().toString(36).slice(2)}`; + const encoded = JSON.stringify({ id, args }); + let timeout = 0; + const onFetchResponse = (e: Event): void => { + const response = JSON.parse((e as CustomEvent).detail) as { + id: string; + response?: any; + error?: string; + }; + if (response.id !== id) return; + window.clearTimeout(timeout); + window.removeEventListener('proxyFetchResponse', onFetchResponse); + if (response.error != null) { + reject(new Error(response.error)); + return; + } + resolve(response.response); + }; + timeout = window.setTimeout(() => { + window.removeEventListener('proxyFetchResponse', onFetchResponse); + reject(new Error('proxy fetch timed out')); + }, 5000); + window.addEventListener('proxyFetchResponse', onFetchResponse); + window.dispatchEvent(new CustomEvent('proxyFetchRequest', { + detail: encoded + })); + }); +}; + +const buildInnertubeHeaders = (ytcfg: YtCfg) => { + const time = Math.floor(Date.now() / 1000); + const sapisid = getCookie('__Secure-3PAPISID') || getCookie('SAPISID'); + const auth = sapisid ? `SAPISIDHASH ${time}_${sha1(`${time} ${sapisid} ${currentDomain}`)}` : null; + const authuser = (ytcfg as any)?.data_?.SESSION_INDEX; + const visitorId = (ytcfg as any)?.data_?.VISITOR_DATA ?? ytcfg.data_.INNERTUBE_CONTEXT?.client?.visitorData; + const clientName = (ytcfg as any)?.data_?.INNERTUBE_CLIENT_NAME; + const clientVersion = (ytcfg as any)?.data_?.INNERTUBE_CLIENT_VERSION; + return { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + ...(authuser != null ? { 'X-Goog-AuthUser': String(authuser) } : {}), + ...(visitorId != null ? { 'X-Goog-Visitor-Id': String(visitorId) } : {}), + ...(clientName != null ? { 'X-Youtube-Client-Name': String(clientName) } : {}), + ...(clientVersion != null ? { 'X-Youtube-Client-Version': String(clientVersion) } : {}), + 'X-Origin': currentDomain, + ...(auth != null ? { Authorization: auth } : {}) + }, + method: 'POST' as const, + mode: 'same-origin' as const + }; +}; + /** Register a client to the interceptor. */ const registerClient = ( port: Chat.Port, @@ -186,37 +247,6 @@ const executeChatAction = async ( action: ChatUserActions, reportOption?: ChatReportUserOptions ): Promise => { - const fetcher = async (...args: any[]): Promise => { - return await new Promise((resolve, reject) => { - const id = `${Date.now()}_${Math.random().toString(36).slice(2)}`; - const encoded = JSON.stringify({ id, args }); - let timeout = 0; - const onFetchResponse = (e: Event): void => { - const response = JSON.parse((e as CustomEvent).detail) as { - id: string; - response?: any; - error?: string; - }; - if (response.id !== id) return; - window.clearTimeout(timeout); - window.removeEventListener('proxyFetchResponse', onFetchResponse); - if (response.error != null) { - reject(new Error(response.error)); - return; - } - resolve(response.response); - }; - timeout = window.setTimeout(() => { - window.removeEventListener('proxyFetchResponse', onFetchResponse); - reject(new Error('proxy fetch timed out')); - }, 5000); - window.addEventListener('proxyFetchResponse', onFetchResponse); - window.dispatchEvent(new CustomEvent('proxyFetchRequest', { - detail: encoded - })); - }); - }; - let success = true; if (message.params == null) { success = false; @@ -229,35 +259,9 @@ const executeChatAction = async ( const contextMenuUrl = `${currentDomain}/youtubei/v1/live_chat/get_item_context_menu?params=` + `${encodeURIComponent(message.params)}&pbj=1&key=${apiKey}&prettyPrint=false`; const baseContext = ytcfg.data_.INNERTUBE_CONTEXT; - function getCookie(name: string): string { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return (parts.pop() ?? '').split(';').shift() ?? ''; - return ''; - } - const time = Math.floor(Date.now() / 1000); - const sapisid = getCookie('__Secure-3PAPISID') || getCookie('SAPISID'); - const auth = sapisid ? `SAPISIDHASH ${time}_${sha1(`${time} ${sapisid} ${currentDomain}`)}` : null; - const authuser = (ytcfg as any)?.data_?.SESSION_INDEX; - const visitorId = (ytcfg as any)?.data_?.VISITOR_DATA ?? baseContext?.client?.visitorData; - const clientName = (ytcfg as any)?.data_?.INNERTUBE_CLIENT_NAME; - const clientVersion = (ytcfg as any)?.data_?.INNERTUBE_CLIENT_VERSION; - const heads = { - headers: { - 'Content-Type': 'application/json', - Accept: '*/*', - ...(authuser != null ? { 'X-Goog-AuthUser': String(authuser) } : {}), - ...(visitorId != null ? { 'X-Goog-Visitor-Id': String(visitorId) } : {}), - ...(clientName != null ? { 'X-Youtube-Client-Name': String(clientName) } : {}), - ...(clientVersion != null ? { 'X-Youtube-Client-Version': String(clientVersion) } : {}), - 'X-Origin': currentDomain, - ...(auth != null ? { Authorization: auth } : {}) - }, - method: 'POST' as const, - mode: 'same-origin' as const - }; + const heads = buildInnertubeHeaders(ytcfg); const contextMenuContext = JSON.parse(JSON.stringify(baseContext)); - const res = await fetcher(contextMenuUrl, { + const res = await proxyFetch(contextMenuUrl, { ...heads, body: JSON.stringify({ context: contextMenuContext }) }); @@ -339,7 +343,7 @@ const executeChatAction = async ( throw new Error('Could not find moderate endpoint in context menu'); } const { params, context } = parseServiceEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint'); - const moderationResponse = await fetcher(`${currentDomain}/youtubei/v1/live_chat/moderate?key=${apiKey}&prettyPrint=false`, { + const moderationResponse = await proxyFetch(`${currentDomain}/youtubei/v1/live_chat/moderate?key=${apiKey}&prettyPrint=false`, { ...heads, body: JSON.stringify({ params, @@ -355,7 +359,7 @@ const executeChatAction = async ( throw new Error('Could not find delete endpoint in context menu'); } const { params, context } = parseServiceEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint'); - const moderationResponse = await fetcher(`${currentDomain}/youtubei/v1/live_chat/moderate?key=${apiKey}&prettyPrint=false`, { + const moderationResponse = await proxyFetch(`${currentDomain}/youtubei/v1/live_chat/moderate?key=${apiKey}&prettyPrint=false`, { ...heads, body: JSON.stringify({ params, @@ -371,7 +375,7 @@ const executeChatAction = async ( throw new Error('Could not find report endpoint in context menu'); } const { params, context } = parseServiceEndpoint(serviceEndpoint, 'getReportFormEndpoint'); - const modal = await fetcher(`${currentDomain}/youtubei/v1/flag/get_form?key=${apiKey}&prettyPrint=false`, { + const modal = await proxyFetch(`${currentDomain}/youtubei/v1/flag/get_form?key=${apiKey}&prettyPrint=false`, { ...heads, body: JSON.stringify({ params, @@ -397,7 +401,7 @@ const executeChatAction = async ( clickTrackingParams }; } - const flagResponse = await fetcher(`${currentDomain}/youtubei/v1/flag/flag?key=${apiKey}&prettyPrint=false`, { + const flagResponse = await proxyFetch(`${currentDomain}/youtubei/v1/flag/flag?key=${apiKey}&prettyPrint=false`, { ...heads, body: JSON.stringify({ action: flagAction, @@ -423,6 +427,59 @@ const executeChatAction = async ( ); }; +const fetchReplyThread = async ( + requestId: string, + params: string, + ytcfg: YtCfg, + isReplay: boolean +): Promise => { + let success = true; + let replies: Ytc.ParsedMessage[] = []; + let error: string | undefined; + try { + const baseContext = ytcfg.data_.INNERTUBE_CONTEXT; + const heads = buildInnertubeHeaders(ytcfg); + const panelRes = await proxyFetch( + `${currentDomain}/youtubei/v1/get_panel?prettyPrint=false`, + { + ...heads, + body: JSON.stringify({ + context: baseContext, + panelId: replyThreadPanelTag, + params + }) + } + ); + const items: any[] = panelRes?.content?.engagementPanelSectionListRenderer + ?.content?.sectionListRenderer?.contents?.[0] + ?.liveChatItemDisplayListRenderer?.items ?? []; + // Reuse parseChatResponse so replies come out shaped identically to live chat messages. + const fakeChunk = JSON.stringify({ + continuationContents: { + liveChatContinuation: { + continuations: [{ timedContinuationData: { timeoutMs: 0 } }], + actions: items.map((item: any) => ({ addChatItemAction: { item } })) + } + } + }); + const chunk = parseChatResponse(fakeChunk, isReplay); + replies = (chunk?.messages ?? []) as Ytc.ParsedMessage[]; + } catch (e) { + success = false; + error = String(e); + } + + interceptor.clients.forEach( + (clientPort) => clientPort.postMessage({ + type: 'replyThreadResponse', + requestId, + success, + replies, + error + }) + ); +}; + export const initInterceptor = ( source: Chat.InterceptorSource, ytcfg: YtCfg, @@ -462,6 +519,9 @@ export const initInterceptor = ( case 'executeChatAction': executeChatAction(message.message, ytcfg, message.action, message.reportOption).catch(console.error); break; + case 'fetchReplyThread': + fetchReplyThread(message.requestId, message.params, ytcfg, isReplay ?? false).catch(console.error); + break; case 'ping': port.postMessage({ type: 'ping' }); break; diff --git a/src/ts/queue.ts b/src/ts/queue.ts index e309af59..358d3983 100644 --- a/src/ts/queue.ts +++ b/src/ts/queue.ts @@ -245,6 +245,9 @@ export function ytcQueue(isReplay = false): YtcQueue { bonks.forEach((bonk) => latestAction.set({ type: 'bonk', bonk })); deletions.forEach((deletion) => latestAction.set({ type: 'delete', deletion })); misc.forEach((action) => latestAction.set(action)); + if (chunk.likeCounts && Object.keys(chunk.likeCounts).length > 0) { + latestAction.set({ type: 'likeCounts', counts: chunk.likeCounts }); + } }; const addJsonToQueue = ( diff --git a/src/ts/storage.ts b/src/ts/storage.ts index 4ff26fab..ea4b6a92 100644 --- a/src/ts/storage.ts +++ b/src/ts/storage.ts @@ -75,6 +75,9 @@ export const alertDialog = writable(null as null | { color: string; }); export const stickySuperchats = writable([] as Ytc.ParsedTicker[]); +export const activeReplyThreadId = writable(null); +export const liveReplyBuffer = writable([]); +export const liveLikeCounts = writable(new Map()); export const isDark = derived(theme, ($theme) => { return $theme === Theme.DARK || ( $theme === Theme.YOUTUBE && window.location.search.includes('dark') @@ -84,5 +87,6 @@ export const ytDark = writable(false); export const currentProgress = writable(null as null | number); export const enableStickySuperchatBar = stores.addSyncStore('hc.enableStickySuperchatBar', true); export const enableHighlightedMentions = stores.addSyncStore('hc.enableHighlightedMentions', true); +export const showSuperchatReplyIndicators = stores.addSyncStore('hc.showSuperchatReplyIndicators', true); export const lastOpenedVersion = stores.addSyncStore('hc.lastOpenedVersion', ''); export const bytesUsed = stores.addSyncStore('hc.bytes.used', 0); diff --git a/src/ts/typings/chat.d.ts b/src/ts/typings/chat.d.ts index df62583e..7d5127fa 100644 --- a/src/ts/typings/chat.d.ts +++ b/src/ts/typings/chat.d.ts @@ -38,7 +38,12 @@ declare namespace Chat { showWelcome?: boolean; } - type Actions = MessagesAction | BonkAction | DeleteAction | Ytc.ParsedMisc | PlayerProgressAction | ForceUpdate; + interface LikeCountsAction { + type: 'likeCounts'; + counts: Record; + } + + type Actions = MessagesAction | BonkAction | DeleteAction | Ytc.ParsedMisc | PlayerProgressAction | ForceUpdate | LikeCountsAction; interface UncheckedFrameInfo { tabId: number | undefined; @@ -82,9 +87,24 @@ declare namespace Chat { success: boolean; } + interface fetchReplyThreadMsg { + type: 'fetchReplyThread'; + requestId: string; + params: string; + } + + interface replyThreadResponse { + type: 'replyThreadResponse'; + requestId: string; + success: boolean; + replies: Ytc.ParsedMessage[]; + error?: string; + } + type BackgroundResponse = Actions | InitialData | ThemeUpdate | LtlMessageResponse | - registerClientResponse | executeChatActionMsg | chatUserActionResponse | Ping; + registerClientResponse | executeChatActionMsg | chatUserActionResponse | Ping | + replyThreadResponse; type InterceptorSource = 'ytc' | 'ltlMessage'; @@ -148,7 +168,8 @@ declare namespace Chat { type BackgroundMessage = RegisterInterceptorMsg | RegisterClientMsg | processJsonMsg | setInitialDataMsg | updatePlayerProgressMsg | setThemeMsg | getThemeMsg | - RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg | chatUserActionResponse | Ping; + RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg | chatUserActionResponse | Ping | + fetchReplyThreadMsg | replyThreadResponse; type Port = Omit & { postMessage: (message: BackgroundMessage | BackgroundResponse) => void; diff --git a/src/ts/typings/ytc.d.ts b/src/ts/typings/ytc.d.ts index c513efde..0aa31a54 100644 --- a/src/ts/typings/ytc.d.ts +++ b/src/ts/typings/ytc.d.ts @@ -12,6 +12,18 @@ declare namespace Ytc { contents?: { liveChatRenderer: BaseData; }; + frameworkUpdates?: { + entityBatchUpdate?: { + mutations?: Array<{ + payload?: { + likeCountEntity?: { + key?: string; + likeCountIfIndifferentNumber?: string; + }; + }; + }>; + }; + }; } interface BaseData { @@ -137,6 +149,9 @@ declare namespace Ytc { }; }; }; + openEngagementPanelCommand?: { + showEngagementPanelEndpoint?: ShowEngagementPanelEndpoint; + }; }; }; durationSec: IntString; @@ -208,6 +223,45 @@ declare namespace Ytc { params: string; }; }; + /** Reply-to-superchat button on normal text messages. */ + beforeContentButtons?: Array<{ + buttonViewModel?: ReplyButtonViewModel; + }>; + /** Reply-thread entry button on SC paid renderers. */ + replyButton?: { + pdgReplyButtonViewModel?: { + replyButton?: { + buttonViewModel?: ReplyButtonViewModel; + }; + replyCountEntityKey?: string; + }; + }; + /** Like button entity key on SC paid renderers; resolved against likeCountEntity mutations. */ + pdgLikeButton?: { + pdgLikeViewModel?: { + likeCountEntityKey?: string; + }; + }; + } + + interface ShowEngagementPanelEndpoint { + identifier?: { + tag?: string; + }; + globalConfiguration?: { + params?: string; + }; + } + + interface ReplyButtonViewModel { + title?: string; + onTap?: { + innertubeCommand?: { + showEngagementPanelEndpoint?: ShowEngagementPanelEndpoint; + }; + }; + customBackgroundColor?: number; + customFontColor?: number; } interface IPaidRenderer extends TextMessageRenderer { @@ -428,6 +482,27 @@ declare namespace Ytc { params?: string; membershipGiftPurchase?: ParsedMembershipGiftPurchase; membershipGiftRedeem?: boolean; + /** Reply context when this message is a reply to a Super Chat. */ + replyToSuperchat?: ParsedReplyToSuperchat; + /** Opaque get_panel params for fetching this message's own reply thread (set on SCs). */ + replyThreadParams?: string; + /** Entity key for resolving live like counts (set on SCs). */ + likeCountEntityKey?: string; + /** SC discussion thread id; shared between SC entity keys and reply chip params. */ + threadId?: string; + } + + interface ParsedReplyToSuperchat { + /** Display name shown in the reply chip on YouTube, e.g. "@Lethelmills". */ + authorName: string; + /** Opaque get_panel params for fetching the SC's reply thread. */ + params: string; + /** 35-byte SC discussion thread id extracted from the reply params, used to match against the SC. */ + threadId?: string; + /** ARGB-derived hex of the SC reply-button background color. */ + bgColor?: string; + /** ARGB-derived hex of the SC reply-button foreground color. */ + fgColor?: string; } interface ParsedBonk { @@ -517,5 +592,7 @@ declare namespace Ytc { miscActions: ParsedMisc[]; isReplay: boolean; refresh: boolean; + /** entityKey → like count, sourced from frameworkUpdates.entityBatchUpdate mutations. */ + likeCounts?: Record; } }