Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions src/components/PollResults.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -59,18 +61,20 @@
{/if}
{/each}
</div>
<div class="flex-none self-end" style="transform: translateY(3px);">
<Tooltip offsetY={0} small>
<Icon
slot="activator"
class="cursor-pointer text-lg"
on:click={() => { dismissed = true; }}
>
close
</Icon>
Dismiss
</Tooltip>
</div>
{#if !poll.item.action}
<div class="flex-none self-end" style="transform: translateY(3px);">
<Tooltip offsetY={0} small>
<Icon
slot="activator"
class="cursor-pointer text-lg"
on:click={() => { dismissed = true; }}
>
close
</Icon>
Dismiss
</Tooltip>
</div>
{/if}
</div>
{#if !shorten && !dismissed}
<div class="mt-1 inline-flex flex-row gap-2 break-words w-full overflow-visible" transition:slide|local={{ duration: 300 }}>
Expand All @@ -85,6 +89,15 @@
</div>
<ProgressLinear progress={(choice.ratio || 0.001) * 100} color="gray"/>
{/each}
{#if poll.item.action}
<div class="mt-1 whitespace-pre-line flex justify-end" transition:slide|global={{ duration: 300 }}>
<Button on:click={() => endPoll(poll, $port)} small>
<span forceDark forceTLColor={Theme.DARK} class="cursor-pointer">
{poll.item.action.text}
</span>
</Button>
</div>
{/if}
{/if}
</div>
{/if}
21 changes: 20 additions & 1 deletion src/ts/chat-actions.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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
});
}
4 changes: 4 additions & 0 deletions src/ts/chat-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
24 changes: 20 additions & 4 deletions src/ts/chat-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -285,6 +300,7 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und
percentage: choice.votePercentage?.simpleText
};
}),
action: actionButton
}
};
}
Expand Down
113 changes: 80 additions & 33 deletions src/ts/messaging.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<any> => {
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,
Expand All @@ -187,31 +219,13 @@ const executeChatAction = async (
): Promise<void> => {
if (message.params == null) return;

const fetcher = async (...args: any[]): Promise<any> => {
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}`);
Expand All @@ -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,
Expand All @@ -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'
);
Expand Down Expand Up @@ -296,6 +300,46 @@ const executeChatAction = async (
);
};

const executePollAction = async (
poll: Ytc.ParsedPoll,
ytcfg: YtCfg,
action: ChatPollActions,
): Promise<void> => {
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,
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion src/ts/typings/chat.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<chrome.runtime.Port, 'postMessage' | 'onMessage'> & {
postMessage: (message: BackgroundMessage | BackgroundResponse) => void;
Expand Down
12 changes: 9 additions & 3 deletions src/ts/typings/ytc.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ declare namespace Ytc {
icon?: string;
accessibility?: AccessibilityObj;
isDisabled?: boolean;
text?: RunsObj; // | SimpleTextObj;
text?: RunsObj | SimpleTextObj;
command: {
commandMetadata?: {
webCommandMetadata?: {
Expand Down Expand Up @@ -315,7 +315,9 @@ declare namespace Ytc {
}
}
displayVoteResults?: boolean;
button?: ButtonRenderer;
button?: {
buttonRenderer: ButtonRenderer;
}
}

interface PollChoice {
Expand Down Expand Up @@ -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 {
Expand Down
Loading