diff --git a/.eslintrc.js b/.eslintrc.js index cb0d1f3..bab0090 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -42,6 +42,7 @@ module.exports = { '@typescript-eslint/no-confusing-void-expression': 'off', '@typescript-eslint/no-floating-promises': 'off', '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unused-expressions': 'off', 'linebreak-style': [ diff --git a/README.md b/README.md index b7b2e2c..4b25602 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,6 @@ See https://livetl.app/hyperchat/install ## Building from Source -### ⚠️ WARNING ⚠️ - -For legacy reasons, we have a `mv2` branch for Firefox support while the `main` branch houses the main MV3 version. - -TODO: we need to confirm whether the MV2 variant is still required for modern versions of Firefox. - ### Development > Note: The repo expects a Linux or Unix-like environment. If you are on Windows, use WSL. diff --git a/src/manifest.json b/src/manifest.json index 082c528..0ecba28 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,5 +1,6 @@ { - "manifest_version": 3, + "{{chrome}}.manifest_version": 3, + "{{firefox}}.manifest_version": 2, "name": "HyperChat [Improved YouTube Chat]", "version": "0.0.0", "homepage_url": "https://livetl.app/hyperchat", @@ -11,7 +12,7 @@ "permissions": [ "storage" ], - "host_permissions": [ + "{{chrome}}.host_permissions": [ "https://www.youtube.com/live_chat*", "https://www.youtube.com/live_chat_replay*", "https://studio.youtube.com/live_chat*", @@ -25,9 +26,12 @@ "https://studio.youtube.com/live_chat*", "https://studio.youtube.com/live_chat_replay*" ], - "js": [ + "{{chrome}}.js": [ "scripts/chat-injector.ts" ], + "{{firefox}}.js": [ + "scripts/mv2/chat-injector.ts" + ], "css": [ "stylesheets/titlebar.css" ], @@ -47,25 +51,36 @@ "all_frames": true } ], - "{{firefox}}.background": { - "scripts": ["scripts/chat-background.ts"] - }, "{{chrome}}.background": { "service_worker": "scripts/chat-background.ts" }, - "action": { + "{{firefox}}.background": { + "scripts": ["scripts/mv2/chat-background.ts"], + "persistent": true + }, + "{{chrome}}.action": { "default_icon": { "48": "assets/logo-48.png", "128": "assets/logo-128.png" }, "default_popup": "options.html" }, - "web_accessible_resources": [ + "{{firefox}}.browser_action": { + "default_icon": { + "48": "assets/logo-48.png", + "128": "assets/logo-128.png" + }, + "default_popup": "options.html" + }, + "{{chrome}}.web_accessible_resources": [ { "resources": ["*"], "matches": [""] } ], + "{{firefox}}.web_accessible_resources": [ + "*" + ], "options_ui": { "page": "options.html", "open_in_tab": true diff --git a/src/scripts/mv2/chat-background.ts b/src/scripts/mv2/chat-background.ts new file mode 100644 index 0000000..f57e5c9 --- /dev/null +++ b/src/scripts/mv2/chat-background.ts @@ -0,0 +1,449 @@ +import type { Unsubscriber } from '../../ts/queue'; +import { ytcQueue } from '../../ts/queue'; +import { isValidFrameInfo } from '../../ts/chat-utils'; +import { isLiveTL } from '../../ts/chat-constants'; +import type { Chat } from '../../ts/typings/chat'; + +const interceptors: Chat.Interceptors[] = []; + +const isYtcInterceptor = (i: Chat.Interceptors): i is Chat.YtcInterceptor => + i.source === 'ytc'; + +const getPortFrameInfo = (port: Chat.Port): Chat.UncheckedFrameInfo => { + return { + tabId: port.sender?.tab?.id, + frameId: port.sender?.frameId + }; +}; + +/** + * Returns true if both FrameInfos are the same frame. + */ +const compareFrameInfo = (a: Chat.FrameInfo, b: Chat.FrameInfo): boolean => { + return a.tabId === b.tabId && a.frameId === b.frameId; +}; + +/** + * Returns the index of the interceptor with a matching FrameInfo. + * Will return `-1` if no interceptor is found. + */ +const findInterceptorIndex = (frameInfo: Chat.FrameInfo): number => { + return interceptors.findIndex( + (i) => compareFrameInfo(i.frameInfo, frameInfo) + ); +}; + +/** + * Finds and returns the interceptor with a matching FrameInfo. + * Will return `undefined` if no interceptor is found. + */ +const findInterceptor = (frameInfo: Chat.FrameInfo, debugObject?: unknown): Chat.Interceptor | undefined => { + const i = findInterceptorIndex(frameInfo); + if (i < 0) { + console.error('Interceptor not registered', debugObject); + return; + } + return interceptors[i]; +}; + +/** + * Finds and returns the interceptor based on the given Port. + * Should only be used for messages that are expected from interceptors. + * Will return `undefined` if no interceptor is found. + */ +const findInterceptorFromPort = ( + port: Chat.Port, + errorObject?: Record +): Chat.Interceptor | undefined => { + const frameInfo = getPortFrameInfo(port); + if (!isValidFrameInfo(frameInfo, port)) return; + return findInterceptor( + frameInfo, + { interceptors, port, ...errorObject } + ); +}; + +const findInterceptorFromClient = ( + client: Chat.Port +): Chat.Interceptor | undefined => { + return interceptors.find((interceptor) => { + for (const c of interceptor.clients) { + if (c.name === client.name) return true; + } + return false; + }); +}; + +/** + * If both port and clients are empty, removes interceptor from array. + * Also runs the queue unsubscribe function. + */ +const cleanupInterceptor = (i: number): void => { + const interceptor = interceptors[i]; + if (!interceptor.port && interceptor.clients.length < 1) { + console.debug('Removing empty interceptor', { interceptor, interceptors }); + if (isYtcInterceptor(interceptor)) { + interceptor.queue.cleanUp(); + interceptor.queueUnsub?.(); + } + interceptors.splice(i, 1); + } +}; + +/** + * Register an interceptor into the `interceptors` array. + * If an interceptor with the same FrameInfo already exists, its port will be + * replaced with the given port instead. + */ +const registerInterceptor = ( + port: Chat.Port, + source: Chat.InterceptorSource, + isReplay?: boolean +): void => { + const frameInfo = getPortFrameInfo(port); + if (!isValidFrameInfo(frameInfo, port)) return; + + // Unregister interceptor when port disconnects + port.onDisconnect.addListener(() => { + const i = findInterceptorIndex(frameInfo); + if (i < 0) { + console.error( + 'Failed to unregister interceptor', + { port, interceptors } + ); + return; + } + interceptors[i].port = undefined; + cleanupInterceptor(i); + console.debug('Interceptor unregistered', { port, interceptors }); + }); + + // Replace port if interceptor already exists + const i = findInterceptorIndex(frameInfo); + if (i >= 0) { + console.debug( + 'Replacing existing interceptor port', + { oldPort: interceptors[i].port, port } + ); + interceptors[i].port = port; + return; + } + + // Add interceptor to array + const interceptor = { + frameInfo, + port, + clients: [] + }; + if (source === 'ytc') { + const queue = ytcQueue(isReplay); + let queueUnsub: Unsubscriber | undefined; + const ytcInterceptor: Chat.YtcInterceptor = { + ...interceptor, + source: 'ytc', + dark: false, + queue, + queueUnsub + }; + interceptors.push(ytcInterceptor); + ytcInterceptor.queueUnsub = queue.latestAction.subscribe((latestAction) => { + const interceptor = findInterceptorFromPort(port, { latestAction }); + if (!interceptor || !latestAction) return; + interceptor.clients.forEach((port) => port.postMessage(latestAction)); + }); + } else { + interceptors.push({ ...interceptor, source }); + } + console.debug('New interceptor registered', { port, interceptors }); +}; + +/** + * Register a client to the interceptor with the matching FrameInfo. + */ +const registerClient = ( + port: Chat.Port, + frameInfo: Chat.FrameInfo, + getInitialData = false +): void => { + const interceptor = findInterceptor( + frameInfo, + { interceptors, port, frameInfo } + ); + if (!interceptor) { + port.postMessage( + { + type: 'registerClientResponse', + success: false, + failReason: 'Interceptor not found' + } + ); + return; + } + + if (interceptor.clients.some((client) => client.name === port.name)) { + console.debug( + 'Client already registered. Not registering', + { interceptors, port, frameInfo } + ); + port.postMessage( + { + type: 'registerClientResponse', + success: false, + failReason: 'Client already registered' + } + ); + return; + } + + // Assign pseudo-unique name + port.name = `${Date.now()}${Math.random()}`; + + // Unregister client when port disconnects + port.onDisconnect.addListener(() => { + const i = interceptor.clients.findIndex( + (clientPort) => clientPort.name === port.name + ); + if (i < 0) { + console.error('Failed to unregister client', { port, interceptor }); + return; + } + interceptor.clients.splice(i, 1); + console.debug('Unregister client successful', { port, interceptor }); + + cleanupInterceptor(findInterceptorIndex(frameInfo)); + }); + + // Add client to array + interceptor.clients.push(port); + console.debug('Register client successful', { port, interceptor }); + port.postMessage( + { + type: 'registerClientResponse', + success: true + } + ); + + if (getInitialData && isYtcInterceptor(interceptor)) { + const selfChannel = interceptor.queue.selfChannel.get(); + const payload: Chat.InitialData = { + type: 'initialData', + initialData: interceptor.queue.getInitialData(), + selfChannel: selfChannel != null + ? { + name: selfChannel.authorName?.simpleText ?? '', + channelId: selfChannel.authorExternalChannelId ?? '' + } + : null + }; + port.postMessage(payload); + console.debug('Sent initial data', { port, interceptor, payload }); + } +}; + +/** + * Parses the given YTC json response, and adds it to the queue of the + * interceptor that sent it. + */ +const processMessageChunk = (port: Chat.Port, message: Chat.JsonMsg): void => { + const json = message.json; + const interceptor = findInterceptorFromPort(port, { message }); + if (!interceptor || !isYtcInterceptor(interceptor)) return; + + if (interceptor.clients.length < 1) { + console.debug('No clients', { interceptor, json }); + return; + } + + interceptor.queue.addJsonToQueue(json, false, interceptor); +}; + +/** + * Parses a sent message and adds a fake message entry. + */ +const processSentMessage = (port: Chat.Port, message: Chat.JsonMsg): void => { + const json = message.json; + const interceptor = findInterceptorFromPort(port, { message }); + if (!interceptor || !isYtcInterceptor(interceptor)) return; + + const fakeJson: Ytc.SentChatItemAction = JSON.parse(json); + const fakeChunk: Ytc.RawResponse = { + continuationContents: { + liveChatContinuation: { + continuations: [{ + timedContinuationData: { + timeoutMs: 0 + } + }], + actions: fakeJson.actions + } + } + }; + interceptor.queue.addJsonToQueue(JSON.stringify( + fakeChunk + ), false, interceptor, true); +}; + +/** + * Parses and sets initial message data and metadata. + */ +const setInitialData = (port: Chat.Port, message: Chat.JsonMsg): void => { + const json = message.json; + const interceptor = findInterceptorFromPort(port, { message }); + if (!interceptor || !isYtcInterceptor(interceptor)) return; + + interceptor.queue.addJsonToQueue(json, true, interceptor); + + const parsedJson = JSON.parse(json); + + const actionPanel = (parsedJson?.continuationContents?.liveChatContinuation || + parsedJson?.contents?.liveChatRenderer) + ?.actionPanel; + + const user = actionPanel?.liveChatMessageInputRenderer + ?.sendButton?.buttonRenderer?.serviceEndpoint + ?.sendLiveChatMessageEndpoint?.actions[0] + ?.addLiveChatTextMessageFromTemplateAction?.template + ?.liveChatTextMessageRenderer ?? { + authorName: { + simpleText: parsedJson?.continuationContents?.liveChatContinuation?.viewerName + } + }; + + interceptor.queue.selfChannel.set(user); +}; + +/** + * Updates the player progress of the queue of the interceptor. + */ +const updatePlayerProgress = (port: Chat.Port, playerProgress: number, isFromYt?: boolean): void => { + const interceptor = findInterceptorFromPort(port, { playerProgress }); + if (!interceptor) return; + + // yt needs logic for clearing chat messages and forcing updates + // yt's updatePlayerProgress will send the playerProgress message in the method + if (isYtcInterceptor(interceptor)) { + interceptor.queue.updatePlayerProgress(playerProgress, isFromYt); + } else { // send to all twitch clients + interceptor.clients.forEach(port => port.postMessage({ type: 'playerProgress', playerProgress })); + } +}; + +/** + * Sets the theme of the interceptor, and sends the new theme to any currently + * registered clients. + */ +const setTheme = (port: Chat.Port, dark: boolean): void => { + const interceptor = findInterceptorFromPort(port, { dark }); + if (!interceptor || !isYtcInterceptor(interceptor)) return; + + interceptor.dark = dark; + interceptor.clients.forEach( + (port) => port.postMessage({ type: 'themeUpdate', dark }) + ); + console.debug(`Set dark theme to ${dark.toString()}`); +}; + +/** + * Returns a message with the theme of the interceptor with a matching + * FrameInfo. + */ +const getTheme = (port: Chat.Port, frameInfo: Chat.FrameInfo): void => { + const interceptor = findInterceptor( + frameInfo, + { interceptors, port, frameInfo } + ); + if (!interceptor || !isYtcInterceptor(interceptor)) return; + + port.postMessage({ type: 'themeUpdate', dark: interceptor.dark }); +}; + +const sendLtlMessage = (port: Chat.Port, message: Chat.LtlMessage): void => { + const interceptor = findInterceptorFromPort(port, { message }); + if (!interceptor) return; + + interceptor.clients.forEach( + (clientPort) => clientPort.postMessage({ type: 'ltlMessage', message }) + ); +}; + +const executeChatAction = ( + port: Chat.Port, + message: Chat.executeChatActionMsg +): void => { + const interceptor = findInterceptorFromClient(port); + interceptor?.port?.postMessage(message); +}; + +const sendChatUserActionResponse = ( + port: Chat.Port, + message: Chat.chatUserActionResponse +): 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) { + case 'registerInterceptor': + registerInterceptor(port, message.source, message.isReplay); + break; + case 'registerClient': + registerClient(port, message.frameInfo, message.getInitialData); + break; + case 'processMessageChunk': + processMessageChunk(port, message); + break; + case 'processSentMessage': + processSentMessage(port, message); + break; + case 'setInitialData': + setInitialData(port, message); + break; + case 'updatePlayerProgress': + updatePlayerProgress(port, message.playerProgress, message.isFromYt); + break; + case 'setTheme': + setTheme(port, message.dark); + break; + case 'getTheme': + getTheme(port, message.frameInfo); + break; + case 'sendLtlMessage': + sendLtlMessage(port, message.message); + break; + case 'executeChatAction': + executeChatAction(port, message); + break; + case 'chatUserActionResponse': + sendChatUserActionResponse(port, message); + break; + default: + console.error('Unknown message type', port, message); + break; + } + }); +}); + +chrome.browserAction.onClicked.addListener(() => { + if (isLiveTL) { + chrome.tabs.create({ url: 'https://livetl.app' }, () => {}); + } else { + chrome.tabs.create({ url: 'https://livetl.app/hyperchat' }, () => {}); + } +}); + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.type === 'getFrameInfo') { + sendResponse({ tabId: sender.tab?.id, frameId: sender.frameId }); + } else if (request.type === 'createPopup') { + chrome.windows.create({ + url: request.url, + type: 'popup' + }, () => {}); + } +}); diff --git a/src/scripts/mv2/chat-injector.ts b/src/scripts/mv2/chat-injector.ts new file mode 100644 index 0000000..31f72ac --- /dev/null +++ b/src/scripts/mv2/chat-injector.ts @@ -0,0 +1,78 @@ +import HcButton from '../../components/HyperchatButton.svelte'; +import { getFrameInfoAsync, isValidFrameInfo, frameIsReplay, checkInjected } from '../../ts/chat-utils'; +import { isLiveTL } from '../../ts/chat-constants'; +import { hcEnabled, autoLiveChat } from '../../ts/storage'; + +const hcWarning = 'An existing HyperChat button has been detected. This ' + + 'usually means both LiveTL and standalone HyperChat are enabled. ' + + 'LiveTL already includes HyperChat, so please enable only one of them.\n\n' + + 'Having multiple instances of the same scripts running WILL cause ' + + 'problems such as chat messages not loading.'; + +const chatLoaded = async (): Promise => { + if (!isLiveTL && checkInjected(hcWarning)) return; + + document.body.style.minWidth = document.body.style.minHeight = '0px'; + const hyperChatEnabled = await hcEnabled.get(); + + // Inject HC button + const ytcPrimaryContent = document.querySelector('#primary-content'); + if (!ytcPrimaryContent) { + console.error('Failed to find #primary-content'); + return; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hcButton = new HcButton({ + target: ytcPrimaryContent + }); + + // Everything past this point will only run if HC is enabled + if (!hyperChatEnabled) return; + + const ytcItemList = document.querySelector('#chat>#item-list'); + if (!ytcItemList) { + console.error('Failed to find #chat>#item-list'); + return; + } + + // Inject hyperchat + const frameInfo = await getFrameInfoAsync(); + if (!isValidFrameInfo(frameInfo)) { + console.error('Failed to get valid frame info', { frameInfo }); + return; + } + const params = new URLSearchParams(); + params.set('tabid', frameInfo.tabId.toString()); + params.set('frameid', frameInfo.frameId.toString()); + if (frameIsReplay()) params.set('isReplay', 'true'); + const source = chrome.runtime.getURL( + (isLiveTL ? 'hyperchat/index.html' : 'hyperchat.html') + + `?${params.toString()}` + ); + ytcItemList.outerHTML = `