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 {