diff --git a/src/components/PollResults.svelte b/src/components/PollResults.svelte index a25a3c6e..cd22f3e5 100644 --- a/src/components/PollResults.svelte +++ b/src/components/PollResults.svelte @@ -5,8 +5,10 @@ import Icon from 'smelte/src/components/Icon'; import { Theme } from '../ts/chat-constants'; import { createEventDispatcher } from 'svelte'; - import { showProfileIcons } from '../ts/storage'; + import { port, showProfileIcons } from '../ts/storage'; import ProgressLinear from 'smelte/src/components/ProgressLinear'; + import { endPoll } from '../ts/chat-actions'; + import Button from 'smelte/src/components/Button'; export let poll: Ytc.ParsedPoll; @@ -59,18 +61,20 @@ {/if} {/each} -
- - { dismissed = true; }} - > - close - - Dismiss - -
+ {#if !poll.item.action} +
+ + { dismissed = true; }} + > + close + + Dismiss + +
+ {/if} {#if !shorten && !dismissed}
@@ -85,6 +89,15 @@
{/each} + {#if poll.item.action} +
+ +
+ {/if} {/if} {/if} diff --git a/src/scripts/chat-background.ts b/src/scripts/chat-background.ts index 862d3496..afc5e4d9 100644 --- a/src/scripts/chat-background.ts +++ b/src/scripts/chat-background.ts @@ -373,6 +373,14 @@ const executeChatAction = ( interceptor?.port?.postMessage(message); }; +const executePollAction = ( + port: Chat.Port, + message: Chat.executePollActionMsg +): void => { + const interceptor = findInterceptorFromClient(port); + interceptor?.port?.postMessage(message); +}; + const sendChatUserActionResponse = ( port: Chat.Port, message: Chat.chatUserActionResponse @@ -418,6 +426,9 @@ chrome.runtime.onConnect.addListener((port) => { case 'executeChatAction': executeChatAction(port, message); break; + case 'executePollAction': + executePollAction(port, message); + break; case 'chatUserActionResponse': sendChatUserActionResponse(port, message); break; diff --git a/src/scripts/chat-interceptor.ts b/src/scripts/chat-interceptor.ts index 4dda76a6..75afa3ce 100644 --- a/src/scripts/chat-interceptor.ts +++ b/src/scripts/chat-interceptor.ts @@ -1,6 +1,6 @@ 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, ChatPollActions, isLiveTL } from '../ts/chat-constants'; import sha1 from 'sha-1'; function injectedFunction(): void { @@ -112,15 +112,51 @@ const chatLoaded = async (): Promise => { }); }; + const getCookie = (name: string): string => { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return (parts.pop() ?? '').split(';').shift() ?? ''; + return ''; + }; + + async function handlePollAction(msg: any): Promise { + try { + if (msg.action !== ChatPollActions.END_POLL) return; + const poll = msg.poll; + const params = poll.item.action?.params; + const apiPath = poll.item.action?.api || '/youtubei/v1/live_chat/live_chat_action'; + if (!params) return; + const currentDomain = (location.protocol + '//' + location.host); + const baseContext = ytcfg.data_.INNERTUBE_CONTEXT; + 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 pageId = (ytcfg as any)?.data_?.DELEGATED_SESSION_ID as string | undefined; + await fetcher(`${currentDomain}${apiPath}?prettyPrint=false`, { + headers: { + ...(authuser != null ? { 'X-Goog-AuthUser': String(authuser) } : {}), + ...(pageId ? { 'X-Goog-PageId': pageId } : {}), + ...(auth != null ? { Authorization: auth } : {}) + }, + method: 'POST' as const, + mode: 'same-origin' as const, + body: JSON.stringify({ + context: baseContext, + params + }) + }); + } catch (e) { + console.debug('Error executing poll action', e); + } + } + // eslint-disable-next-line @typescript-eslint/no-misused-promises port.onMessage.addListener(async (msg) => { - const getCookie = (name: string): string => { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return (parts.pop() ?? '').split(';').shift() ?? ''; - return ''; - }; - + if (msg.type === 'executePollAction') { + await handlePollAction(msg); + return; + } if (msg.type !== 'executeChatAction') return; const message = msg.message; const debugAction = msg.action === ChatUserActions.DELETE_MESSAGE; @@ -135,7 +171,7 @@ const chatLoaded = async (): Promise => { 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`; + `${encodeURIComponent(message.params)}&pbj=1&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. @@ -147,6 +183,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 pageId = (ytcfg as any)?.data_?.DELEGATED_SESSION_ID as string | undefined; const heads = { headers: { 'Content-Type': 'application/json', @@ -155,6 +192,7 @@ const chatLoaded = async (): Promise => { ...(visitorId != null ? { 'X-Goog-Visitor-Id': String(visitorId) } : {}), ...(clientName != null ? { 'X-Youtube-Client-Name': String(clientName) } : {}), ...(clientVersion != null ? { 'X-Youtube-Client-Version': String(clientVersion) } : {}), + ...(pageId ? { 'X-Goog-PageId': pageId } : {}), 'X-Origin': currentDomain, ...(auth != null ? { Authorization: auth } : {}) }, diff --git a/src/ts/chat-actions.ts b/src/ts/chat-actions.ts index 087f6f6a..660cb65b 100644 --- a/src/ts/chat-actions.ts +++ b/src/ts/chat-actions.ts @@ -1,5 +1,5 @@ import { writable } from 'svelte/store'; -import { ChatReportUserOptions, ChatUserActions } from './chat-constants'; +import { ChatReportUserOptions, ChatUserActions, ChatPollActions } from './chat-constants'; import { reportDialog } from './storage'; export function useBanHammer( @@ -28,3 +28,22 @@ export function useBanHammer( }); } } + +/** + * Ends a poll that is currently active in the live chat + * @param poll The ParsedPoll object containing information about the poll to end + * @param port The port to communicate with the background script + */ +export function endPoll( + poll: Ytc.ParsedPoll, + port: Chat.Port | null +): void { + if (!port) return; + + // Use a dedicated executePollAction message type for poll operations + port?.postMessage({ + type: 'executePollAction', + poll, + action: ChatPollActions.END_POLL + }); +} diff --git a/src/ts/chat-constants.ts b/src/ts/chat-constants.ts index 22f67427..fd29bdc8 100644 --- a/src/ts/chat-constants.ts +++ b/src/ts/chat-constants.ts @@ -52,6 +52,10 @@ export enum ChatUserActions { DELETE_MESSAGE = 'DELETE_MESSAGE', } +export enum ChatPollActions { + END_POLL = 'END_POLL', +} + export enum ChatReportUserOptions { UNWANTED_SPAM = 'UNWANTED_SPAM', PORN_OR_SEX = 'PORN_OR_SEX', diff --git a/src/ts/chat-parser.ts b/src/ts/chat-parser.ts index 4eed8320..ae8c46b8 100644 --- a/src/ts/chat-parser.ts +++ b/src/ts/chat-parser.ts @@ -117,10 +117,16 @@ const parseRedirectBanner = (renderer: Ytc.AddChatItem, actionId: string, showti src: fixUrl(baseRenderer.authorPhoto?.thumbnails[0].url ?? ''), alt: 'Redirect profile icon' }; - const url = baseRenderer.inlineActionButton?.buttonRenderer.command.urlEndpoint?.url || - (baseRenderer.inlineActionButton?.buttonRenderer.command.watchEndpoint?.videoId ? - "/watch?v=" + baseRenderer.inlineActionButton?.buttonRenderer.command.watchEndpoint?.videoId + const buttonRenderer = baseRenderer.inlineActionButton?.buttonRenderer; + const url = buttonRenderer?.command.urlEndpoint?.url || + (buttonRenderer?.command.watchEndpoint?.videoId ? + "/watch?v=" + buttonRenderer?.command.watchEndpoint?.videoId : ''); + const buttonRendererText = buttonRenderer?.text; + const buttonText = buttonRendererText && ( + ('runs' in buttonRendererText && parseMessageRuns(buttonRendererText.runs)) + || ('simpleText' in buttonRendererText && [{ type: 'text', text: buttonRendererText.simpleText }] as Ytc.ParsedTextRun[]) + ) || []; const item: Ytc.ParsedRedirect = { type: 'redirect', actionId: actionId, @@ -129,7 +135,7 @@ const parseRedirectBanner = (renderer: Ytc.AddChatItem, actionId: string, showti profileIcon: profileIcon, action: { url: fixUrl(url), - text: parseMessageRuns(baseRenderer.inlineActionButton?.buttonRenderer.text?.runs), + text: buttonText, } }, showtime: showtime, @@ -268,6 +274,15 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und src: fixUrl(baseRenderer.header.pollHeaderRenderer.thumbnail?.thumbnails[0].url ?? ''), alt: 'Poll profile icon' }; + // only allow action if all the relevant fields are present for it + const buttonRenderer = baseRenderer.button?.buttonRenderer; + const actionButton = buttonRenderer?.command?.commandMetadata?.webCommandMetadata?.apiUrl && + buttonRenderer?.text && 'simpleText' in buttonRenderer?.text && + buttonRenderer?.command?.liveChatActionEndpoint?.params && { + api: buttonRenderer.command.commandMetadata.webCommandMetadata.apiUrl, + text: buttonRenderer.text.simpleText, + params: buttonRenderer.command.liveChatActionEndpoint.params + } || undefined; // TODO implement 'selected' field? YT doesn't use it in results. return { type: 'poll', @@ -284,6 +299,7 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und percentage: choice.votePercentage?.simpleText }; }), + action: actionButton } }; } diff --git a/src/ts/typings/chat.d.ts b/src/ts/typings/chat.d.ts index 76525eff..25bfee31 100644 --- a/src/ts/typings/chat.d.ts +++ b/src/ts/typings/chat.d.ts @@ -139,10 +139,17 @@ declare namespace Chat { reportOption?: ChatReportUserOptions; } + interface executePollActionMsg { + type: 'executePollAction'; + poll: Ytc.ParsedPoll; + action: ChatPollActions; + } + type BackgroundMessage = RegisterInterceptorMsg | RegisterClientMsg | processJsonMsg | setInitialDataMsg | updatePlayerProgressMsg | setThemeMsg | getThemeMsg | - RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg | chatUserActionResponse; + RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg | + executePollActionMsg | chatUserActionResponse; 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..8c1c54d1 100644 --- a/src/ts/typings/ytc.d.ts +++ b/src/ts/typings/ytc.d.ts @@ -282,7 +282,7 @@ declare namespace Ytc { icon?: string; accessibility?: AccessibilityObj; isDisabled?: boolean; - text?: RunsObj; // | SimpleTextObj; + text?: RunsObj | SimpleTextObj; command: { commandMetadata?: { webCommandMetadata?: { @@ -315,7 +315,9 @@ declare namespace Ytc { } } displayVoteResults?: boolean; - button?: ButtonRenderer; + button?: { + buttonRenderer: ButtonRenderer; + } } interface PollChoice { @@ -520,8 +522,12 @@ declare namespace Ytc { ratio?: number; percentage?: string; }>; + action?: { + api: string; + params: string; + text: string; + } } - // TODO add 'action' for ending poll button } interface ParsedRemoveBanner {