Skip to content

Commit 2a073b2

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 1722cfc commit 2a073b2

File tree

8 files changed

+175
-45
lines changed

8 files changed

+175
-45
lines changed

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
@@ -116,10 +116,16 @@ const parseRedirectBanner = (renderer: Ytc.AddChatItem, actionId: string, showti
116116
src: fixUrl(baseRenderer.authorPhoto?.thumbnails[0].url ?? ''),
117117
alt: 'Redirect profile icon'
118118
};
119-
const url = baseRenderer.inlineActionButton?.buttonRenderer.command.urlEndpoint?.url ||
120-
(baseRenderer.inlineActionButton?.buttonRenderer.command.watchEndpoint?.videoId ?
121-
"/watch?v=" + baseRenderer.inlineActionButton?.buttonRenderer.command.watchEndpoint?.videoId
119+
const buttonRenderer = baseRenderer.inlineActionButton?.buttonRenderer;
120+
const url = buttonRenderer?.command.urlEndpoint?.url ||
121+
(buttonRenderer?.command.watchEndpoint?.videoId ?
122+
"/watch?v=" + buttonRenderer?.command.watchEndpoint?.videoId
122123
: '');
124+
const buttonRendererText = buttonRenderer?.text;
125+
const buttonText = buttonRendererText && (
126+
('runs' in buttonRendererText && parseMessageRuns(buttonRendererText.runs))
127+
|| ('simpleText' in buttonRendererText && [{ type: 'text', text: buttonRendererText.simpleText }] as Ytc.ParsedTextRun[])
128+
) || [];
123129
const item: Ytc.ParsedRedirect = {
124130
type: 'redirect',
125131
actionId: actionId,
@@ -128,7 +134,7 @@ const parseRedirectBanner = (renderer: Ytc.AddChatItem, actionId: string, showti
128134
profileIcon: profileIcon,
129135
action: {
130136
url: fixUrl(url),
131-
text: parseMessageRuns(baseRenderer.inlineActionButton?.buttonRenderer.text?.runs),
137+
text: buttonText,
132138
}
133139
},
134140
showtime: showtime,
@@ -266,6 +272,15 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und
266272
src: fixUrl(baseRenderer.header.pollHeaderRenderer.thumbnail?.thumbnails[0].url ?? ''),
267273
alt: 'Poll profile icon'
268274
};
275+
// only allow action if all the relevant fields are present for it
276+
const buttonRenderer = baseRenderer.button?.buttonRenderer;
277+
const actionButton = buttonRenderer?.command?.commandMetadata?.webCommandMetadata?.apiUrl &&
278+
buttonRenderer?.text && 'simpleText' in buttonRenderer?.text &&
279+
buttonRenderer?.command?.liveChatActionEndpoint?.params && {
280+
api: buttonRenderer.command.commandMetadata.webCommandMetadata.apiUrl,
281+
text: buttonRenderer.text.simpleText,
282+
params: buttonRenderer.command.liveChatActionEndpoint.params
283+
} || undefined;
269284
// TODO implement 'selected' field? YT doesn't use it in results.
270285
return {
271286
type: 'poll',
@@ -282,6 +297,7 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und
282297
percentage: choice.votePercentage?.simpleText
283298
};
284299
}),
300+
action: actionButton
285301
}
286302
};
287303
}

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 {
@@ -518,8 +520,12 @@ declare namespace Ytc {
518520
ratio?: number;
519521
percentage?: string;
520522
}>;
523+
action?: {
524+
api: string;
525+
params: string;
526+
text: string;
527+
}
521528
}
522-
// TODO add 'action' for ending poll button
523529
}
524530

525531
interface ParsedRemoveBanner {

0 commit comments

Comments
 (0)