diff --git a/src/components/PollResults.svelte b/src/components/PollResults.svelte index a25a3c6..cd22f3e 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/ts/chat-actions.ts b/src/ts/chat-actions.ts index aa5a3d4..3bf190a 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 ceb953c..c89988d 100644 --- a/src/ts/chat-constants.ts +++ b/src/ts/chat-constants.ts @@ -30,6 +30,10 @@ export enum ChatUserActions { REPORT_USER = 'REPORT_USER', } +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 2e25a8f..bfa985f 100644 --- a/src/ts/chat-parser.ts +++ b/src/ts/chat-parser.ts @@ -118,10 +118,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, @@ -130,7 +136,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, @@ -269,6 +275,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', @@ -285,6 +300,7 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und percentage: choice.votePercentage?.simpleText }; }), + action: actionButton } }; } diff --git a/src/ts/messaging.ts b/src/ts/messaging.ts index dc393a1..f503a8d 100644 --- a/src/ts/messaging.ts +++ b/src/ts/messaging.ts @@ -1,7 +1,7 @@ import type { Unsubscriber } from './queue'; import { ytcQueue } from './queue'; import sha1 from 'sha-1'; -import { chatReportUserOptions, ChatUserActions, ChatReportUserOptions } from '../ts/chat-constants'; +import { chatReportUserOptions, ChatUserActions, ChatReportUserOptions, ChatPollActions } from '../ts/chat-constants'; const currentDomain = location.protocol.includes('youtube') ? (location.protocol + '//' + location.host) : 'https://www.youtube.com'; @@ -179,6 +179,38 @@ const sendLtlMessage = (message: Chat.LtlMessage): void => { ); }; +function getCookie(name: string): string { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return (parts.pop() ?? '').split(';').shift() ?? ''; + return ''; +} + +function parseServiceEndpoint(baseContext: any, serviceEndpoint: any, prop: string): { params: string, context: any } { + const { clickTrackingParams, [prop]: { params } } = serviceEndpoint; + const clonedContext = JSON.parse(JSON.stringify(baseContext)); + clonedContext.clickTracking = { + clickTrackingParams + }; + return { + params, + context: clonedContext + }; +} + +const fetcher = async (...args: any[]): Promise => { + return await new Promise((resolve) => { + const encoded = JSON.stringify(args); + window.addEventListener('proxyFetchResponse', (e) => { + const response = JSON.parse((e as CustomEvent).detail); + resolve(response); + }); + window.dispatchEvent(new CustomEvent('proxyFetchRequest', { + detail: encoded + })); + }); +}; + const executeChatAction = async ( message: Ytc.ParsedMessage, ytcfg: YtCfg, @@ -187,31 +219,13 @@ const executeChatAction = async ( ): Promise => { if (message.params == null) return; - const fetcher = async (...args: any[]): Promise => { - return await new Promise((resolve) => { - const encoded = JSON.stringify(args); - window.addEventListener('proxyFetchResponse', (e) => { - const response = JSON.parse((e as CustomEvent).detail); - resolve(response); - }); - window.dispatchEvent(new CustomEvent('proxyFetchRequest', { - detail: encoded - })); - }); - }; - let success = true; try { 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; - 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'); const sha = sha1(`${time} ${SAPISID} ${currentDomain}`); @@ -228,19 +242,9 @@ const executeChatAction = async ( ...heads, body: JSON.stringify({ context: baseContext }) }); - function parseServiceEndpoint(serviceEndpoint: any, prop: string): { params: string, context: any } { - const { clickTrackingParams, [prop]: { params } } = serviceEndpoint; - const clonedContext = JSON.parse(JSON.stringify(baseContext)); - clonedContext.clickTracking = { - clickTrackingParams - }; - return { - params, - context: clonedContext - }; - } + if (action === ChatUserActions.BLOCK) { - const { params, context } = parseServiceEndpoint( + const { params, context } = parseServiceEndpoint(baseContext, res.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[1] .menuNavigationItemRenderer.navigationEndpoint.confirmDialogEndpoint .content.confirmDialogRenderer.confirmButton.buttonRenderer.serviceEndpoint, @@ -254,7 +258,7 @@ const executeChatAction = async ( }) }); } else if (action === ChatUserActions.REPORT_USER) { - const { params, context } = parseServiceEndpoint( + const { params, context } = parseServiceEndpoint(baseContext, res.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0].menuServiceItemRenderer.serviceEndpoint, 'getReportFormEndpoint' ); @@ -296,6 +300,46 @@ const executeChatAction = async ( ); }; +const executePollAction = async ( + poll: Ytc.ParsedPoll, + ytcfg: YtCfg, + action: ChatPollActions, +): Promise => { + try { + const apiKey = ytcfg.data_.INNERTUBE_API_KEY; + const baseContext = ytcfg.data_.INNERTUBE_CONTEXT; + + const time = Math.floor(Date.now() / 1000); + const SAPISID = getCookie('__Secure-3PAPISID'); + const sha = sha1(`${time} ${SAPISID} ${currentDomain}`); + const auth = `SAPISIDHASH ${time}_${sha}`; + const heads = { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + Authorization: auth + }, + method: 'POST' + }; + + if (action === ChatPollActions.END_POLL) { + const params = poll.item.action?.params || ''; + const url = poll.item.action?.api || '/youtubei/v1/live_chat/live_chat_action'; + + // Call YouTube API to end the poll + await fetcher(`${currentDomain}${url}?key=${apiKey}&prettyPrint=false`, { + ...heads, + body: JSON.stringify({ + params, + context: baseContext + }) + }); + } + } catch (e) { + console.debug('Error executing poll action', e); + } +} + export const initInterceptor = ( source: Chat.InterceptorSource, ytcfg: YtCfg, @@ -335,6 +379,9 @@ export const initInterceptor = ( case 'executeChatAction': executeChatAction(message.message, ytcfg, message.action, message.reportOption).catch(console.error); break; + case 'executePollAction': + executePollAction(message.poll, ytcfg, message.action).catch(console.error); + break; case 'ping': port.postMessage({ type: 'ping' }); break; diff --git a/src/ts/typings/chat.d.ts b/src/ts/typings/chat.d.ts index 1b5bd58..2f786e4 100644 --- a/src/ts/typings/chat.d.ts +++ b/src/ts/typings/chat.d.ts @@ -145,10 +145,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 42804a9..8c1c54d 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 {