diff --git a/src/components/Hyperchat.svelte b/src/components/Hyperchat.svelte index a0588a33..ab89cdb5 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -2,6 +2,7 @@ import '../stylesheets/scrollbar.css'; import { onDestroy, 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'; @@ -24,6 +25,7 @@ ChatUserActions } from '../ts/chat-constants'; import { isAllEmoji, isChatMessage, isPrivileged, responseIsAction } from '../ts/chat-utils'; + import { handleReplyThreadResponse } from '../ts/chat-actions'; import Button from 'smelte/src/components/Button'; import { theme, @@ -41,6 +43,9 @@ selfChannel, alertDialog, stickySuperchats, + activeReplyThreadId, + liveReplyBuffer, + liveLikeCounts, currentProgress, enableStickySuperchatBar, lastOpenedVersion, @@ -141,6 +146,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) => { @@ -151,15 +181,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 = [ @@ -167,6 +206,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; }; @@ -232,6 +277,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; + } } }; @@ -290,6 +348,9 @@ break; case 'registerClientResponse': 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 54a72cec..d37a7faa 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 f2321a68..1a4e3551 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 2b07c6ed..8383c4c7 100644 --- a/src/components/SuperchatViewDialog.svelte +++ b/src/components/SuperchatViewDialog.svelte @@ -1,24 +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}
@@ -28,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 0c90f3e8..bac803ad 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/scripts/chat-background.ts b/src/scripts/chat-background.ts index 862d3496..d7bd2872 100644 --- a/src/scripts/chat-background.ts +++ b/src/scripts/chat-background.ts @@ -385,6 +385,26 @@ const sendChatUserActionResponse = ( ); }; +const fetchReplyThread = ( + port: Chat.Port, + message: Chat.fetchReplyThreadMsg +): void => { + const interceptor = findInterceptorFromClient(port); + interceptor?.port?.postMessage(message); +}; + +const sendReplyThreadResponse = ( + port: Chat.Port, + message: Chat.replyThreadResponse +): void => { + const interceptor = findInterceptorFromPort(port, { message }); + if (!interceptor) return; + + interceptor.clients.forEach( + (clientPort) => clientPort.postMessage(message) + ); +}; + chrome.runtime.onConnect.addListener((port) => { port.onMessage.addListener((message: Chat.BackgroundMessage) => { switch (message.type) { @@ -421,6 +441,12 @@ chrome.runtime.onConnect.addListener((port) => { case 'chatUserActionResponse': sendChatUserActionResponse(port, message); break; + case 'fetchReplyThread': + fetchReplyThread(port, message); + break; + case 'replyThreadResponse': + sendReplyThreadResponse(port, message); + break; default: console.error('Unknown message type', port, message); break; diff --git a/src/scripts/chat-interceptor.ts b/src/scripts/chat-interceptor.ts index 4dda76a6..bc414fb6 100644 --- a/src/scripts/chat-interceptor.ts +++ b/src/scripts/chat-interceptor.ts @@ -1,6 +1,7 @@ import { fixLeaks } from '../ts/ytc-fix-memleaks'; import { frameIsReplay as isReplay, checkInjected } from '../ts/chat-utils'; -import { chatReportUserOptions, ChatUserActions, isLiveTL } from '../ts/chat-constants'; +import { chatReportUserOptions, ChatUserActions, isLiveTL, replyThreadPanelTag } from '../ts/chat-constants'; +import { parseChatResponse } from '../ts/chat-parser'; import sha1 from 'sha-1'; function injectedFunction(): void { @@ -121,25 +122,9 @@ const chatLoaded = async (): Promise => { return ''; }; - if (msg.type !== 'executeChatAction') return; - const message = msg.message; - const debugAction = msg.action === ChatUserActions.DELETE_MESSAGE; - let success = true; - if (message.params == null) { - success = false; - } - try { - if (message.params == null) { - throw new Error('Missing context menu params for message'); - } - const currentDomain = (location.protocol + '//' + location.host); - const apiKey = ytcfg.data_.INNERTUBE_API_KEY; - 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; - // Do not override Innertube headers like X-Goog-Visitor-Id here. Those can differ from - // ytcfg.context.client.visitorData in subtle ways and cause YT to treat the request as logged out. - // Instead, let the page-side proxy merge the latest headers from real YT requests. + const currentDomain = (location.protocol + '//' + location.host); + const baseContext = ytcfg.data_.INNERTUBE_CONTEXT; + const buildInnertubeHeaders = () => { const time = Math.floor(Date.now() / 1000); const sapisid = getCookie('__Secure-3PAPISID') || getCookie('SAPISID'); const auth = sapisid ? `SAPISIDHASH ${time}_${sha1(`${time} ${sapisid} ${currentDomain}`)}` : null; @@ -147,7 +132,7 @@ const chatLoaded = async (): Promise => { 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 = { + return { headers: { 'Content-Type': 'application/json', Accept: '*/*', @@ -161,6 +146,70 @@ const chatLoaded = async (): Promise => { method: 'POST' as const, mode: 'same-origin' as const }; + }; + + if (msg.type === 'fetchReplyThread') { + try { + const panelRes = await fetcher( + `${currentDomain}/youtubei/v1/get_panel?prettyPrint=false`, + { + ...buildInnertubeHeaders(), + body: JSON.stringify({ + context: baseContext, + panelId: replyThreadPanelTag, + params: msg.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()); + port.postMessage({ + type: 'replyThreadResponse', + requestId: msg.requestId, + success: true, + replies: (chunk?.messages ?? []) as Ytc.ParsedMessage[] + }); + } catch (e) { + port.postMessage({ + type: 'replyThreadResponse', + requestId: msg.requestId, + success: false, + replies: [], + error: String(e) + }); + } + return; + } + + if (msg.type !== 'executeChatAction') return; + const message = msg.message; + const debugAction = msg.action === ChatUserActions.DELETE_MESSAGE; + let success = true; + if (message.params == null) { + success = false; + } + try { + if (message.params == null) { + throw new Error('Missing context menu params for message'); + } + const apiKey = ytcfg.data_.INNERTUBE_API_KEY; + const contextMenuUrl = `${currentDomain}/youtubei/v1/live_chat/get_item_context_menu?params=` + + `${encodeURIComponent(message.params)}&pbj=1&key=${apiKey}&prettyPrint=false`; + // Do not override Innertube headers like X-Goog-Visitor-Id here. Those can differ from + // ytcfg.context.client.visitorData in subtle ways and cause YT to treat the request as logged out. + // Instead, let the page-side proxy merge the latest headers from real YT requests. + const heads = buildInnertubeHeaders(); const contextMenuContext = JSON.parse(JSON.stringify(baseContext)); if (debugAction) { console.debug('[hc] delete: get_item_context_menu', { diff --git a/src/ts/chat-actions.ts b/src/ts/chat-actions.ts index 087f6f6a..df0c1c3d 100644 --- a/src/ts/chat-actions.ts +++ b/src/ts/chat-actions.ts @@ -28,3 +28,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 22f67427..e3c28136 100644 --- a/src/ts/chat-constants.ts +++ b/src/ts/chat-constants.ts @@ -106,4 +106,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 4eed8320..352a8f39 100644 --- a/src/ts/chat-parser.ts +++ b/src/ts/chat-parser.ts @@ -4,7 +4,7 @@ import { isMembershipRenderer, isMembershipGiftPurchaseRenderer } from './chat-utils'; -import { currentDomain } from './chat-constants'; +import { currentDomain, replyThreadPanelTag } from './chat-constants'; // Source: https://stackoverflow.com/a/64396666 const standardEmoji = @@ -17,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; @@ -201,6 +263,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, @@ -336,6 +421,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, @@ -448,12 +540,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 0cf7bff0..afb8568a 100644 --- a/src/ts/chat-utils.ts +++ b/src/ts/chat-utils.ts @@ -50,7 +50,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/queue.ts b/src/ts/queue.ts index 38ad81ec..1646840f 100644 --- a/src/ts/queue.ts +++ b/src/ts/queue.ts @@ -244,6 +244,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 756d2410..1c27ed03 100644 --- a/src/ts/storage.ts +++ b/src/ts/storage.ts @@ -74,6 +74,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') @@ -83,4 +86,5 @@ 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', ''); diff --git a/src/ts/typings/chat.d.ts b/src/ts/typings/chat.d.ts index 76525eff..1d40cbc8 100644 --- a/src/ts/typings/chat.d.ts +++ b/src/ts/typings/chat.d.ts @@ -35,7 +35,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; @@ -76,9 +81,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; + registerClientResponse | executeChatActionMsg | chatUserActionResponse | + replyThreadResponse; type InterceptorSource = 'ytc' | 'ltlMessage'; @@ -142,7 +162,8 @@ declare namespace Chat { type BackgroundMessage = RegisterInterceptorMsg | RegisterClientMsg | processJsonMsg | setInitialDataMsg | updatePlayerProgressMsg | setThemeMsg | getThemeMsg | - RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg | chatUserActionResponse; + RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg | chatUserActionResponse | + 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 42804a9b..4ea66250 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 { @@ -132,6 +144,9 @@ declare namespace Ytc { }; }; }; + openEngagementPanelCommand?: { + showEngagementPanelEndpoint?: ShowEngagementPanelEndpoint; + }; }; }; durationSec: IntString; @@ -209,6 +224,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 { @@ -458,6 +512,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 { @@ -548,5 +623,7 @@ declare namespace Ytc { miscActions: ParsedMisc[]; isReplay: boolean; refresh: boolean; + /** entityKey → like count, sourced from frameworkUpdates.entityBatchUpdate mutations. */ + likeCounts?: Record; } }