diff --git a/docs/YOUTUBE_ACTIONS.md b/docs/YOUTUBE_ACTIONS.md index 685e4dac..1a7ea4e9 100644 --- a/docs/YOUTUBE_ACTIONS.md +++ b/docs/YOUTUBE_ACTIONS.md @@ -1,6 +1,6 @@ # YouTube Actions (Dev Notes) -This repo implements YouTube "chat actions" (block, report, delete/retract, and future mod actions) by calling Innertube endpoints based on data from the message + its context menu. +This repo implements YouTube "chat actions" (block, report, delete/retract, and mod actions) by calling Innertube endpoints based on data from the message + its context menu. This doc exists so we do not re-learn the same YouTube quirks every time. @@ -58,7 +58,7 @@ Instead: Examples: -- block: `moderateLiveChatEndpoint` (and friends) +- block/hide/delete/timeout/unhide: `moderateLiveChatEndpoint` (but choose by menu icon/action, not by endpoint type alone) - report: `getReportFormEndpoint` (flow can be multi-step) - delete/retract: look for the delete/retract endpoint in the same way @@ -68,6 +68,110 @@ If an endpoint is missing, log enough context to diagnose: - which ones we did not - which message/menu payload we used to ask for the menu +## Mod Action Learnings + +The captured mod-action HAR (`artifacts/build/trying-mod-actions.har`) is enough to reconstruct the exact native YouTube flows. All mutation requests start from a message's `contextMenuEndpoint.liveChatItemContextMenuEndpoint.params`, then call `live_chat/get_item_context_menu`, then execute the endpoint attached to the selected menu item or nested option. + +Do not resolve mod actions by grabbing the first `moderateLiveChatEndpoint`. In moderator menus, delete, timeout, hide, and unhide all use `moderateLiveChatEndpoint`. The action identity comes from the menu item icon, and for dialog-backed actions, from the selected nested option. + +Known menu/action mapping: + +- `KEEP` / `Pin message`: top-level `liveChatActionEndpoint` -> `live_chat/live_chat_action` +- `KEEP` / `Replace pinned message`: top-level `liveChatActionEndpoint` -> `live_chat/live_chat_action` +- `DELETE` / `Remove`: top-level `moderateLiveChatEndpoint` -> `live_chat/moderate` +- `HOURGLASS` / `Put user in timeout`: nested option `submitEndpoint.moderateLiveChatEndpoint` -> `live_chat/moderate` +- `REMOVE_CIRCLE` / `Hide user on this channel`: top-level `moderateLiveChatEndpoint` -> `live_chat/moderate` +- `ADD_CIRCLE` / `Unhide user on this channel`: top-level `moderateLiveChatEndpoint` -> `live_chat/moderate` +- `ADD_MODERATOR` / `Add as moderator`: nested option `submitEndpoint.manageLiveChatUserEndpoint` -> `live_chat/manage_user` +- `REMOVE_MODERATOR` / `Remove as managing moderator`: top-level `manageLiveChatUserEndpoint` -> `live_chat/manage_user` +- `REMOVE_MODERATOR` / `Remove as standard moderator`: top-level `manageLiveChatUserEndpoint` -> `live_chat/manage_user` +- `FLAG` / `Report`: top-level `getReportFormEndpoint` -> `flag/get_form`, then `flag/flag` +- `WATCH_HISTORY` / `Channel Activity`: `showEngagementPanelEndpoint`; this opens YouTube's engagement panel and is not a moderation mutation request + +Nested timeout options captured from the native dialog: + +- `10 seconds` +- `1 minute` +- `5 minutes` +- `10 minutes` +- `30 minutes` +- `24 hours` + +Nested add-moderator options captured from the native dialog: + +- `Managing moderator` +- `Standard moderator` + +Exact captured demo sequence: + +1. Menu entry `187`: selected `KEEP` / `Pin message`; POST entry `196` to `live_chat/live_chat_action`; response showed `Message pinned`, `Undo`, and `addBannerToLiveChatCommand`. +2. Menu entry `227`: selected `DELETE` / `Remove`; POST entry `229` to `live_chat/moderate`; response had `markChatItemAsDeletedAction` with `[message retracted]`. +3. Menu entry `437`: selected `KEEP` / `Replace pinned message`; POST entry `443` to `live_chat/live_chat_action`; response showed `Message pinned`, `Undo`, and a pinned banner update. +4. Menu entry `453`: selected `DELETE` / `Remove`; POST entry `460` to `live_chat/moderate`; response had `markChatItemAsDeletedAction` with `Message deleted by @livetl-vtuberclipsch.8354.`. +5. Menu entry `466`: selected `HOURGLASS` / `Put user in timeout`, nested option `1 minute`; POST entry `478` to `live_chat/moderate`; response toast said `@KentoNishi has been timed out for 1 minute`. +6. Menu entry `531`: selected `ADD_MODERATOR` / `Add as moderator`, nested option `Managing moderator`; POST entry `543` to `live_chat/manage_user`; response toast said `@KentoNishi is now a managing moderator for your channel`. +7. Menu entry `545`: selected `REMOVE_MODERATOR` / `Remove as managing moderator`; POST entry `551` to `live_chat/manage_user`; response toast said `@KentoNishi is no longer a managing moderator for your channel`. +8. Menu entry `554`: selected `ADD_MODERATOR` / `Add as moderator`, nested option `Standard moderator`; POST entry `561` to `live_chat/manage_user`; response toast said `@KentoNishi is now a standard moderator for your channel`. +9. Menu entry `564`: selected `REMOVE_MODERATOR` / `Remove as standard moderator`; POST entry `568` to `live_chat/manage_user`; response toast said `@KentoNishi is no longer a standard moderator for your channel`. +10. Menu entry `578`: selected `REMOVE_CIRCLE` / `Hide user on this channel`; POST entry `584` to `live_chat/moderate`; response toast said `This user's messages will be hidden` and included an `Undo` button. +11. Menu entry `590`: selected `ADD_CIRCLE` / `Unhide user on this channel`; POST entry `594` to `live_chat/moderate`; response was an empty success. +12. Menu entry `597`: selected `REMOVE_CIRCLE` / `Hide user on this channel`; POST entry `602` to `live_chat/moderate`; response toast said `This user's messages will be hidden` and included an `Undo` button. +13. Response entry `602`: clicked the hide toast's `Undo` button; POST entry `605` to `live_chat/moderate`; response was an empty success. + +The hide/unhide flow therefore has two proven unhide sources: the context menu's `ADD_CIRCLE` item and the `Undo` button endpoint returned by a successful hide response. For HyperChat's message action menu, use the context-menu `ADD_CIRCLE` path. If HyperChat later renders native-style action toasts, the response-button endpoint is also valid. + +The mod-action HAR contains `FLAG` / `Report` menu items, but it does not contain an executed report submission. Use the existing report flow for report execution unless a new report-specific HAR says otherwise. + +## Mod Action Implementation Plan + +Everything currently implemented for block, report, delete/retract, message parsing, queueing, and MV2 background forwarding works and must not regress. Implement mod actions by preserving the existing architecture and changing only the pieces required to select and execute the correct YouTube endpoints. + +Constraints: + +- Do not rewrite the common menu component. +- Do not make the message menu dynamically fetch native YouTube menu items on open. +- Do not replace the background/interceptor message flow. +- Do not change deletion/retraction UI state handling except where endpoint selection must become more precise. +- Do not ingest arbitrary action response bodies into the queue in the first mod-action pass. + +Implementation shape: + +1. Keep the static HyperChat message menu. +2. Add explicit action constants/menu entries for the supported mod actions. +3. Use the existing report-dialog pattern for actions that need a choice: + - timeout duration: `10 seconds`, `1 minute`, `5 minutes`, `10 minutes`, `30 minutes`, `24 hours` + - add moderator role: `Managing moderator`, `Standard moderator` +4. Keep `useBanHammer`, `executeChatAction`, `chatUserActionResponse`, and MV2 background forwarding structurally intact. +5. Inside the action executor, keep the existing `get_item_context_menu` request, headers, SAPISIDHASH, and proxy fetch flow. +6. Replace fragile endpoint selection with icon-aware resolution: + - `DELETE_MESSAGE`: `DELETE` + `moderateLiveChatEndpoint` + - `PIN_MESSAGE`: `KEEP` + `liveChatActionEndpoint` + - `HIDE_USER`: `REMOVE_CIRCLE` + `moderateLiveChatEndpoint` + - `UNHIDE_USER`: `ADD_CIRCLE` + `moderateLiveChatEndpoint` + - `TIMEOUT_USER`: `HOURGLASS` + selected nested option's `moderateLiveChatEndpoint` + - `ADD_MODERATOR`: `ADD_MODERATOR` + selected nested option's `manageLiveChatUserEndpoint` + - `REMOVE_MODERATOR`: `REMOVE_MODERATOR` + `manageLiveChatUserEndpoint` + - `REPORT_USER`: existing report form flow + - `BLOCK`: only a real `BLOCK` menu item; do not fall back to the first `moderateLiveChatEndpoint` +7. Keep local success side effects narrow: + - `DELETE_MESSAGE`: keep the current local deleted-message replacement. + - `BLOCK`: keep current removal of that author's visible messages. + - `REPORT_USER`: keep current removal of that author's visible messages. + - `HIDE_USER`: may remove that author's visible messages, matching the user-visible effect of hiding. + - pin, timeout, add moderator, remove moderator, and unhide: show success/failure only. +8. Implement on MV2 first, then merge forward: + - HyperChat `mv2` + - HyperChat `main` + - HyperChat `mv3-ltl` + +Regression guardrails: + +- Existing delete/retract behavior must continue to work for self messages, own streams, other streams, and moderator deletes. +- Existing report behavior must keep the same dialog and request flow. +- Existing block behavior must not accidentally execute delete/hide/timeout just because those share `moderateLiveChatEndpoint`. +- Existing queue/parser deletion handling must remain the source of truth for YouTube-originated delete updates. +- If a static HyperChat action is unavailable in YouTube's context menu, fail gracefully through `chatUserActionResponse` instead of guessing another endpoint. + ## Keep Requests Correlated If you proxy Innertube calls through a background/service worker, keep request/response events correlated by request id. @@ -98,4 +202,3 @@ If you fake success, users will trust the UI less than the native UI. - Wrong Innertube client name/version (YouTube serves different schemas) - SAPISIDHASH removed or computed for the wrong origin - Context menu parsing tied to item index instead of endpoint types - diff --git a/postcss.config.js b/postcss.config.js index 427baeed..95e263d3 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -6,6 +6,7 @@ const safelistSelectors = [ 'body', 'stroke-primary', 'mode-dark', + 'line-through', // Components with custom color prop might need its color to be whitelisted too 'bg-blue-500', 'hover:bg-blue-400' diff --git a/src/components/Hyperchat.svelte b/src/components/Hyperchat.svelte index bc13bd15..91df0180 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -24,7 +24,7 @@ chatUserActionsItems, ChatUserActions } from '../ts/chat-constants'; - import { isAllEmoji, isChatMessage, isPrivileged, responseIsAction } from '../ts/chat-utils'; + import { buildDeletedObj, isAllEmoji, isChatMessage, isPrivileged, responseIsAction } from '../ts/chat-utils'; import { handleReplyThreadResponse } from '../ts/chat-actions'; import Button from 'smelte/src/components/Button'; import { @@ -216,14 +216,15 @@ }; const onDelete = (deletion: Ytc.ParsedDeleted) => { - messageActions.some((action) => { + const changed = messageActions.some((action) => { if (isWelcome(action)) return false; if (action.message.messageId === deletion.messageId) { - action.deleted = { replace: deletion.replacedMessage }; + action.deleted = buildDeletedObj(deletion, action.message.message); return true; } return false; }); + if (changed) messageActions = messageActions; }; const onChatAction = (action: Chat.Actions, isInitial = false) => { @@ -303,6 +304,12 @@ else document.body.classList.remove('bg-ytdark-50'); }; + const removesAuthorMessages = (action: ChatUserActions): boolean => { + return action === ChatUserActions.BLOCK || + action === ChatUserActions.REPORT_USER || + action === ChatUserActions.HIDE_USER; + }; + const onPortMessage = (response: Chat.BackgroundResponse) => { if (responseIsAction(response)) { onChatAction(response); @@ -320,6 +327,14 @@ $ytDark = response.dark; break; case 'chatUserActionResponse': + if (response.success && response.action === ChatUserActions.DELETE_MESSAGE) { + onDelete({ + messageId: response.message.messageId, + replacedMessage: [], + pending: true + }); + break; + } $alertDialog = { title: response.success ? 'Success!' : 'Error', message: chatUserActionsItems.find(v => v.value === response.action) @@ -327,13 +342,7 @@ color: response.success ? 'primary' : 'error' }; if (response.success) { - if (response.action === ChatUserActions.DELETE_MESSAGE) { - onDelete({ - messageId: response.message.messageId, - replacedMessage: [{ text: '[message retracted]' }] - }); - break; - } + if (!removesAuthorMessages(response.action)) break; messageActions = messageActions.filter( (a) => { if (isWelcome(a)) return true; @@ -465,7 +474,7 @@ {#if $enableStickySuperchatBar} {/if} -
+
{#each messageActions as action (action.message.messageId)} diff --git a/src/components/Message.svelte b/src/components/Message.svelte index 61452897..420c4c87 100644 --- a/src/components/Message.svelte +++ b/src/components/Message.svelte @@ -59,8 +59,17 @@ }); $: nameColorClass = generateNameColorClass(member, moderator, owner, forceDark); - $: if (deleted != null) { - message.message = deleted.replace; + let showOriginal = false; + $: displayRuns = deleted != null && !showOriginal ? deleted.replace : message.message; + // If showing original text, swap the first text run to 'hide'. + let toggleLabelRuns: Ytc.ParsedRun[] | undefined; + $: { + let swapped = !showOriginal; + toggleLabelRuns = deleted?.viewOriginalText?.map((r) => { + if (swapped || r.type !== 'text') return r; + swapped = true; + return { ...r, text: 'Hide deleted message' }; + }); } $: displayAuthorName = formatAuthorName(message.author.name); @@ -71,10 +80,14 @@ $: isSelf = message.author.id === $selfChannelId; $: visibleActions = chatUserActionsItems.filter((d) => { - if (isSelf) { - return d.value === ChatUserActions.DELETE_MESSAGE && message.params != null; + if (d.value === ChatUserActions.DELETE_MESSAGE) { + return (isSelf || message.canDelete) && message.params != null && deleted == null; } - return d.value !== ChatUserActions.DELETE_MESSAGE; + if (isSelf) return false; + if (message.params == null) { + return d.value === ChatUserActions.BLOCK || d.value === ChatUserActions.REPORT_USER; + } + return true; }); $: menuItems = visibleActions.map((d) => ({ icon: d.icon, @@ -92,13 +105,13 @@ -
{#if !hideName && $showProfileIcons} {/if} -
+
{#if !hideName} {/if} + {#if deleted?.viewOriginalText} + + {/if} {#if message.membershipGiftRedeem} ; + $: actionOptionStore = $chatActionOptionDialog?.optionStore as Writable; @@ -29,6 +31,23 @@
+ + {$chatActionOptionDialog?.title} +
+ +
+
+ +
+
+ {$alertDialog?.title}
diff --git a/src/components/common/Menu.svelte b/src/components/common/Menu.svelte index 2c572e53..6be7c136 100644 --- a/src/components/common/Menu.svelte +++ b/src/components/common/Menu.svelte @@ -85,7 +85,7 @@ class={menuClasses} transition:fade={{ duration: 150 }} bind:this={listDiv} - style="max-width: 20em; font-size: 0.9em; {offsetYStyle}" + style="max-width: 20em; font-size: 1em; {offsetYStyle}" > { onItemClick(item); }} style="padding: 0.5em 1em" > - + {item.icon} {item.text} diff --git a/src/scripts/chat-interceptor.ts b/src/scripts/chat-interceptor.ts index bc414fb6..ccfc9a16 100644 --- a/src/scripts/chat-interceptor.ts +++ b/src/scripts/chat-interceptor.ts @@ -132,6 +132,7 @@ const chatLoaded = async (): Promise => { const visitorId = (ytcfg as any)?.data_?.VISITOR_DATA ?? baseContext?.client?.visitorData; const clientName = (ytcfg as any)?.data_?.INNERTUBE_CLIENT_NAME; const clientVersion = (ytcfg as any)?.data_?.INNERTUBE_CLIENT_VERSION; + const pageId = (ytcfg as any)?.data_?.DELEGATED_SESSION_ID; return { headers: { 'Content-Type': 'application/json', @@ -140,6 +141,7 @@ const chatLoaded = async (): Promise => { ...(visitorId != null ? { 'X-Goog-Visitor-Id': String(visitorId) } : {}), ...(clientName != null ? { 'X-Youtube-Client-Name': String(clientName) } : {}), ...(clientVersion != null ? { 'X-Youtube-Client-Version': String(clientVersion) } : {}), + ...(pageId != null ? { 'X-Goog-PageId': String(pageId) } : {}), 'X-Origin': currentDomain, ...(auth != null ? { Authorization: auth } : {}) }, @@ -237,25 +239,41 @@ const chatLoaded = async (): Promise => { hints: iconTypes }); } - function findServiceEndpoint(root: any, prop: string): any | null { + type EndpointProp = 'moderateLiveChatEndpoint' | 'getReportFormEndpoint' | + 'liveChatActionEndpoint' | 'manageLiveChatUserEndpoint'; + function getText(text: any): string { + if (typeof text?.simpleText === 'string') return text.simpleText; + if (Array.isArray(text?.runs)) { + return text.runs.map((r: any) => r?.text).filter(Boolean).join(''); + } + return ''; + } + function walkObjects(root: any, visitor: (current: any) => void): void { const queue = [root]; const visited = new Set(); while (queue.length > 0) { const current = queue.shift(); if (current == null || typeof current !== 'object' || visited.has(current)) continue; visited.add(current); - if (typeof current?.[prop]?.params === 'string') { - return current; - } + visitor(current); for (const value of Object.values(current)) { if (value != null && typeof value === 'object') { queue.push(value); } } } - return null; } - function parseServiceEndpoint(serviceEndpoint: any, prop: string): { params: string, context: any } { + function findServiceEndpoint(root: any, prop: EndpointProp): any | null { + let found: any | null = null; + walkObjects(root, (current) => { + if (found != null) return; + if (typeof current?.[prop]?.params === 'string') { + found = current; + } + }); + return found; + } + function parseServiceEndpoint(serviceEndpoint: any, prop: EndpointProp): { params: string, context: any } { if (typeof serviceEndpoint?.[prop]?.params !== 'string') { throw new Error(`Missing service endpoint params for ${prop}`); } @@ -271,78 +289,104 @@ const chatLoaded = async (): Promise => { context: clonedContext }; } - function findDeleteMessageEndpoint(root: any): any | null { - const queue = [root]; - const visited = new Set(); - const candidates: Array<{ iconType?: string, label?: string, endpoint: any }> = []; - while (queue.length > 0) { - const current = queue.shift(); - if (current == null || typeof current !== 'object' || visited.has(current)) continue; - visited.add(current); + function findMenuEndpoint( + root: any, + iconType: string, + prop: EndpointProp, + labelMatches: Array<(label: string) => boolean> = [] + ): any | null { + const candidates: Array<{ iconType?: string, label: string, endpoint: any }> = []; + walkObjects(root, (current) => { const menu = current?.menuServiceItemRenderer; - const iconType = menu?.icon?.iconType; + if (menu == null) return; const endpoint = menu?.serviceEndpoint; - const label = ( - Array.isArray(menu?.text?.runs) - ? menu.text.runs.map((r: any) => r?.text).filter(Boolean).join('') - : menu?.text?.simpleText - ) as string | undefined; - // Prefer stable identifiers (DELETE icon + moderate endpoint) over localized label text. - if (typeof endpoint?.moderateLiveChatEndpoint?.params === 'string') { - candidates.push({ iconType, label, endpoint }); - } - for (const value of Object.values(current)) { - if (value != null && typeof value === 'object') { - queue.push(value); - } + if (typeof endpoint?.[prop]?.params === 'string') { + candidates.push({ + iconType: menu?.icon?.iconType, + label: getText(menu?.text), + endpoint + }); } - } + }); for (const c of candidates) { - if (c.iconType === 'DELETE') return c.endpoint; + if (c.iconType === iconType) return c.endpoint; } for (const c of candidates) { - const l = (c.label ?? '').toLowerCase(); - if (l.includes('remove') || l.includes('delete') || l.includes('retract') || l.includes('unsend')) { + const label = c.label.toLowerCase(); + if (labelMatches.some((matcher) => matcher(label))) { return c.endpoint; } } - if (candidates.length === 1) return candidates[0].endpoint; return null; } - if (msg.action === ChatUserActions.BLOCK) { - const serviceEndpoint = findServiceEndpoint(res, 'moderateLiveChatEndpoint'); - if (serviceEndpoint == null) { - throw new Error('Could not find moderate endpoint in context menu'); + function findNestedOptionEndpoint( + root: any, + iconType: string, + optionLabel: string | undefined, + prop: EndpointProp + ): any | null { + if (optionLabel == null) { + throw new Error(`Missing option label for ${iconType}`); } - const { params, context } = parseServiceEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint'); - const moderationResponse = await fetcher(`${currentDomain}/youtubei/v1/live_chat/moderate?key=${apiKey}&prettyPrint=false`, { + let found: any | null = null; + const normalizedOptionLabel = optionLabel.toLowerCase(); + walkObjects(root, (current) => { + if (found != null) return; + const menu = current?.menuServiceItemRenderer; + if (menu?.icon?.iconType !== iconType) return; + walkObjects(menu, (menuNode) => { + if (found != null) return; + const option = menuNode?.optionSelectableItemRenderer; + const endpoint = option?.submitEndpoint; + if (typeof endpoint?.[prop]?.params !== 'string') return; + if (getText(option?.text).toLowerCase() === normalizedOptionLabel) { + found = endpoint; + } + }); + }); + return found; + } + async function postEndpoint( + serviceEndpoint: any, + prop: EndpointProp, + apiPath: string + ): Promise { + const { params, context } = parseServiceEndpoint(serviceEndpoint, prop); + const actionResponse = await fetcher(`${currentDomain}/youtubei/v1/${apiPath}?key=${apiKey}&prettyPrint=false`, { ...heads, body: JSON.stringify({ params, context }) }); - if (moderationResponse?.error != null || moderationResponse?.success === false) { - throw new Error('Moderation request failed'); + if (actionResponse?.error != null || actionResponse?.success === false) { + throw new Error(`${apiPath} request failed`); + } + return actionResponse; + } + if (msg.action === ChatUserActions.BLOCK) { + const serviceEndpoint = findMenuEndpoint(res, 'BLOCK', 'moderateLiveChatEndpoint', [ + (label) => label.includes('block') + ]); + if (serviceEndpoint == null) { + throw new Error('Could not find block endpoint in context menu'); } + await postEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint', 'live_chat/moderate'); } else if (msg.action === ChatUserActions.DELETE_MESSAGE) { - const serviceEndpoint = findDeleteMessageEndpoint(res); + const serviceEndpoint = findMenuEndpoint(res, 'DELETE', 'moderateLiveChatEndpoint', [ + (label) => label.includes('remove') || label.includes('delete') || + label.includes('retract') || label.includes('unsend') + ]); if (serviceEndpoint == null) { throw new Error('Could not find delete endpoint in context menu'); } - const { params, context } = parseServiceEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint'); if (debugAction) { + const { params } = parseServiceEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint'); console.debug('[hc] delete: moderate', { paramsPrefix: params.slice(0, 24) }); } - const moderationResponse = await fetcher(`${currentDomain}/youtubei/v1/live_chat/moderate?key=${apiKey}&prettyPrint=false`, { - ...heads, - body: JSON.stringify({ - params, - context - }) - }); + const moderationResponse = await postEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint', 'live_chat/moderate'); if (debugAction) { console.debug('[hc] delete: moderate response', { keys: moderationResponse != null && typeof moderationResponse === 'object' @@ -352,12 +396,65 @@ const chatLoaded = async (): Promise => { success: moderationResponse?.success }); } - if (moderationResponse?.error != null || moderationResponse?.success === false) { - throw new Error('Moderation request failed'); + } else if (msg.action === ChatUserActions.PIN_MESSAGE) { + const serviceEndpoint = findMenuEndpoint(res, 'KEEP', 'liveChatActionEndpoint', [ + (label) => label.includes('pin') + ]); + if (serviceEndpoint == null) { + throw new Error('Could not find pin endpoint in context menu'); + } + await postEndpoint(serviceEndpoint, 'liveChatActionEndpoint', 'live_chat/live_chat_action'); + } else if (msg.action === ChatUserActions.TIMEOUT_USER) { + const serviceEndpoint = findNestedOptionEndpoint( + res, + 'HOURGLASS', + msg.actionOption, + 'moderateLiveChatEndpoint' + ); + if (serviceEndpoint == null) { + throw new Error('Could not find timeout endpoint in context menu'); + } + await postEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint', 'live_chat/moderate'); + } else if (msg.action === ChatUserActions.HIDE_USER) { + const serviceEndpoint = findMenuEndpoint(res, 'REMOVE_CIRCLE', 'moderateLiveChatEndpoint', [ + (label) => label.includes('hide user') + ]); + if (serviceEndpoint == null) { + throw new Error('Could not find hide endpoint in context menu'); + } + await postEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint', 'live_chat/moderate'); + } else if (msg.action === ChatUserActions.UNHIDE_USER) { + const serviceEndpoint = findMenuEndpoint(res, 'ADD_CIRCLE', 'moderateLiveChatEndpoint', [ + (label) => label.includes('unhide user') + ]); + if (serviceEndpoint == null) { + throw new Error('Could not find unhide endpoint in context menu'); + } + await postEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint', 'live_chat/moderate'); + } else if (msg.action === ChatUserActions.ADD_MODERATOR) { + const serviceEndpoint = findNestedOptionEndpoint( + res, + 'ADD_MODERATOR', + msg.actionOption, + 'manageLiveChatUserEndpoint' + ); + if (serviceEndpoint == null) { + throw new Error('Could not find add moderator endpoint in context menu'); + } + await postEndpoint(serviceEndpoint, 'manageLiveChatUserEndpoint', 'live_chat/manage_user'); + } else if (msg.action === ChatUserActions.REMOVE_MODERATOR) { + const serviceEndpoint = findMenuEndpoint(res, 'REMOVE_MODERATOR', 'manageLiveChatUserEndpoint', [ + (label) => label.includes('remove') && label.includes('moderator') + ]); + if (serviceEndpoint == null) { + throw new Error('Could not find remove moderator endpoint in context menu'); } + await postEndpoint(serviceEndpoint, 'manageLiveChatUserEndpoint', 'live_chat/manage_user'); } else if (msg.action === ChatUserActions.REPORT_USER) { const apiKey = ytcfg.data_.INNERTUBE_API_KEY; - const serviceEndpoint = findServiceEndpoint(res, 'getReportFormEndpoint'); + const serviceEndpoint = findMenuEndpoint(res, 'FLAG', 'getReportFormEndpoint', [ + (label) => label.includes('report') + ]) ?? findServiceEndpoint(res, 'getReportFormEndpoint'); if (serviceEndpoint == null) { throw new Error('Could not find report endpoint in context menu'); } @@ -398,6 +495,8 @@ const chatLoaded = async (): Promise => { if (flagResponse?.error != null || flagResponse?.success === false) { throw new Error('Report request failed'); } + } else { + throw new Error(`Unknown chat action: ${msg.action as string}`); } } catch (e) { console.debug('Error executing chat action', e); diff --git a/src/ts/chat-actions.ts b/src/ts/chat-actions.ts index df0c1c3d..589882f9 100644 --- a/src/ts/chat-actions.ts +++ b/src/ts/chat-actions.ts @@ -1,19 +1,26 @@ import { writable } from 'svelte/store'; -import { ChatReportUserOptions, ChatUserActions } from './chat-constants'; -import { reportDialog } from './storage'; +import { + ChatReportUserOptions, + ChatUserActions, + chatModeratorRoleOptions, + chatTimeoutOptions +} from './chat-constants'; +import { chatActionOptionDialog, reportDialog } from './storage'; export function useBanHammer( message: Ytc.ParsedMessage, action: ChatUserActions, port: Chat.Port | null ): void { - if (action === ChatUserActions.BLOCK || action === ChatUserActions.DELETE_MESSAGE) { + const executeAction = (actionOption?: string): void => { port?.postMessage({ type: 'executeChatAction', message, - action + action, + actionOption }); - } else if (action === ChatUserActions.REPORT_USER) { + }; + if (action === ChatUserActions.REPORT_USER) { const store = writable(null as null | ChatReportUserOptions); reportDialog.set({ callback: (selection) => { @@ -26,6 +33,26 @@ export function useBanHammer( }, optionStore: store }); + } else if (action === ChatUserActions.TIMEOUT_USER) { + const store = writable(null as null | string); + chatActionOptionDialog.set({ + title: 'Put User In Timeout', + confirmText: 'Timeout', + items: chatTimeoutOptions, + callback: executeAction, + optionStore: store + }); + } else if (action === ChatUserActions.ADD_MODERATOR) { + const store = writable(null as null | string); + chatActionOptionDialog.set({ + title: 'Add Moderator', + confirmText: 'Add', + items: chatModeratorRoleOptions, + callback: executeAction, + optionStore: store + }); + } else { + executeAction(); } } diff --git a/src/ts/chat-constants.ts b/src/ts/chat-constants.ts index e3c28136..b4bb36a5 100644 --- a/src/ts/chat-constants.ts +++ b/src/ts/chat-constants.ts @@ -50,6 +50,12 @@ export enum ChatUserActions { BLOCK = 'BLOCK', REPORT_USER = 'REPORT_USER', DELETE_MESSAGE = 'DELETE_MESSAGE', + PIN_MESSAGE = 'PIN_MESSAGE', + TIMEOUT_USER = 'TIMEOUT_USER', + HIDE_USER = 'HIDE_USER', + UNHIDE_USER = 'UNHIDE_USER', + ADD_MODERATOR = 'ADD_MODERATOR', + REMOVE_MODERATOR = 'REMOVE_MODERATOR', } export enum ChatReportUserOptions { @@ -74,6 +80,20 @@ export const chatReportUserOptions = [ { value: ChatReportUserOptions.MISINFORMATION, label: 'Misinformation' } ]; +export const chatTimeoutOptions = [ + { value: '10 seconds', label: '10 seconds' }, + { value: '1 minute', label: '1 minute' }, + { value: '5 minutes', label: '5 minutes' }, + { value: '10 minutes', label: '10 minutes' }, + { value: '30 minutes', label: '30 minutes' }, + { value: '24 hours', label: '24 hours' } +]; + +export const chatModeratorRoleOptions = [ + { value: 'Managing moderator', label: 'Managing moderator' }, + { value: 'Standard moderator', label: 'Standard moderator' } +]; + export const chatUserActionsItems = [ { value: ChatUserActions.BLOCK, @@ -101,6 +121,60 @@ export const chatUserActionsItems = [ success: 'Your message has been deleted.', error: 'There was an error deleting your message. Please try again later.' } + }, + { + value: ChatUserActions.PIN_MESSAGE, + text: 'Pin message', + icon: 'push_pin', + messages: { + success: 'The message has been pinned.', + error: 'There was an error pinning the message. Please try again later.' + } + }, + { + value: ChatUserActions.TIMEOUT_USER, + text: 'Put user in timeout', + icon: 'hourglass_empty', + messages: { + success: 'The user has been timed out.', + error: 'There was an error timing out the user. Please try again later.' + } + }, + { + value: ChatUserActions.HIDE_USER, + text: 'Hide user', + icon: 'remove_circle', + messages: { + success: 'The user has been hidden from this channel.', + error: 'There was an error hiding the user. Please try again later.' + } + }, + { + value: ChatUserActions.UNHIDE_USER, + text: 'Unhide user', + icon: 'add_circle', + messages: { + success: 'The user has been unhidden from this channel.', + error: 'There was an error unhiding the user. Please try again later.' + } + }, + { + value: ChatUserActions.ADD_MODERATOR, + text: 'Add moderator', + icon: 'person_add', + messages: { + success: 'The user has been added as a moderator.', + error: 'There was an error adding the moderator. Please try again later.' + } + }, + { + value: ChatUserActions.REMOVE_MODERATOR, + text: 'Remove moderator', + icon: 'person_remove', + messages: { + success: 'The moderator has been removed.', + error: 'There was an error removing the moderator. Please try again later.' + } } ]; diff --git a/src/ts/chat-parser.ts b/src/ts/chat-parser.ts index 352a8f39..0a712fff 100644 --- a/src/ts/chat-parser.ts +++ b/src/ts/chat-parser.ts @@ -244,6 +244,10 @@ const parseAddChatItemAction = (action: Ytc.AddChatItemAction, isReplay = false, }; const channelId = renderer.authorExternalChannelId; + const canDelete = messageRenderer.inlineActionButtons?.some( + (b) => b.buttonRenderer?.icon?.iconType === 'DELETE' + ) ?? false; + const item: Ytc.ParsedMessage = { author: { // It's apparently possible for there to be no author name (and only an author photo). @@ -257,7 +261,8 @@ const parseAddChatItemAction = (action: Ytc.AddChatItemAction, isReplay = false, timestamp: isReplay && timestampText != null ? timestampText : formatTimestamp(timestampUsec), showtime: isReplay ? liveTimeoutOrReplayMs : liveShowtimeMs, messageId: renderer.id, - params: messageRenderer.contextMenuEndpoint?.liveChatItemContextMenuEndpoint.params + params: messageRenderer.contextMenuEndpoint?.liveChatItemContextMenuEndpoint.params, + canDelete }; if (channelId != null) { item.author.url = `${currentDomain}/channel/${channelId}`; @@ -341,7 +346,10 @@ const parseAuthorBonkedAction = (action: Ytc.AuthorBonkedAction): Ytc.ParsedBonk const parseMessageDeletedAction = (action: Ytc.MessageDeletedAction): Ytc.ParsedDeleted | undefined => { return { replacedMessage: parseMessageRuns(action.deletedStateMessage.runs), - messageId: action.targetItemId + messageId: action.targetItemId, + viewOriginalText: action.showOriginalContentMessage + ? parseMessageRuns(action.showOriginalContentMessage.runs) + : undefined }; }; @@ -469,6 +477,12 @@ const processLiveAction = (action: Ytc.Action, isReplay: boolean, liveTimeoutMs: return parseAuthorBonkedAction(action.markChatItemsByAuthorAsDeletedAction); } else if (action.markChatItemAsDeletedAction) { return parseMessageDeletedAction(action.markChatItemAsDeletedAction); + } else if (action.removeChatItemAction) { + return { + replacedMessage: [], + messageId: action.removeChatItemAction.targetItemId, + pending: true + }; } }; diff --git a/src/ts/chat-utils.ts b/src/ts/chat-utils.ts index afb8568a..164df137 100644 --- a/src/ts/chat-utils.ts +++ b/src/ts/chat-utils.ts @@ -102,3 +102,12 @@ export const stripYoutubePlayerShell = (): void => { } }; + +export const buildDeletedObj = ( + deletion: Ytc.ParsedDeleted, + originalRuns: Ytc.ParsedRun[] +): Chat.MessageDeletedObj => ({ + replace: deletion.pending ? originalRuns : deletion.replacedMessage, + viewOriginalText: deletion.viewOriginalText, + pending: deletion.pending +}); diff --git a/src/ts/queue.ts b/src/ts/queue.ts index 1646840f..8db9c8bc 100644 --- a/src/ts/queue.ts +++ b/src/ts/queue.ts @@ -1,4 +1,5 @@ import { parseChatResponse } from './chat-parser'; +import { buildDeletedObj } from './chat-utils'; interface QueueItem { data: T, next?: QueueItem } export interface Queue { @@ -195,7 +196,7 @@ export function ytcQueue(isReplay = false): YtcQueue { } for (const d of deletions) { if (message.messageId !== d.messageId) continue; - messageAction.deleted = { replace: d.replacedMessage }; + messageAction.deleted = buildDeletedObj(d, message.message); return; } }; diff --git a/src/ts/storage.ts b/src/ts/storage.ts index 1c27ed03..2013d86a 100644 --- a/src/ts/storage.ts +++ b/src/ts/storage.ts @@ -68,6 +68,13 @@ export const reportDialog = writable(null as null | { callback: (selection: ChatReportUserOptions) => void; optionStore: Writable; }); +export const chatActionOptionDialog = writable(null as null | { + title: string; + confirmText: string; + items: Array<{ value: string, label: string }>; + callback: (selection: string) => void; + optionStore: Writable; +}); export const alertDialog = writable(null as null | { title: string; message: string; diff --git a/src/ts/typings/chat.d.ts b/src/ts/typings/chat.d.ts index 1d40cbc8..6dbb5c05 100644 --- a/src/ts/typings/chat.d.ts +++ b/src/ts/typings/chat.d.ts @@ -1,6 +1,8 @@ declare namespace Chat { interface MessageDeletedObj { replace: Ytc.ParsedRun[]; + viewOriginalText?: Ytc.ParsedRun[]; + pending?: boolean; } interface MessageAction { @@ -157,6 +159,7 @@ declare namespace Chat { message: Ytc.ParsedMessage; action: ChatUserActions; reportOption?: ChatReportUserOptions; + actionOption?: string; } type BackgroundMessage = diff --git a/src/ts/typings/ytc.d.ts b/src/ts/typings/ytc.d.ts index 4ea66250..ab429379 100644 --- a/src/ts/typings/ytc.d.ts +++ b/src/ts/typings/ytc.d.ts @@ -58,6 +58,7 @@ declare namespace Ytc { replayChatItemAction?: ReplayChatItemAction; markChatItemsByAuthorAsDeletedAction?: AuthorBonkedAction; markChatItemAsDeletedAction?: MessageDeletedAction; + removeChatItemAction?: RemoveChatItemAction; } /* @@ -85,6 +86,11 @@ declare namespace Ytc { externalChannelId: string; } + /** YTC removeChatItemAction object */ + interface RemoveChatItemAction { + targetItemId: string; + } + /** YTC markChatItemAsDeletedAction object. */ interface MessageDeletedAction extends IDeleted { /** ID of message to be deleted */ @@ -224,6 +230,12 @@ declare namespace Ytc { params: string; }; }; + /** Mod-only quick-action buttons (Remove/Timeout/Hide). */ + inlineActionButtons?: Array<{ + buttonRenderer?: { + icon?: { iconType?: string }; + }; + }>; /** Reply-to-superchat button on normal text messages. */ beforeContentButtons?: Array<{ buttonViewModel?: ReplyButtonViewModel; @@ -423,6 +435,8 @@ declare namespace Ytc { interface IDeleted { /** Message to replace deleted messages. */ deletedStateMessage: RunsObj; + /** Mod-only "View deleted message" affordance. */ + showOriginalContentMessage?: RunsObj; } /** Integer formatted as string for whatever reason */ @@ -512,6 +526,7 @@ declare namespace Ytc { params?: string; membershipGiftPurchase?: ParsedMembershipGiftPurchase; membershipGiftRedeem?: boolean; + canDelete?: boolean; /** Reply context when this message is a reply to a Super Chat. */ replyToSuperchat?: ParsedReplyToSuperchat; /** Opaque get_panel params for fetching this message's own reply thread (set on SCs). */ @@ -543,6 +558,9 @@ declare namespace Ytc { interface ParsedDeleted { replacedMessage: ParsedRun[]; messageId: string; + viewOriginalText?: ParsedRun[]; + /** No replacement text from YT — keep original text and mark as awaiting retraction (line-through). */ + pending?: boolean; } interface ParsedPinned {