From 270523f51eb7b921267e9bda2d0bd4f61f1bf35c Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Sun, 16 Feb 2025 01:51:06 -0700 Subject: [PATCH 1/6] Add implementation for showing poll results, make unpin action actionId-aware so that multiple things can be removed (e.g. polls) instead of only pinned messages --- src/components/Hyperchat.svelte | 31 ++++++++++- src/components/PollResults.svelte | 92 +++++++++++++++++++++++++++++++ src/ts/chat-parser.ts | 55 ++++++++++++++++-- src/ts/chat-utils.ts | 2 +- src/ts/typings/ytc.d.ts | 90 ++++++++++++++++++++++++++++-- 5 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 src/components/PollResults.svelte diff --git a/src/components/Hyperchat.svelte b/src/components/Hyperchat.svelte index 607f4da..27430b7 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -8,6 +8,7 @@ import PinnedMessage from './PinnedMessage.svelte'; import ChatSummary from './ChatSummary.svelte'; import RedirectBanner from './RedirectBanner.svelte'; + import PollResults from './PollResults.svelte'; import PaidMessage from './PaidMessage.svelte'; import MembershipItem from './MembershipItem.svelte'; import ReportBanDialog from './ReportBanDialog.svelte'; @@ -55,6 +56,7 @@ const TRUNCATE_SIZE = 20; let messageActions: (Chat.MessageAction | Welcome)[] = []; const messageKeys = new Set(); + let poll: Ytc.ParsedPoll | null; let pinned: Ytc.ParsedPinned | null; let summary: Ytc.ParsedSummary | null; let redirect: Ytc.ParsedRedirect | null; @@ -83,7 +85,7 @@ // }, // showtime: 5000, // }; - $: hasBanner = pinned ?? redirect ?? (summary && $showChatSummary); + $: hasBanner = poll ?? pinned ?? redirect ?? (summary && $showChatSummary); let div: HTMLElement; let isAtBottom = true; let truncateInterval: number; @@ -214,6 +216,9 @@ case 'delete': onDelete(action.deletion); break; + case 'poll': + poll = action; + break; case 'summary': summary = action; break; @@ -224,7 +229,22 @@ pinned = action; break; case 'unpin': - pinned = null; + if (action.targetActionId) { + if (action.targetActionId === pinned?.actionId) { + pinned = null; + } + if (action.targetActionId === summary?.actionId) { + summary = null; + } + if (action.targetActionId === poll?.actionId) { + poll = null; + } + if (action.targetActionId === redirect?.actionId) { + redirect = null; + } + } else { + pinned = null; + } break; case 'playerProgress': $currentProgress = action.playerProgress; @@ -376,7 +396,7 @@ } }, 350); }; - $: $enableStickySuperchatBar, pinned, topBarResized(); + $: $enableStickySuperchatBar, hasBanner, topBarResized(); const isMention = (msg: Ytc.ParsedMessage) => { return $selfChannelName && msg.message.map(run => { @@ -432,6 +452,11 @@ {#if hasBanner}
+ {#if poll} +
+ +
+ {/if} {#if summary && $showChatSummary}
diff --git a/src/components/PollResults.svelte b/src/components/PollResults.svelte new file mode 100644 index 0000000..a08c76b --- /dev/null +++ b/src/components/PollResults.svelte @@ -0,0 +1,92 @@ + + +{#if !dismissed} +
+
+
+ + + {#if shorten} + expand_more + {:else} + expand_less + {/if} + + + {#if $showProfileIcons} + {poll.item.profileIcon.alt} + {/if} + {#each poll.item.header as run} + {#if run.type === 'text'} + {run.text} + {/if} + {/each} +
+
+ + { dismissed = true; }} + > + close + + Dismiss + +
+
+ {#if !shorten && !dismissed} +
+ +
+ {#each poll.item.choices as choice} +
+ + + {choice.percentage} + +
+ + {/each} + {/if} +
+{/if} diff --git a/src/ts/chat-parser.ts b/src/ts/chat-parser.ts index b70cf63..0cdc722 100644 --- a/src/ts/chat-parser.ts +++ b/src/ts/chat-parser.ts @@ -75,7 +75,7 @@ const splitRunsByNewline = (runs: Ytc.ParsedRun[], maxSplit: number = -1): Ytc.P return acc; }, [[]]); -const parseChatSummary = (renderer: Ytc.AddChatItem, showtime: number): Ytc.ParsedSummary | undefined => { +const parseChatSummary = (renderer: Ytc.AddChatItem, actionId: string, showtime: number): Ytc.ParsedSummary | undefined => { if (!renderer.liveChatBannerChatSummaryRenderer) { return; } @@ -96,18 +96,18 @@ const parseChatSummary = (renderer: Ytc.AddChatItem, showtime: number): Ytc.Pars }); const item: Ytc.ParsedSummary = { type: 'summary', + actionId: baseRenderer.liveChatSummaryId, item: { header: splitRuns[0], subheader: subheader, message: splitRuns[2], }, - id: baseRenderer.liveChatSummaryId, showtime: showtime, }; return item; } -const parseRedirectBanner = (renderer: Ytc.AddChatItem, showtime: number): Ytc.ParsedRedirect | undefined => { +const parseRedirectBanner = (renderer: Ytc.AddChatItem, actionId: string, showtime: number): Ytc.ParsedRedirect | undefined => { if (!renderer.liveChatBannerRedirectRenderer) { return; } @@ -122,6 +122,7 @@ const parseRedirectBanner = (renderer: Ytc.AddChatItem, showtime: number): Ytc.P : ''); const item: Ytc.ParsedRedirect = { type: 'redirect', + actionId: actionId, item: { message: parseMessageRuns(baseRenderer.bannerMessage.runs), profileIcon: profileIcon, @@ -257,19 +258,55 @@ const parseMessageDeletedAction = (action: Ytc.MessageDeletedAction): Ytc.Parsed }; }; +const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | undefined => { + if (!baseRenderer) { + return; + } + const profileIcon = { + src: fixUrl(baseRenderer.header.pollHeaderRenderer.thumbnail?.thumbnails[0].url ?? ''), + alt: 'Poll profile icon' + }; + // TODO implement 'selected' field? YT doesn't use it in results. + return { + type: 'poll', + actionId: baseRenderer.liveChatPollId, + item: { + profileIcon: profileIcon, + header: parseMessageRuns(baseRenderer.header.pollHeaderRenderer.metadataText.runs), + question: parseMessageRuns(baseRenderer.header.pollHeaderRenderer.pollQuestion.runs), + choices: baseRenderer.choices.map((choice) => { + return { + text: parseMessageRuns(choice.text.runs), + selected: choice.selected, + ratio: choice.voteRatio, + percentage: choice.votePercentage?.simpleText + }; + }), + } + }; +} + const parseBannerAction = (action: Ytc.AddPinnedAction): Ytc.ParsedMisc | undefined => { const baseRenderer = action.bannerRenderer.liveChatBannerRenderer; + // polls can come through banner or through update actions, send both to the same parser + // actionId isn't needed as the pollRenderer contains liveChatPollId + if (baseRenderer.contents.pollRenderer) { + return parsePollRenderer(baseRenderer.contents.pollRenderer); + } + + const actionId = baseRenderer.actionId; + // fold both auto-disappear and auto-collapse into just collapse for showtime const showtime = action.bannerProperties?.isEphemeral ? (action.bannerProperties?.bannerTimeoutMs || 0) : 1000 * (action.bannerProperties?.autoCollapseDelay?.seconds || baseRenderer.bannerProperties?.autoCollapseDelay?.seconds || 0); if (baseRenderer.contents.liveChatBannerChatSummaryRenderer) { - return parseChatSummary(baseRenderer.contents, showtime); + return parseChatSummary(baseRenderer.contents, actionId, showtime); } if (baseRenderer.contents.liveChatBannerRedirectRenderer) { - return parseRedirectBanner(baseRenderer.contents, showtime); + return parseRedirectBanner(baseRenderer.contents, actionId, showtime); } const parsedContents = parseAddChatItemAction( { item: baseRenderer.contents }, true @@ -279,6 +316,7 @@ const parseBannerAction = (action: Ytc.AddPinnedAction): Ytc.ParsedMisc | undefi } return { type: 'pin', + actionId: actionId, item: { header: parseMessageRuns( baseRenderer.header.liveChatBannerHeaderRenderer.text.runs @@ -318,9 +356,14 @@ const processCommonAction = ( } else if (action.addBannerToLiveChatCommand) { return parseBannerAction(action.addBannerToLiveChatCommand); } else if (action.removeBannerForLiveChatCommand) { - return { type: 'unpin' } as const; + return { + type: 'unpin', + targetActionId: action.removeBannerForLiveChatCommand.targetActionId, + } as Ytc.ParsedRemoveBanner; } else if (action.addLiveChatTickerItemAction) { return parseTickerAction(action.addLiveChatTickerItemAction, isReplay, liveTimeoutOrReplayMs); + } else if (action.updateLiveChatPollAction) { + return parsePollRenderer(action.updateLiveChatPollAction.pollToUpdate.pollRenderer); } }; diff --git a/src/ts/chat-utils.ts b/src/ts/chat-utils.ts index 9a90550..fe22d2b 100644 --- a/src/ts/chat-utils.ts +++ b/src/ts/chat-utils.ts @@ -42,7 +42,7 @@ export const isValidFrameInfo = (f: Chat.UncheckedFrameInfo, port?: Chat.Port): return check; }; -const actionTypes = new Set(['messages', 'bonk', 'delete', 'pin', 'unpin', 'summary', 'redirect', 'playerProgress', 'forceUpdate']); +const actionTypes = new Set(['messages', 'bonk', 'delete', 'pin', 'unpin', 'summary', 'poll', 'redirect', 'playerProgress', 'forceUpdate']); export const responseIsAction = (r: Chat.BackgroundResponse): r is Chat.Actions => actionTypes.has(r.type); diff --git a/src/ts/typings/ytc.d.ts b/src/ts/typings/ytc.d.ts index d86114a..1efecea 100644 --- a/src/ts/typings/ytc.d.ts +++ b/src/ts/typings/ytc.d.ts @@ -34,8 +34,11 @@ declare namespace Ytc { interface ReplayAction { addChatItemAction?: AddChatItemAction; addBannerToLiveChatCommand?: AddPinnedAction; - removeBannerForLiveChatCommand?: unknown; + removeBannerForLiveChatCommand?: { + targetActionId: string; + }; addLiveChatTickerItemAction?: AddTickerAction; + updateLiveChatPollAction?: UpdatePollAction; } /** Expected YTC action object */ @@ -86,6 +89,7 @@ declare namespace Ytc { text: RunsObj; }; }; + actionId: string; /** Gets used for pinned messages */ bannerProperties?: BannerPropertiesObj; }; @@ -148,6 +152,12 @@ declare namespace Ytc { accessibility?: AccessibilityObj; } + interface UpdatePollAction { + pollToUpdate: { + pollRenderer: PollRenderer; + }; + } + /** Message run object */ interface MessageRun { text?: string; @@ -233,6 +243,19 @@ declare namespace Ytc { }; } + interface EngagementMessageRenderer { + message: RunsObj[]; + id: string; + timestampUsec?: IntString; + icon?: { + /** Unlocalized string */ + iconType: string; + }; + actionButton?: { + buttonRenderer: ButtonRenderer; + } + } + interface ChatSummaryRenderer { liveChatSummaryId: string; chatSummary: RunsObj; @@ -259,8 +282,17 @@ declare namespace Ytc { icon?: string; accessibility?: AccessibilityObj; isDisabled?: boolean; - text?: RunsObj; + text?: RunsObj; // | SimpleTextObj; command: { + commandMetadata?: { + webCommandMetadata?: { + apiUrl?: string; + sendPost?: boolean; + } + } + liveChatActionEndpoint?: { + params: string; + } urlEndpoint?: { url: string; target: string; @@ -271,6 +303,28 @@ declare namespace Ytc { } } + interface PollRenderer { + choices: PollChoice[]; + liveChatPollId: string; + header: { + pollHeaderRenderer: { + pollQuestion: RunsObj; + thumbnail: Thumbnails; + metadataText: RunsObj; + liveChatPollType: string; + } + } + displayVoteResults?: boolean; + button?: ButtonRenderer; + } + + interface PollChoice { + text: RunsObj; + selected: boolean; + voteRatio?: number; + votePercentage?: SimpleTextObj; + } + interface PlaceholderRenderer { // No idea what the purpose of this is id: string; timestampUsec: IntString; @@ -296,6 +350,10 @@ declare namespace Ytc { liveChatBannerChatSummaryRenderer?: ChatSummaryRenderer; /** Redirects */ liveChatBannerRedirectRenderer?: RedirectRenderer; + /** Poll start */ + pollRenderer?: PollRenderer; + /** Poll end + other in-chat announcements TODO */ + liveChatViewerEngagementMessageRenderer?: EngagementMessageRenderer; /** ??? */ liveChatPlaceholderItemRenderer?: PlaceholderRenderer; } @@ -414,6 +472,7 @@ declare namespace Ytc { interface ParsedPinned { type: 'pin'; + actionId: string; item: { header: ParsedRun[]; contents: ParsedMessage; @@ -423,17 +482,18 @@ declare namespace Ytc { interface ParsedSummary { type: 'summary'; + actionId: string; item: { header: ParsedRun[]; subheader: ParsedRun[]; message: ParsedRun[]; }; - id: string; showtime: number; } interface ParsedRedirect { type: 'redirect'; + actionId: string; item: { message: ParsedRun[]; profileIcon: ParsedImage; @@ -445,13 +505,35 @@ declare namespace Ytc { showtime: number; } + interface ParsedPoll { + type: 'poll'; + actionId: string; + item: { + header: ParsedRun[]; + profileIcon: ParsedImage; + question: ParsedRun[]; + choices: Array<{ + text: ParsedRun[]; + selected: boolean; + ratio?: number; + percentage?: string; + }>; + } + // TODO add 'action' for ending poll button + } + + interface ParsedRemoveBanner { + type: 'unpin'; + targetActionId: string; + } + interface ParsedTicker extends ParsedMessage { type: 'ticker'; tickerDuration: number; detailText?: string; } - type ParsedMisc = ParsedPinned | ParsedSummary | ParsedRedirect | { type: 'unpin' }; + type ParsedMisc = ParsedPinned | ParsedSummary | ParsedRedirect | ParsedPoll | ParsedRemoveBanner; type ParsedTimedItem = ParsedMessage | ParsedTicker; From f6a743b6cbece2fda56ee3d39367d40fd750683c Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Sun, 16 Feb 2025 16:07:06 -0700 Subject: [PATCH 2/6] Remove cursor-pointer from poll options --- src/components/PollResults.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PollResults.svelte b/src/components/PollResults.svelte index a08c76b..3713915 100644 --- a/src/components/PollResults.svelte +++ b/src/components/PollResults.svelte @@ -80,7 +80,7 @@
{#each poll.item.choices as choice}
- + {choice.percentage} From ad584a710fd63572e93854cba1f67effb0b34aac Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Tue, 18 Feb 2025 01:33:15 -0700 Subject: [PATCH 3/6] Fix build lint --- src/components/PollResults.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PollResults.svelte b/src/components/PollResults.svelte index 3713915..dd91c31 100644 --- a/src/components/PollResults.svelte +++ b/src/components/PollResults.svelte @@ -20,7 +20,7 @@ shorten = !shorten; }; - $: if (poll.actionId != prevId) { + $: if (poll.actionId !== prevId) { dismissed = false; shorten = false; prevId = poll.actionId; From 8fe1a5587c551ed5160122e6950d4fb3a04c24ad Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Tue, 18 Feb 2025 07:33:23 -0700 Subject: [PATCH 4/6] Enforce percentage sits on top of bar instead of floating above it if the choice is multiline --- src/components/PollResults.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/PollResults.svelte b/src/components/PollResults.svelte index dd91c31..81ac85a 100644 --- a/src/components/PollResults.svelte +++ b/src/components/PollResults.svelte @@ -79,9 +79,9 @@
{#each poll.item.choices as choice} -
+
- + {choice.percentage}
From c6b29180b13aee043e4e7f41383e396cb33fd12f Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Tue, 25 Feb 2025 14:37:48 -0800 Subject: [PATCH 5/6] Remove dummy redirect data --- src/components/Hyperchat.svelte | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/components/Hyperchat.svelte b/src/components/Hyperchat.svelte index 27430b7..85f70a8 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -60,31 +60,6 @@ let pinned: Ytc.ParsedPinned | null; let summary: Ytc.ParsedSummary | null; let redirect: Ytc.ParsedRedirect | null; - // = { - // type: 'redirect', - // item: { - // message: [ - // { - // type: 'text', - // text: 'Don\'t miss out! People are going to watch something from someone', - // }, - // ], - // profileIcon: { - // src: 'https://picsum.photos/32', - // alt: 'Redirect profile photo', - // }, - // action: { - // url: 'https://example.com/', - // text: [ - // { - // type: 'text', - // text: 'Go Now', - // }, - // ], - // }, - // }, - // showtime: 5000, - // }; $: hasBanner = poll ?? pinned ?? redirect ?? (summary && $showChatSummary); let div: HTMLElement; let isAtBottom = true; From a3f479668c6a4813e52d1bf4f1c86db915d95ece Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Tue, 25 Feb 2025 14:56:21 -0800 Subject: [PATCH 6/6] Remove unnecessary $poll --- src/components/PollResults.svelte | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/PollResults.svelte b/src/components/PollResults.svelte index 81ac85a..a25a3c6 100644 --- a/src/components/PollResults.svelte +++ b/src/components/PollResults.svelte @@ -26,8 +26,6 @@ prevId = poll.actionId; } - $: poll; - const dispatch = createEventDispatcher(); $: dismissed, shorten, dispatch('resize');