diff --git a/src/components/AlertDialog.svelte b/src/components/AlertDialog.svelte new file mode 100644 index 00000000..fbf71759 --- /dev/null +++ b/src/components/AlertDialog.svelte @@ -0,0 +1,18 @@ + + + {$alertDialog?.title} +
+ {$alertDialog?.message} +
+
+ +
+
diff --git a/src/components/GiftedMembershipToggle.svelte b/src/components/GiftedMembershipToggle.svelte new file mode 100644 index 00000000..a5d89ee4 --- /dev/null +++ b/src/components/GiftedMembershipToggle.svelte @@ -0,0 +1,21 @@ + + +{#if isYtFrame} +
{ + toggleMembershipGifting($port); + }} + style="transform: translateX(3.5px);" + class="rounded-full flex justify-center items-center cursor-pointer w-8 h-8"> + + + +
+{/if} diff --git a/src/components/Hyperchat.svelte b/src/components/Hyperchat.svelte index e178078d..c3da3536 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -9,6 +9,7 @@ import PaidMessage from './PaidMessage.svelte'; import MembershipItem from './MembershipItem.svelte'; import ReportBanDialog from './ReportBanDialog.svelte'; + import AlertDialog from './AlertDialog.svelte'; import SuperchatViewDialog from './SuperchatViewDialog.svelte'; import StickyBar from './StickyBar.svelte'; import { @@ -235,6 +236,15 @@ ); } break; + case 'toggleMembershipGiftingResponse': + if (!response.success) { + $alertDialog = { + title: 'Error', + message: "Please try again from YouTube's membership settings interface.", + color: 'error' + }; + } + break; case 'registerClientResponse': break; default: @@ -341,6 +351,7 @@ + { scrollToBottom(); diff --git a/src/components/MembershipItem.svelte b/src/components/MembershipItem.svelte index 860a7162..dbaf7d7a 100644 --- a/src/components/MembershipItem.svelte +++ b/src/components/MembershipItem.svelte @@ -3,6 +3,7 @@ import MessageRun from './MessageRuns.svelte'; import { showProfileIcons } from '../ts/storage'; import { membershipBackground, milestoneChatBackground } from '../ts/chat-constants'; + import GiftedMembershipToggle from './GiftedMembershipToggle.svelte'; export let message: Ytc.ParsedMessage; @@ -21,7 +22,7 @@ {#if membership || membershipGift}
{#if $showProfileIcons} @@ -44,11 +45,14 @@ {/if} {#if membershipGift} - {membershipGift.image.alt} +
+ {membershipGift.image.alt} + +
{/if}
{#if isMilestoneChat} diff --git a/src/components/ReportBanDialog.svelte b/src/components/ReportBanDialog.svelte index 1dab391a..2719fd5f 100644 --- a/src/components/ReportBanDialog.svelte +++ b/src/components/ReportBanDialog.svelte @@ -4,8 +4,7 @@ chatReportUserOptions } from '../ts/chat-constants'; import { - reportDialog, - alertDialog + reportDialog } from '../ts/storage'; import Dialog from './common/Dialog.svelte'; import type { Writable } from 'svelte/store'; @@ -30,15 +29,3 @@ }} color="error" disabled={!$optionStore}>Report
- - - {$alertDialog?.title} -
- {$alertDialog?.message} -
-
- -
-
diff --git a/src/components/SuperchatViewDialog.svelte b/src/components/SuperchatViewDialog.svelte index 2b07c6ed..a5af4169 100644 --- a/src/components/SuperchatViewDialog.svelte +++ b/src/components/SuperchatViewDialog.svelte @@ -2,7 +2,7 @@ import { focusedSuperchat } from '../ts/storage'; - import Dialog from './common/Dialog.svelte'; + import TransparentDialog from './common/TransparentDialog.svelte'; import PaidMessage from './PaidMessage.svelte'; import MembershipItem from './MembershipItem.svelte'; @@ -14,21 +14,10 @@ $: if (!open) closeDialog(); - + {#if ('superChat' in sc || 'superSticker' in sc)} {:else} {/if} - - - + diff --git a/src/components/common/TransparentDialog.svelte b/src/components/common/TransparentDialog.svelte new file mode 100644 index 00000000..6170b8de --- /dev/null +++ b/src/components/common/TransparentDialog.svelte @@ -0,0 +1,19 @@ + + + + + + + diff --git a/src/manifest.json b/src/manifest.json index 6cd68378..3a4e2492 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -3,7 +3,7 @@ "name": "HyperChat by LiveTL", "homepage_url": "https://livetl.app/en/hyperchat/", "description": "YouTube chat, but it's fast and sleek!", - "version": "2.6.3", + "version": "2.6.4", "permissions": [ "storage" ], diff --git a/src/scripts/chat-background.ts b/src/scripts/chat-background.ts index 2bd6a822..f7a2ca70 100644 --- a/src/scripts/chat-background.ts +++ b/src/scripts/chat-background.ts @@ -379,6 +379,26 @@ const sendChatUserActionResponse = ( ); }; +const toggleMembershipGifting = ( + port: Chat.Port, + message: Chat.toggleMembershipGiftingMsg +): void => { + const interceptor = findInterceptorFromClient(port); + interceptor?.port?.postMessage(message); +}; + +const sendMembershipGiftingResponse = ( + port: Chat.Port, + message: Chat.toggleMembershipGiftingResponse +): void => { + const interceptor = findInterceptorFromPort(port, { message }); + if (!interceptor) return; + + interceptor.clients.forEach( + (clientPort) => clientPort.postMessage(message) + ); +}; + chrome.runtime.onConnect.addListener((port) => { port.onMessage.addListener((message: Chat.BackgroundMessage) => { switch (message.type) { @@ -415,6 +435,12 @@ chrome.runtime.onConnect.addListener((port) => { case 'chatUserActionResponse': sendChatUserActionResponse(port, message); break; + case 'toggleMembershipGifting': + toggleMembershipGifting(port, message); + break; + case 'toggleMembershipGiftingResponse': + sendMembershipGiftingResponse(port, message); + break; default: console.error('Unknown message type', port, message); break; diff --git a/src/scripts/chat-injector.ts b/src/scripts/chat-injector.ts index 0b370d6d..7bd6bb0f 100644 --- a/src/scripts/chat-injector.ts +++ b/src/scripts/chat-injector.ts @@ -46,6 +46,10 @@ const chatLoaded = async (): Promise => { const params = new URLSearchParams(); params.set('tabid', frameInfo.tabId.toString()); params.set('frameid', frameInfo.frameId.toString()); + try { + params.set('isYtFrame', window.parent.location.href !== window.location.href ? '1' : '0'); + } catch { + } if (frameIsReplay) params.set('isReplay', 'true'); const source = chrome.runtime.getURL( (isLiveTL ? 'hyperchat/index.html' : 'hyperchat.html') + diff --git a/src/scripts/chat-interceptor.ts b/src/scripts/chat-interceptor.ts index 9ed720bb..46fa22d7 100644 --- a/src/scripts/chat-interceptor.ts +++ b/src/scripts/chat-interceptor.ts @@ -85,101 +85,132 @@ const chatLoaded = async (): Promise => { // eslint-disable-next-line @typescript-eslint/no-misused-promises port.onMessage.addListener(async (msg) => { - if (msg.type !== 'executeChatAction') return; - const message = msg.message; - if (message.params == null) return; let success = true; - try { - // const action = msg.action; - const apiKey = ytcfg.data_.INNERTUBE_API_KEY; - const contextMenuUrl = 'https://www.youtube.com/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} https://www.youtube.com`); - const auth = `SAPISIDHASH ${time}_${sha}`; - const heads = { - headers: { - 'Content-Type': 'application/json', - Accept: '*/*', - Authorization: auth - }, - method: 'POST' + const apiKey = ytcfg.data_.INNERTUBE_API_KEY; + 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} https://www.youtube.com`); + const auth = `SAPISIDHASH ${time}_${sha}`; + const heads = { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + Authorization: auth + }, + method: 'POST' + }; + 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 }; - const res = await fetcher(contextMenuUrl, { - ...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( - res.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[1] - .menuNavigationItemRenderer.navigationEndpoint.confirmDialogEndpoint - .content.confirmDialogRenderer.confirmButton.buttonRenderer.serviceEndpoint, - 'moderateLiveChatEndpoint' - ); - await fetcher(`https://www.youtube.com/youtubei/v1/live_chat/moderate?key=${apiKey}&prettyPrint=false`, { - ...heads, - body: JSON.stringify({ - params, - context - }) - }); - } else if (msg.action === ChatUserActions.REPORT_USER) { - const { params, context } = parseServiceEndpoint( - res.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0].menuServiceItemRenderer.serviceEndpoint, - 'getReportFormEndpoint' - ); - const modal = await fetcher(`https://www.youtube.com/youtubei/v1/flag/get_form?key=${apiKey}&prettyPrint=false`, { - ...heads, - body: JSON.stringify({ - params, - context - }) - }); - const index = chatReportUserOptions.findIndex(d => d.value === msg.reportOption); - const options = modal.actions[0].openPopupAction.popup.reportFormModalRenderer.optionsSupportedRenderers.optionsRenderer.items; - const submitEndpoint = options[index].optionSelectableItemRenderer.submitEndpoint; - const clickTrackingParams = submitEndpoint.clickTrackingParams; - const flagAction = submitEndpoint.flagEndpoint.flagAction; - context.clickTracking = { - clickTrackingParams - }; - await fetcher(`https://www.youtube.com/youtubei/v1/flag/flag?key=${apiKey}&prettyPrint=false`, { + return { + params, + context: clonedContext + }; + } + if (msg.type === 'executeChatAction') { + const message = msg.message; + if (message.params == null) return; + try { + // const action = msg.action; + const contextMenuUrl = 'https://www.youtube.com/youtubei/v1/live_chat/get_item_context_menu?params=' + + `${encodeURIComponent(message.params)}&pbj=1&key=${apiKey}&prettyPrint=false`; + const res = await fetcher(contextMenuUrl, { ...heads, - body: JSON.stringify({ - action: flagAction, - context - }) + body: JSON.stringify({ context: baseContext }) }); + if (msg.action === ChatUserActions.BLOCK) { + const { params, context } = parseServiceEndpoint( + res.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[1] + .menuNavigationItemRenderer.navigationEndpoint.confirmDialogEndpoint + .content.confirmDialogRenderer.confirmButton.buttonRenderer.serviceEndpoint, + 'moderateLiveChatEndpoint' + ); + await fetcher(`https://www.youtube.com/youtubei/v1/live_chat/moderate?key=${apiKey}&prettyPrint=false`, { + ...heads, + body: JSON.stringify({ + params, + context + }) + }); + } else if (msg.action === ChatUserActions.REPORT_USER) { + const { params, context } = parseServiceEndpoint( + res.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0].menuServiceItemRenderer.serviceEndpoint, + 'getReportFormEndpoint' + ); + const modal = await fetcher(`https://www.youtube.com/youtubei/v1/flag/get_form?key=${apiKey}&prettyPrint=false`, { + ...heads, + body: JSON.stringify({ + params, + context + }) + }); + const index = chatReportUserOptions.findIndex(d => d.value === msg.reportOption); + const options = modal.actions[0].openPopupAction.popup.reportFormModalRenderer.optionsSupportedRenderers.optionsRenderer.items; + const submitEndpoint = options[index].optionSelectableItemRenderer.submitEndpoint; + const clickTrackingParams = submitEndpoint.clickTrackingParams; + const flagAction = submitEndpoint.flagEndpoint.flagAction; + context.clickTracking = { + clickTrackingParams + }; + await fetcher(`https://www.youtube.com/youtubei/v1/flag/flag?key=${apiKey}&prettyPrint=false`, { + ...heads, + body: JSON.stringify({ + action: flagAction, + context + }) + }); + } + } catch (e) { + console.debug('Error executing chat action', e); + success = false; + } + port.postMessage({ + type: 'chatUserActionResponse', + action: msg.action, + message, + success + }); + } else if (msg.type === 'toggleMembershipGifting') { + const clickItem = async (selector: string, condition = () => true): Promise => await new Promise((resolve, reject) => { + const interval = setInterval(() => { + const item = window.parent.document.querySelector(selector); + if (item != null) { + (item as HTMLButtonElement).click(); + if (condition()) { + clearInterval(interval); + resolve(); + } + } + }, 0); + setTimeout(() => { + clearInterval(interval); + reject(new Error(`Could not click ${selector}`)); + }, 500); + }); + try { + await clickItem('div#owner .ytd-button-renderer .style-suggestive'); + const s = '#items > ytd-menu-service-item-renderer:nth-child(3) > tp-yt-paper-item > yt-formatted-string'; + await clickItem('.ytd-sponsorships-offer-renderer button', () => window.parent.document.querySelector(s) != null); + await clickItem(s); + } catch (e) { + console.debug('Error toggling membership gifting', e); + success = false; } - } catch (e) { - console.debug('Error executing chat action', e); - success = false; + port.postMessage({ + type: 'toggleMembershipGiftingResponse', + success + }); } - port.postMessage({ - type: 'chatUserActionResponse', - action: msg.action, - message, - success - }); }); }); diff --git a/src/ts/chat-actions.ts b/src/ts/chat-actions.ts index aa5a3d47..106dd3b4 100644 --- a/src/ts/chat-actions.ts +++ b/src/ts/chat-actions.ts @@ -28,3 +28,11 @@ export function useBanHammer( }); } } + +export function toggleMembershipGifting( + port: Chat.Port | null +): void { + port?.postMessage({ + type: 'toggleMembershipGifting' + }); +} diff --git a/src/ts/chat-constants.ts b/src/ts/chat-constants.ts index eeecfbca..383b3515 100644 --- a/src/ts/chat-constants.ts +++ b/src/ts/chat-constants.ts @@ -26,6 +26,7 @@ const params = new URLSearchParams(window.location.search); export const paramsTabId = params.get('tabid'); export const paramsFrameId = params.get('frameid'); export const paramsIsReplay = params.get('isReplay'); +export const isYtFrame = params.get('isYtFrame') === '1'; export const enum Theme { YOUTUBE = 'YOUTUBE', diff --git a/src/ts/chat-utils.ts b/src/ts/chat-utils.ts index 6fa4fa8d..33a1d93b 100644 --- a/src/ts/chat-utils.ts +++ b/src/ts/chat-utils.ts @@ -51,6 +51,7 @@ export const isChatMessage = (a: Chat.MessageAction): boolean => !a.message.superChat && !a.message.superSticker && !a.message.membership; export const isAllEmoji = (a: Chat.MessageAction): boolean => + a.message.message.length !== 0 && a.message.message.every(m => m.type === 'emoji' || (m.type === 'text' && m.text.trim() === '')); export const checkInjected = (error: string): boolean => { diff --git a/src/ts/typings/chat.d.ts b/src/ts/typings/chat.d.ts index 76525eff..f86cb3e7 100644 --- a/src/ts/typings/chat.d.ts +++ b/src/ts/typings/chat.d.ts @@ -69,16 +69,13 @@ declare namespace Chat { failReason?: string; } - interface chatUserActionResponse { - type: 'chatUserActionResponse'; - action: ChatUserActions; - message: Ytc.ParsedMessage; - success: boolean; - } + type interceptorCommunication = + executeChatActionMsg | chatUserActionResponse | + toggleMembershipGiftingMsg | toggleMembershipGiftingResponse; type BackgroundResponse = Actions | InitialData | ThemeUpdate | LtlMessageResponse | - registerClientResponse | executeChatActionMsg | chatUserActionResponse; + registerClientResponse | interceptorCommunication; type InterceptorSource = 'ytc' | 'ltlMessage'; @@ -139,10 +136,26 @@ declare namespace Chat { reportOption?: ChatReportUserOptions; } + interface chatUserActionResponse { + type: 'chatUserActionResponse'; + action: ChatUserActions; + message: Ytc.ParsedMessage; + success: boolean; + } + + interface toggleMembershipGiftingMsg { + type: 'toggleMembershipGifting'; + } + + interface toggleMembershipGiftingResponse { + type: 'toggleMembershipGiftingResponse'; + success: boolean; + } + type BackgroundMessage = RegisterInterceptorMsg | RegisterClientMsg | processJsonMsg | setInitialDataMsg | updatePlayerProgressMsg | setThemeMsg | getThemeMsg | - RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg | chatUserActionResponse; + RegisterYtcInterceptorMsg | sendLtlMessageMsg | interceptorCommunication; type Port = Omit & { postMessage: (message: BackgroundMessage | BackgroundResponse) => void;