Skip to content

Commit b5560cf

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 7ecb5c0 commit b5560cf

File tree

7 files changed

+167
-55
lines changed

7 files changed

+167
-55
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/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
@@ -30,6 +30,10 @@ export enum ChatUserActions {
3030
REPORT_USER = 'REPORT_USER',
3131
}
3232

33+
export enum ChatPollActions {
34+
END_POLL = 'END_POLL',
35+
}
36+
3337
export enum ChatReportUserOptions {
3438
UNWANTED_SPAM = 'UNWANTED_SPAM',
3539
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,
@@ -267,6 +273,15 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und
267273
src: fixUrl(baseRenderer.header.pollHeaderRenderer.thumbnail?.thumbnails[0].url ?? ''),
268274
alt: 'Poll profile icon'
269275
};
276+
// only allow action if all the relevant fields are present for it
277+
const buttonRenderer = baseRenderer.button?.buttonRenderer;
278+
const actionButton = buttonRenderer?.command?.commandMetadata?.webCommandMetadata?.apiUrl &&
279+
buttonRenderer?.text && 'simpleText' in buttonRenderer?.text &&
280+
buttonRenderer?.command?.liveChatActionEndpoint?.params && {
281+
api: buttonRenderer.command.commandMetadata.webCommandMetadata.apiUrl,
282+
text: buttonRenderer.text.simpleText,
283+
params: buttonRenderer.command.liveChatActionEndpoint.params
284+
} || undefined;
270285
// TODO implement 'selected' field? YT doesn't use it in results.
271286
return {
272287
type: 'poll',
@@ -283,6 +298,7 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und
283298
percentage: choice.votePercentage?.simpleText
284299
};
285300
}),
301+
action: actionButton
286302
}
287303
};
288304
}

src/ts/messaging.ts

Lines changed: 80 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Unsubscriber } from './queue';
22
import { ytcQueue } from './queue';
33
import sha1 from 'sha-1';
4-
import { chatReportUserOptions, ChatUserActions, ChatReportUserOptions } from '../ts/chat-constants';
4+
import { chatReportUserOptions, ChatUserActions, ChatReportUserOptions, ChatPollActions } from '../ts/chat-constants';
55

66
const currentDomain = location.protocol.includes('youtube') ? (location.protocol + '//' + location.host) : 'https://www.youtube.com';
77

@@ -179,6 +179,38 @@ const sendLtlMessage = (message: Chat.LtlMessage): void => {
179179
);
180180
};
181181

