From de4b6413842d4285441f92514030280112deff21 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Mon, 11 May 2026 20:37:59 -0700 Subject: [PATCH 1/7] Fix SC report menu not showing up on top of chat --- src/components/PaidMessage.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/PaidMessage.svelte b/src/components/PaidMessage.svelte index 43604cae..4f1ec393 100644 --- a/src/components/PaidMessage.svelte +++ b/src/components/PaidMessage.svelte @@ -22,7 +22,8 @@ headerStyle = ''; } - const classes = 'inline-flex flex-col rounded break-words overflow-hidden w-full'; + const classes = 'inline-flex flex-col rounded break-words w-full'; + $: hasBody = message.message.length > 0; $: displayAuthorName = formatAuthorName(message.author.name); $: if (!paid) { @@ -36,7 +37,7 @@ {#if paid}
-
+
{#if $showProfileIcons} Date: Mon, 11 May 2026 20:44:02 -0700 Subject: [PATCH 2/7] Make dialog popups snappier --- src/components/common/Dialog.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 @@ - +
From 2f09310461283e8c66e4b183e075342052f062fa Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Mon, 11 May 2026 23:10:18 -0700 Subject: [PATCH 3/7] Restyle SC/Membership panels slightly Amount/Tier on the left, timestamp between avatar and name if enabled, duration (membership) on a 2nd line, super sticker underneath the header instead of on the right --- src/components/MembershipItem.svelte | 52 +++++++++++++++------------- src/components/PaidMessage.svelte | 21 ++++++----- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/src/components/MembershipItem.svelte b/src/components/MembershipItem.svelte index d5b472f7..7277c343 100644 --- a/src/components/MembershipItem.svelte +++ b/src/components/MembershipItem.svelte @@ -2,7 +2,7 @@ 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; @@ -26,31 +26,33 @@ class="p-2" style="{isMilestoneChat ? `background-color: #${milestoneChatBackground};` : ''}" > - {#if $showProfileIcons} - {message.author.profileIcon.alt} - {/if} - - {displayAuthorName} - +
+ {#if $showProfileIcons} + {message.author.profileIcon.alt} + {/if} + {#if $showTimestamps} + {message.timestamp} + {/if} + + {displayAuthorName} + + {#if membershipGift} + {membershipGift.image.alt} + {/if} + {#if membership} + + {/if} +
{#if primaryText && primaryText.length > 0} - - {/if} - {#if membership} - - {/if} - {#if membershipGift} - {membershipGift.image.alt} + {/if}
{#if isMilestoneChat} diff --git a/src/components/PaidMessage.svelte b/src/components/PaidMessage.svelte index 4f1ec393..6cee8e75 100644 --- a/src/components/PaidMessage.svelte +++ b/src/components/PaidMessage.svelte @@ -3,7 +3,7 @@ import isDarkColor from 'is-dark-color'; import { Theme } from '../ts/chat-constants'; import { formatAuthorName } from '../ts/component-utils'; - import { showProfileIcons } from '../ts/storage'; + import { showProfileIcons, showTimestamps } from '../ts/storage'; export let message: Ytc.ParsedMessage; @@ -23,7 +23,7 @@ } const classes = 'inline-flex flex-col rounded break-words w-full'; - $: hasBody = message.message.length > 0; + $: hasBody = message.message.length > 0 || !!message.superSticker; $: displayAuthorName = formatAuthorName(message.author.name); $: if (!paid) { @@ -45,18 +45,23 @@ alt={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}
From 67bd774c417648a984b384ce5ec2c460bc140242 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Tue, 12 May 2026 04:49:24 -0700 Subject: [PATCH 4/7] Restyle membership gift panels so the image is a faded background --- src/components/MembershipItem.svelte | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/components/MembershipItem.svelte b/src/components/MembershipItem.svelte index 7277c343..ad1ead91 100644 --- a/src/components/MembershipItem.svelte +++ b/src/components/MembershipItem.svelte @@ -7,7 +7,7 @@ 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}
@@ -40,13 +48,6 @@ {displayAuthorName} - {#if membershipGift} - {membershipGift.image.alt} - {/if} {#if membership} {/if} @@ -56,7 +57,7 @@ {/if}
{#if isMilestoneChat} -
+
{/if} From 602c69876b787580ee77ad60a0439f97c9571907 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Tue, 12 May 2026 01:05:37 -0700 Subject: [PATCH 5/7] Implement SC reply/like display (no sending yet) and move SC panel to the top of the chat window This adds an icon in front of a chat message if it's a reply to a superchat, which will open that superchat's panel when clicked (if available). Setting to disable this icon. It also pulls like counts from the incoming requests and keeps track of them for the superchats that are currently visible in the ticker at the top. It also also shows incoming replies in that superchat's panel if it's open (in a collapsible section). --- src/components/Hyperchat.svelte | 63 +++++- src/components/Message.svelte | 39 +++- src/components/SuperchatViewDialog.svelte | 153 +++++++++++++- .../settings/InterfaceSettings.svelte | 4 +- src/ts/chat-actions.ts | 43 ++++ src/ts/chat-constants.ts | 2 + src/ts/chat-parser.ts | 106 +++++++++- src/ts/chat-utils.ts | 2 +- src/ts/messaging.ts | 192 ++++++++++++------ src/ts/queue.ts | 3 + src/ts/storage.ts | 4 + src/ts/typings/chat.d.ts | 27 ++- src/ts/typings/ytc.d.ts | 77 +++++++ 13 files changed, 630 insertions(+), 85 deletions(-) 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/Message.svelte b/src/components/Message.svelte index b576d45d..368adc0c 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} - import { focusedSuperchat } from '../ts/storage'; + import { slide } from 'svelte/transition'; + import Icon from 'smelte/src/components/Icon'; + import { + focusedSuperchat, + port, + activeReplyThreadId, + liveReplyBuffer, + liveLikeCounts + } from '../ts/storage'; + import { fetchReplyThread } from '../ts/chat-actions'; import Dialog from './common/Dialog.svelte'; import PaidMessage from './PaidMessage.svelte'; import MembershipItem from './MembershipItem.svelte'; + import Message from './Message.svelte'; - $: sc = $focusedSuperchat!; + $: sc = $focusedSuperchat; let open = false; const openDialog = () => (open = true); const closeDialog = () => ($focusedSuperchat = null); $: if (sc) openDialog(); $: if (!open) closeDialog(); + + let fetchedReplies: Ytc.ParsedMessage[] = []; + let replyError: string | null = null; + let loadingReplies = false; + let repliesExpanded = false; + let lastFetchedParams: string | null = null; + + $: replyThreadParams = sc?.replyThreadParams; + $: likeCountKey = sc?.likeCountEntityKey; + $: likeCount = likeCountKey ? $liveLikeCounts.get(likeCountKey) : undefined; + + $: fetchedIds = new Set(fetchedReplies.map(r => r.messageId)); + $: replies = [...fetchedReplies, ...$liveReplyBuffer.filter(r => !fetchedIds.has(r.messageId))]; + + $: scPaid = sc && (sc.superChat ?? sc.superSticker); + $: scTintStyle = sc?.superChat + ? `background-color: #${sc.superChat.headerBackgroundColor}; color: #${sc.superChat.headerTextColor};` + : sc?.superSticker + ? `background-color: #${sc.superSticker.bodyBackgroundColor}; color: #${sc.superSticker.bodyTextColor};` + : ''; + $: borderStyle = scPaid ? `border: 2px solid #${scPaid.bodyBackgroundColor};` : ''; + + const setReplyState = (active: boolean, threadId: string | null, params: string | null) => { + fetchedReplies = []; + replyError = null; + $liveReplyBuffer = []; + loadingReplies = active; + $activeReplyThreadId = threadId; + lastFetchedParams = params; + }; + + $: if (open && replyThreadParams && replyThreadParams !== lastFetchedParams) { + const fetching = replyThreadParams; + setReplyState(true, sc?.threadId ?? null, fetching); + fetchReplyThread(fetching, $port) + .then((r) => { + if (lastFetchedParams !== fetching) return; + fetchedReplies = r; + loadingReplies = false; + }) + .catch((e) => { + if (lastFetchedParams !== fetching) return; + replyError = String(e?.message ?? e); + loadingReplies = false; + }); + } + + $: if (!open) { + setReplyState(false, null, null); + repliesExpanded = false; + } + + $: canExpand = !loadingReplies && !replyError && (replies.length > 0 || likeCountKey != null); + $: if (!canExpand && repliesExpanded) repliesExpanded = false; + + const toggleReplies = () => { + if (!canExpand) return; + repliesExpanded = !repliesExpanded; + }; - - {#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/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; } } From 56c2881a5f2514b6a434fcd9273f1bcb4e62ccf9 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Tue, 12 May 2026 06:14:22 -0700 Subject: [PATCH 6/7] eslint --- src/components/Message.svelte | 2 +- src/components/SuperchatViewDialog.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Message.svelte b/src/components/Message.svelte index 368adc0c..8204b9d8 100644 --- a/src/components/Message.svelte +++ b/src/components/Message.svelte @@ -157,7 +157,7 @@ - {#if sc.superChat || sc.superSticker} + {#if sc.superChat ?? sc.superSticker} {:else} From 30f56f6727e7983a74570b628679415ae2c27d52 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Tue, 12 May 2026 06:33:34 -0700 Subject: [PATCH 7/7] Remove unnecessary div --- src/components/MembershipItem.svelte | 34 +++++++++++++--------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/components/MembershipItem.svelte b/src/components/MembershipItem.svelte index ad1ead91..de7f41cb 100644 --- a/src/components/MembershipItem.svelte +++ b/src/components/MembershipItem.svelte @@ -34,24 +34,22 @@ class="p-2 relative z-10" style="{isMilestoneChat ? `background-color: #${milestoneChatBackground};` : ''}" > -
- {#if $showProfileIcons} - {message.author.profileIcon.alt} - {/if} - {#if $showTimestamps} - {message.timestamp} - {/if} - - {displayAuthorName} - - {#if membership} - - {/if} -
+ {#if $showProfileIcons} + {message.author.profileIcon.alt} + {/if} + {#if $showTimestamps} + {message.timestamp} + {/if} + + {displayAuthorName} + + {#if membership} + + {/if} {#if primaryText && primaryText.length > 0} {/if}