Skip to content

Commit a6a8ca4

Browse files
committed
Handle End Poll button, do not allow poll owner to close the poll banner so they can always end it
1 parent 4e7f708 commit a6a8ca4

8 files changed

Lines changed: 175 additions & 45 deletions

File tree

src/components/PollResults.svelte

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import Icon from 'smelte/src/components/Icon';
66
import { Theme } from '../ts/chat-constants';
77
import { createEventDispatcher } from 'svelte';
8-
import { showProfileIcons } from '../ts/storage';
8+
import { port, showProfileIcons } from '../ts/storage';
99
import ProgressLinear from 'smelte/src/components/ProgressLinear';
10+
import { endPoll } from '../ts/chat-actions';
11+
import Button from 'smelte/src/components/Button';
1012
1113
export let poll: Ytc.ParsedPoll;
1214
@@ -59,18 +61,20 @@
5961
{/if}
6062
{/each}
6163
</div>
62-
<div class="flex-none self-end" style="transform: translateY(3px);">
63-
<Tooltip offsetY={0} small>
64-
<Icon
65-
slot="activator"
66-
class="cursor-pointer text-lg"
67-
on:click={() => { dismissed = true; }}
68-
>
69-
close
70-
</Icon>
71-
Dismiss
72-
</Tooltip>
73-
</div>
64+
{#if !poll.item.action}
65+
<div class="flex-none self-end" style="transform: translateY(3px);">
66+
<Tooltip offsetY={0} small>
67+
<Icon
68+
slot="activator"
69+
class="cursor-pointer text-lg"
70+
on:click={() => { dismissed = true; }}
71+
>
72+
close
73+
</Icon>
74+
Dismiss
75+
</Tooltip>
76+
</div>
77+
{/if}
7478
</div>
7579
{#if !shorten && !dismissed}
7680
<div class="mt-1 inline-flex flex-row gap-2 break-words w-full overflow-visible" transition:slide|local={{ duration: 300 }}>
@@ -85,6 +89,15 @@
8589
</div>
8690
<ProgressLinear progress={(choice.ratio || 0.001) * 100} color="gray"/>
8791
{/each}
92+
{#if poll.item.action}
93+
<div class="mt-1 whitespace-pre-line flex justify-end" transition:slide|global={{ duration: 300 }}>
94+
<Button on:click={() => endPoll(poll, $port)} small>
95+
<span forceDark forceTLColor={Theme.DARK} class="cursor-pointer">
96+
{poll.item.action.text}
97+
</span>
98+
</Button>
99+
</div>
100+
{/if}
88101
{/if}
89102
</div>
90103
{/if}

src/scripts/chat-background.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,14 @@ const executeChatAction = (
373373
interceptor?.port?.postMessage(message);
374374
};
375375

376+
const executePollAction = (
377+
port: Chat.Port,
378+
message: Chat.executePollActionMsg
379+
): void => {
380+
const interceptor = findInterceptorFromClient(port);
381+
interceptor?.port?.postMessage(message);
382+
};
383+
376384
const sendChatUserActionResponse = (
377385
port: Chat.Port,
378386
message: Chat.chatUserActionResponse
@@ -418,6 +426,9 @@ chrome.runtime.onConnect.addListener((port) => {
418426
case 'executeChatAction':
419427
executeChatAction(port, message);
420428
break;
429+
case 'executePollAction':
430+
executePollAction(port, message);
431+
break;
421432
case 'chatUserActionResponse':
422433
sendChatUserActionResponse(port, message);
423434
break;

src/scripts/chat-interceptor.ts

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { fixLeaks } from '../ts/ytc-fix-memleaks';
22
import { frameIsReplay as isReplay, checkInjected } from '../ts/chat-utils';
33
import sha1 from 'sha-1';
4-
import { chatReportUserOptions, ChatUserActions, isLiveTL } from '../ts/chat-constants';
4+
import { chatReportUserOptions, ChatUserActions, ChatPollActions, isLiveTL } from '../ts/chat-constants';
55

66
function injectedFunction(): void {
77
const currentDomain = (location.protocol + '//' + location.host);
@@ -84,9 +84,69 @@ const chatLoaded = async (): Promise<void> => {
8484
});
8585
};
8686

87-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
88-
port.onMessage.addListener(async (msg) => {
89-
if (msg.type !== 'executeChatAction') return;
87+
function getCookie(name: string): string {
88+
const value = `; ${document.cookie}`;
89+
const parts = value.split(`; ${name}=`);
90+
if (parts.length === 2) return (parts.pop() ?? '').split(';').shift() ?? '';
91+
return '';
92+
}
93+
94+
function parseServiceEndpoint(baseContext: any, serviceEndpoint: any, prop: string): { params: string, context: any } {
95+
const { clickTrackingParams, [prop]: { params } } = serviceEndpoint;
96+
const clonedContext = JSON.parse(JSON.stringify(baseContext));
97+
clonedContext.clickTracking = {
98+
clickTrackingParams
99+
};
100+
return {
101+
params,
102+
context: clonedContext
103+
};
104+
}
105+
106+
/**
107+
* Executes a poll action (e.g., ending a poll)
108+
*/
109+
async function handlePollAction(msg: any, fetcher: (...args: any[]) => Promise<any>): Promise<void> {
110+
try {
111+
const currentDomain = (location.protocol + '//' + location.host);
112+
const apiKey = ytcfg.data_.INNERTUBE_API_KEY;
113+
const baseContext = ytcfg.data_.INNERTUBE_CONTEXT;
114+
const time = Math.floor(Date.now() / 1000);
115+
const SAPISID = getCookie('__Secure-3PAPISID');
116+
const sha = sha1(`${time} ${SAPISID} ${currentDomain}`);
117+
const auth = `SAPISIDHASH ${time}_${sha}`;
118+
const heads = {
119+
headers: {
120+
'Content-Type': 'application/json',
121+
Accept: '*/*',
122+
Authorization: auth
123+
},
124+
method: 'POST'
125+
};
126+
127+
if (msg.action === ChatPollActions.END_POLL) {
128+
const poll = msg.poll;
129+
const params = poll.item.action?.params || '';
130+
const url = poll.item.action?.url || '/youtubei/v1/live_chat/live_chat_action';
131+
132+
// Call YouTube API to end the poll
133+
await fetcher(`${currentDomain}${url}?key=${apiKey}&prettyPrint=false`, {
134+
...heads,
135+
body: JSON.stringify({
136+
params,
137+
context: baseContext
138+
})
139+
});
140+
}
141+
} catch (e) {
142+
console.debug('Error executing poll action', e);
143+
}
144+
}
145+
146+
/**
147+
* Executes a chat action (e.g., blocking or reporting a user)
148+
*/
149+
async function handleChatAction(msg: any, fetcher: (...args: any[]) => Promise<any>): Promise<void> {
90150
const message = msg.message;
91151
if (message.params == null) return;
92152
let success = true;
@@ -97,12 +157,6 @@ const chatLoaded = async (): Promise<void> => {
97157
const contextMenuUrl = `${currentDomain}/youtubei/v1/live_chat/get_item_context_menu?params=` +
98158
`${encodeURIComponent(message.params)}&pbj=1&key=${apiKey}&prettyPrint=false`;
99159
const baseContext = ytcfg.data_.INNERTUBE_CONTEXT;
100-
function getCookie(name: string): string {
101-
const value = `; ${document.cookie}`;
102-
const parts = value.split(`; ${name}=`);
103-
if (parts.length === 2) return (parts.pop() ?? '').split(';').shift() ?? '';
104-
return '';
105-
}
106160
const time = Math.floor(Date.now() / 1000);
107161
const SAPISID = getCookie('__Secure-3PAPISID');
108162
const sha = sha1(`${time} ${SAPISID} ${currentDomain}`);
@@ -119,19 +173,8 @@ const chatLoaded = async (): Promise<void> => {
119173
...heads,
120174
body: JSON.stringify({ context: baseContext })
121175
});
122-
function parseServiceEndpoint(serviceEndpoint: any, prop: string): { params: string, context: any } {
123-
const { clickTrackingParams, [prop]: { params } } = serviceEndpoint;
124-
const clonedContext = JSON.parse(JSON.stringify(baseContext));
125-
clonedContext.clickTracking = {
126-
clickTrackingParams
127-
};
128-
return {
129-
params,
130-
context: clonedContext
131-
};
132-
}
133176
if (msg.action === ChatUserActions.BLOCK) {
134-
const { params, context } = parseServiceEndpoint(
177+
const { params, context } = parseServiceEndpoint(baseContext,
135178
res.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[1]
136179
.menuNavigationItemRenderer.navigationEndpoint.confirmDialogEndpoint
137180
.content.confirmDialogRenderer.confirmButton.buttonRenderer.serviceEndpoint,
@@ -145,7 +188,7 @@ const chatLoaded = async (): Promise<void> => {
145188
})
146189
});
147190
} else if (msg.action === ChatUserActions.REPORT_USER) {
148-
const { params, context } = parseServiceEndpoint(
191+
const { params, context } = parseServiceEndpoint(baseContext,
149192
res.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0].menuServiceItemRenderer.serviceEndpoint,
150193
'getReportFormEndpoint'
151194
);
@@ -182,6 +225,17 @@ const chatLoaded = async (): Promise<void> => {
182225
message,
183226
success
184227
});
228+
}
229+
230+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
231+
port.onMessage.addListener(async (msg: any) => {
232+
if (msg.type === 'executePollAction') {
233+
return handlePollAction(msg, fetcher);
234+
}
235+
if (msg.type === 'executeChatAction') {
236+
return handleChatAction(msg, fetcher);
237+
}
238+
return;
185239
});
186240
});
187241

src/ts/chat-actions.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { writable } from 'svelte/store';
2-
import { ChatReportUserOptions, ChatUserActions } from './chat-constants';
2+
import { ChatReportUserOptions, ChatUserActions, ChatPollActions } from './chat-constants';
33
import { reportDialog } from './storage';
44

55
export function useBanHammer(
@@ -28,3 +28,22 @@ export function useBanHammer(
2828
});
2929
}
3030
}
31+
32+
/**
33+
* Ends a poll that is currently active in the live chat
34+
* @param poll The ParsedPoll object containing information about the poll to end
35+
* @param port The port to communicate with the background script
36+
*/
37+
export function endPoll(
38+
poll: Ytc.ParsedPoll,
39+
port: Chat.Port | null
40+
): void {
41+
if (!port) return;
42+
43+
// Use a dedicated executePollAction message type for poll operations
44+
port?.postMessage({
45+
type: 'executePollAction',
46+
poll,
47+
action: ChatPollActions.END_POLL
48+
});
49+
}

src/ts/chat-constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export enum ChatUserActions {
5151
REPORT_USER = 'REPORT_USER',
5252
}
5353

54+
export enum ChatPollActions {
55+
END_POLL = 'END_POLL',
56+
}
57+
5458
export enum ChatReportUserOptions {
5559
UNWANTED_SPAM = 'UNWANTED_SPAM',
5660
PORN_OR_SEX = 'PORN_OR_SEX',

src/ts/chat-parser.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,16 @@ const parseRedirectBanner = (renderer: Ytc.AddChatItem, actionId: string, showti
117117
src: fixUrl(baseRenderer.authorPhoto?.thumbnails[0].url ?? ''),
118118
alt: 'Redirect profile icon'
119119
};
120-
const url = baseRenderer.inlineActionButton?.buttonRenderer.command.urlEndpoint?.url ||
121-
(baseRenderer.inlineActionButton?.buttonRenderer.command.watchEndpoint?.videoId ?
122-
"/watch?v=" + baseRenderer.inlineActionButton?.buttonRenderer.command.watchEndpoint?.videoId
120+
const buttonRenderer = baseRenderer.inlineActionButton?.buttonRenderer;
121+
const url = buttonRenderer?.command.urlEndpoint?.url ||
122+
(buttonRenderer?.command.watchEndpoint?.videoId ?
123+
"/watch?v=" + buttonRenderer?.command.watchEndpoint?.videoId
123124
: '');
125+
const buttonRendererText = buttonRenderer?.text;
126+
const buttonText = buttonRendererText && (
127+
('runs' in buttonRendererText && parseMessageRuns(buttonRendererText.runs))
128+
|| ('simpleText' in buttonRendererText && [{ type: 'text', text: buttonRendererText.simpleText }] as Ytc.ParsedTextRun[])
129+
) || [];
124130
const item: Ytc.ParsedRedirect = {
125131
type: 'redirect',
126132
actionId: actionId,
@@ -129,7 +135,7 @@ const parseRedirectBanner = (renderer: Ytc.AddChatItem, actionId: string, showti
129135
profileIcon: profileIcon,
130136
action: {
131137
url: fixUrl(url),
132-
text: parseMessageRuns(baseRenderer.inlineActionButton?.buttonRenderer.text?.runs),
138+
text: buttonText,
133139
}
134140
},
135141
showtime: showtime,
@@ -268,6 +274,15 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und
268274
src: fixUrl(baseRenderer.header.pollHeaderRenderer.thumbnail?.thumbnails[0].url ?? ''),
269275
alt: 'Poll profile icon'
270276
};
277+
// only allow action if all the relevant fields are present for it
278+
const buttonRenderer = baseRenderer.button?.buttonRenderer;
279+
const actionButton = buttonRenderer?.command?.commandMetadata?.webCommandMetadata?.apiUrl &&
280+
buttonRenderer?.text && 'simpleText' in buttonRenderer?.text &&
281+
buttonRenderer?.command?.liveChatActionEndpoint?.params && {
282+
api: buttonRenderer.command.commandMetadata.webCommandMetadata.apiUrl,
283+
text: buttonRenderer.text.simpleText,
284+
params: buttonRenderer.command.liveChatActionEndpoint.params
285+
} || undefined;
271286
// TODO implement 'selected' field? YT doesn't use it in results.
272287
return {
273288
type: 'poll',
@@ -284,6 +299,7 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und
284299
percentage: choice.votePercentage?.simpleText
285300
};
286301
}),
302+
action: actionButton
287303
}
288304
};
289305
}

