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 {