182+
function getCookie(name: string): string {
183+
const value = `; ${document.cookie}`;
184+
const parts = value.split(`; ${name}=`);
185+
if (parts.length === 2) return (parts.pop() ?? '').split(';').shift() ?? '';
186+
return '';
187+
}
188+
189+
function parseServiceEndpoint(baseContext: any, serviceEndpoint: any, prop: string): { params: string, context: any } {
190+
const { clickTrackingParams, [prop]: { params } } = serviceEndpoint;
191+
const clonedContext = JSON.parse(JSON.stringify(baseContext));
192+
clonedContext.clickTracking = {
193+
clickTrackingParams
194+
};
195+
return {
196+
params,
197+
context: clonedContext
198+
};
199+
}
200+
201+
const fetcher = async (...args: any[]): Promise<any> => {
202+
return await new Promise((resolve) => {
203+
const encoded = JSON.stringify(args);
204+
window.addEventListener('proxyFetchResponse', (e) => {
205+
const response = JSON.parse((e as CustomEvent).detail);
206+
resolve(response);
207+
});
208+
window.dispatchEvent(new CustomEvent('proxyFetchRequest', {
209+
detail: encoded
210+
}));
211+
});
212+
};
213+
182214
const executeChatAction = async (
183215
message: Ytc.ParsedMessage,
184216
ytcfg: YtCfg,
@@ -187,31 +219,13 @@ const executeChatAction = async (
187219
): Promise<void> => {
188220
if (message.params == null) return;
189221

190-
const fetcher = async (...args: any[]): Promise<any> => {
191-
return await new Promise((resolve) => {
192-
const encoded = JSON.stringify(args);
193-
window.addEventListener('proxyFetchResponse', (e) => {
194-
const response = JSON.parse((e as CustomEvent).detail);
195-
resolve(response);
196-
});
197-
window.dispatchEvent(new CustomEvent('proxyFetchRequest', {
198-
detail: encoded
199-
}));
200-
});
201-
};
202-
203222
let success = true;
204223
try {
205224
const apiKey = ytcfg.data_.INNERTUBE_API_KEY;
206225
const contextMenuUrl = `${currentDomain}/youtubei/v1/live_chat/get_item_context_menu?params=` +
207226
`${encodeURIComponent(message.params)}&pbj=1&key=${apiKey}&prettyPrint=false`;
208227
const baseContext = ytcfg.data_.INNERTUBE_CONTEXT;
209-
function getCookie(name: string): string {
210-
const value = `; ${document.cookie}`;
211-
const parts = value.split(`; ${name}=`);
212-
if (parts.length === 2) return (parts.pop() ?? '').split(';').shift() ?? '';
213-
return '';
214-
}
228+
215229
const time = Math.floor(Date.now() / 1000);
216230
const SAPISID = getCookie('__Secure-3PAPISID');
217231
const sha = sha1(`${time} ${SAPISID} ${currentDomain}`);
@@ -228,19 +242,9 @@ const executeChatAction = async (
228242
...heads,
229243
body: JSON.stringify({ context: baseContext })
230244
});
231-
function parseServiceEndpoint(serviceEndpoint: any, prop: string): { params: string, context: any } {
232-
const { clickTrackingParams, [prop]: { params } } = serviceEndpoint;
233-
const clonedContext = JSON.parse(JSON.stringify(baseContext));
234-
clonedContext.clickTracking = {
235-
clickTrackingParams
236-
};
237-
return {
238-
params,
239-
context: clonedContext
240-
};
241-
}
245+
242246
if (action === ChatUserActions.BLOCK) {
243-
const { params, context } = parseServiceEndpoint(
247+
const { params, context } = parseServiceEndpoint(baseContext,
244248
res.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[1]
245249
.menuNavigationItemRenderer.navigationEndpoint.confirmDialogEndpoint
246250
.content.confirmDialogRenderer.confirmButton.buttonRenderer.serviceEndpoint,
@@ -254,7 +258,7 @@ const executeChatAction = async (
254258
})
255259
});
256260
} else if (action === ChatUserActions.REPORT_USER) {
257-
const { params, context } = parseServiceEndpoint(
261+
const { params, context } = parseServiceEndpoint(baseContext,
258262
res.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0].menuServiceItemRenderer.serviceEndpoint,
259263
'getReportFormEndpoint'
260264
);
@@ -296,6 +300,46 @@ const executeChatAction = async (
296300
);
297301
};
298302

303+
const executePollAction = async (
304+
poll: Ytc.ParsedPoll,
305+
ytcfg: YtCfg,
306+
action: ChatPollActions,
307+
): Promise<void> => {
308+
try {
309+
const apiKey = ytcfg.data_.INNERTUBE_API_KEY;
310+
const baseContext = ytcfg.data_.INNERTUBE_CONTEXT;
311+
312+
const time = Math.floor(Date.now() / 1000);
313+
const SAPISID = getCookie('__Secure-3PAPISID');
314+
const sha = sha1(`${time} ${SAPISID} ${currentDomain}`);
315+
const auth = `SAPISIDHASH ${time}_${sha}`;
316+
const heads = {
317+
headers: {
318+
'Content-Type': 'application/json',
319+
Accept: '*/*',
320+
Authorization: auth
321+
},
322+
method: 'POST'
323+
};
324+
325+
if (action === ChatPollActions.END_POLL) {
326+
const params = poll.item.action?.params || '';
327+
const url = poll.item.action?.api || '/youtubei/v1/live_chat/live_chat_action';
328+
329+
// Call YouTube API to end the poll
330+
await fetcher(`${currentDomain}${url}?key=${apiKey}&prettyPrint=false`, {
331+
...heads,
332+
body: JSON.stringify({
333+
params,
334+
context: baseContext
335+
})
336+
});
337+
}
338+
} catch (e) {
339+
console.debug('Error executing poll action', e);
340+
}
341+
}
342+
299343
export const initInterceptor = (
300344
source: Chat.InterceptorSource,
301345
ytcfg: YtCfg,
@@ -335,6 +379,9 @@ export const initInterceptor = (
335379
case 'executeChatAction':
336380
executeChatAction(message.message, ytcfg, message.action, message.reportOption).catch(console.error);
337381
break;
382+
case 'executePollAction':
383+
executePollAction(message.poll, ytcfg, message.action).catch(console.error);
384+
break;
338385
case 'ping':
339386
port.postMessage({ type: 'ping' });
340387
break;

src/ts/typings/chat.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,17 @@ declare namespace Chat {
145145
reportOption?: ChatReportUserOptions;
146146
}
147147

148+
interface executePollActionMsg {
149+
type: 'executePollAction';
150+
poll: Ytc.ParsedPoll;
151+
action: ChatPollActions;
152+
}
153+
148154
type BackgroundMessage =
149155
RegisterInterceptorMsg | RegisterClientMsg | processJsonMsg |
150156
setInitialDataMsg | updatePlayerProgressMsg | setThemeMsg | getThemeMsg |
151-
RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg | chatUserActionResponse;
157+
RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg |
158+
executePollActionMsg | chatUserActionResponse;
152159

153160
type Port = Omit<chrome.runtime.Port, 'postMessage' | 'onMessage'> & {
154161
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)