src/ts/typings/chat.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,17 @@ declare namespace Chat {
139139
reportOption?: ChatReportUserOptions;
140140
}
141141

142+
interface executePollActionMsg {
143+
type: 'executePollAction';
144+
poll: Ytc.ParsedPoll;
145+
action: ChatPollActions;
146+
}
147+
142148
type BackgroundMessage =
143149
RegisterInterceptorMsg | RegisterClientMsg | processJsonMsg |
144150
setInitialDataMsg | updatePlayerProgressMsg | setThemeMsg | getThemeMsg |
145-
RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg | chatUserActionResponse;
151+
RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg |
152+
executePollActionMsg | chatUserActionResponse;
146153

147154
type Port = Omit<chrome.runtime.Port, 'postMessage' | 'onMessage'> & {
148155
postMessage: (message: BackgroundMessage | BackgroundResponse) => void;

src/ts/typings/ytc.d.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ declare namespace Ytc {
282282
icon?: string;
283283
accessibility?: AccessibilityObj;
284284
isDisabled?: boolean;
285-
text?: RunsObj; // | SimpleTextObj;
285+
text?: RunsObj | SimpleTextObj;
286286
command: {
287287
commandMetadata?: {
288288
webCommandMetadata?: {
@@ -315,7 +315,9 @@ declare namespace Ytc {
315315
}
316316
}
317317
displayVoteResults?: boolean;
318-
button?: ButtonRenderer;
318+
button?: {
319+
buttonRenderer: ButtonRenderer;
320+
}
319321
}
320322

321323
interface PollChoice {
@@ -520,8 +522,12 @@ declare namespace Ytc {
520522
ratio?: number;
521523
percentage?: string;
522524
}>;
525+
action?: {
526+
api: string;
527+
params: string;
528+
text: string;
529+
}
523530
}
524-
// TODO add 'action' for ending poll button
525531
}
526532

527533
interface ParsedRemoveBanner {

0 commit comments

Comments
 (0)