diff --git a/src/components/ChatSummary.svelte b/src/components/ChatSummary.svelte index 35f46ad..5453cc7 100644 --- a/src/components/ChatSummary.svelte +++ b/src/components/ChatSummary.svelte @@ -5,6 +5,7 @@ import Icon from 'smelte/src/components/Icon'; import { Theme } from '../ts/chat-constants'; import { createEventDispatcher } from 'svelte'; + import { showTimestamps } from '../ts/storage'; export let summary: Ytc.ParsedSummary; @@ -55,6 +56,11 @@ {run.text} {/if} {/each} + {#if summary.timestamp && $showTimestamps} + + {summary.timestamp} + + {/if}
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/components/RedirectBanner.svelte b/src/components/RedirectBanner.svelte index d087727..6a2faaa 100644 --- a/src/components/RedirectBanner.svelte +++ b/src/components/RedirectBanner.svelte @@ -2,11 +2,11 @@ import { slide, fade } from 'svelte/transition'; import MessageRun from './MessageRuns.svelte'; import Tooltip from './common/Tooltip.svelte'; + import Button from 'smelte/src/components/Button'; import Icon from 'smelte/src/components/Icon'; import { Theme } from '../ts/chat-constants'; import { createEventDispatcher } from 'svelte'; - import { showProfileIcons } from '../ts/storage'; - import Button from 'smelte/src/components/Button'; + import { showProfileIcons, showTimestamps } from '../ts/storage'; export let redirect: Ytc.ParsedRedirect; @@ -53,6 +53,11 @@ Live Redirect Notice + {#if redirect.timestamp && $showTimestamps} + + {redirect.timestamp} + + {/if}
diff --git a/src/scripts/chat-background.ts b/src/scripts/chat-background.ts index 862d349..afc5e4d 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 e1aed30..3314e9c 100644 --- a/src/scripts/chat-interceptor.ts +++ b/src/scripts/chat-interceptor.ts @@ -1,7 +1,7 @@ import { fixLeaks } from '../ts/ytc-fix-memleaks'; import { frameIsReplay as isReplay, checkInjected } from '../ts/chat-utils'; import sha1 from 'sha-1'; -import { chatReportUserOptions, ChatUserActions, isLiveTL } from '../ts/chat-constants'; +import { chatReportUserOptions, ChatUserActions, ChatPollActions, isLiveTL } from '../ts/chat-constants'; function injectedFunction(): void { const currentDomain = (location.protocol + '//' + location.host); @@ -84,9 +84,69 @@ const chatLoaded = async (): Promise => { }); }; - // eslint-disable-next-line @typescript-eslint/no-misused-promises - port.onMessage.addListener(async (msg) => { - if (msg.type !== 'executeChatAction') return; + 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 + }; + } + + /** + * Executes a poll action (e.g., ending a poll) + */ + async function handlePollAction(msg: any, fetcher: (...args: any[]) => Promise): Promise { + try { + const currentDomain = (location.protocol + '//' + location.host); + 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} SAPISID1PHASH ${time}_${sha} SAPISID3PHASH ${time}_${sha}`; + const heads = { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + Authorization: auth + }, + method: 'POST' + }; + + if (msg.action === ChatPollActions.END_POLL) { + const poll = msg.poll; + const params = poll.item.action?.params || ''; + const url = poll.item.action?.url || '/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); + } + } + + /** + * Executes a chat action (e.g., blocking or reporting a user) + */ + async function handleChatAction(msg: any, fetcher: (...args: any[]) => Promise): Promise { const message = msg.message; if (message.params == null) return; let success = true; @@ -95,18 +155,12 @@ const chatLoaded = async (): Promise => { // const action = msg.action; 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; - 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}`); - const auth = `SAPISIDHASH ${time}_${sha}`; + const auth = `SAPISIDHASH ${time}_${sha} SAPISID1PHASH ${time}_${sha} SAPISID3PHASH ${time}_${sha}`; const heads = { headers: { 'Content-Type': 'application/json', @@ -119,19 +173,8 @@ const chatLoaded = async (): Promise => { ...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 (msg.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, @@ -145,7 +188,7 @@ const chatLoaded = async (): Promise => { }) }); } else if (msg.action === ChatUserActions.REPORT_USER) { - const { params, context } = parseServiceEndpoint( + const { params, context } = parseServiceEndpoint(baseContext, res.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0].menuServiceItemRenderer.serviceEndpoint, 'getReportFormEndpoint' ); @@ -182,6 +225,17 @@ const chatLoaded = async (): Promise => { message, success }); + } + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + port.onMessage.addListener(async (msg: any) => { + if (msg.type === 'executePollAction') { + return handlePollAction(msg, fetcher); + } + if (msg.type === 'executeChatAction') { + return handleChatAction(msg, fetcher); + } + return; }); }); 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 3525cee..6d9341b 100644 --- a/src/ts/chat-constants.ts +++ b/src/ts/chat-constants.ts @@ -51,6 +51,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 6ce4055..ae8c46b 100644 --- a/src/ts/chat-parser.ts +++ b/src/ts/chat-parser.ts @@ -103,6 +103,7 @@ const parseChatSummary = (renderer: Ytc.AddChatItem, actionId: string, showtime: message: splitRuns[2], }, showtime: showtime, + timestamp: formatTimestamp(Date.now() * 1000), }; return item; } @@ -116,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, @@ -128,10 +135,11 @@ 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, + timestamp: formatTimestamp(Date.now() * 1000), }; return item; } @@ -266,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', @@ -282,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 76525ef..25bfee3 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 1efecea..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 { @@ -489,6 +491,7 @@ declare namespace Ytc { message: ParsedRun[]; }; showtime: number; + timestamp?: string; } interface ParsedRedirect { @@ -503,6 +506,7 @@ declare namespace Ytc { } }; showtime: number; + timestamp?: string; } interface ParsedPoll { @@ -518,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 {