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 {