From 9c6314a439e063110a73115d67e7c41a25cca102 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Wed, 13 May 2026 04:17:29 -0700 Subject: [PATCH 1/8] Support message deletions Includes moderation deletions, self-deletions, etc. --- postcss.config.js | 1 + src/components/Hyperchat.svelte | 22 ++++++++++--------- src/components/Message.svelte | 36 ++++++++++++++++++++++--------- src/components/common/Menu.svelte | 4 ++-- src/scripts/chat-interceptor.ts | 2 ++ src/ts/chat-parser.ts | 18 ++++++++++++++-- src/ts/chat-utils.ts | 9 ++++++++ src/ts/queue.ts | 3 ++- src/ts/typings/chat.d.ts | 2 ++ src/ts/typings/ytc.d.ts | 18 ++++++++++++++++ 10 files changed, 90 insertions(+), 25 deletions(-) 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 a0588a33..26b3f874 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -23,7 +23,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 Button from 'smelte/src/components/Button'; import { theme, @@ -171,14 +171,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) => { @@ -262,6 +263,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) @@ -269,13 +278,6 @@ color: response.success ? 'primary' : 'error' }; if (response.success) { - if (response.action === ChatUserActions.DELETE_MESSAGE) { - onDelete({ - messageId: response.message.messageId, - replacedMessage: [{ text: '[message retracted]' }] - }); - break; - } messageActions = messageActions.filter( (a) => { if (isWelcome(a)) return true; diff --git a/src/components/Message.svelte b/src/components/Message.svelte index f2321a68..d0db4141 100644 --- a/src/components/Message.svelte +++ b/src/components/Message.svelte @@ -55,9 +55,11 @@ }); $: nameColorClass = generateNameColorClass(member, moderator, owner, forceDark); - $: if (deleted != null) { - message.message = deleted.replace; - } + let showOriginal = false; + $: displayRuns = deleted != null && !showOriginal ? deleted.replace : message.message; + $: hideOriginalRuns = deleted?.viewOriginalText?.slice(0, 1).map( + (r) => r.type === 'text' ? { ...r, text: 'Hide deleted message' } : r + ); $: displayAuthorName = formatAuthorName(message.author.name); $: showUserMargin = $showProfileIcons || $showUsernames || $showTimestamps || @@ -67,10 +69,10 @@ $: 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; + return !isSelf; }); $: menuItems = visibleActions.map((d) => ({ icon: d.icon, @@ -81,8 +83,8 @@ -
{#if !hideName && $showProfileIcons} {/if} + {#if deleted?.viewOriginalText} + + {/if} {#if message.membershipGiftRedeem} 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 4dda76a6..0400954c 100644 --- a/src/scripts/chat-interceptor.ts +++ b/src/scripts/chat-interceptor.ts @@ -147,6 +147,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; const heads = { headers: { 'Content-Type': 'application/json', @@ -155,6 +156,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 } : {}) }, diff --git a/src/ts/chat-parser.ts b/src/ts/chat-parser.ts index 4eed8320..c32b557c 100644 --- a/src/ts/chat-parser.ts +++ b/src/ts/chat-parser.ts @@ -182,6 +182,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). @@ -195,7 +199,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}`; @@ -256,7 +261,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 }; }; @@ -377,6 +385,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 0cf7bff0..20a095e3 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 38ad81ec..e1fc1653 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/typings/chat.d.ts b/src/ts/typings/chat.d.ts index 76525eff..f2785248 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 { diff --git a/src/ts/typings/ytc.d.ts b/src/ts/typings/ytc.d.ts index 42804a9b..2045a969 100644 --- a/src/ts/typings/ytc.d.ts +++ b/src/ts/typings/ytc.d.ts @@ -46,6 +46,7 @@ declare namespace Ytc { replayChatItemAction?: ReplayChatItemAction; markChatItemsByAuthorAsDeletedAction?: AuthorBonkedAction; markChatItemAsDeletedAction?: MessageDeletedAction; + removeChatItemAction?: RemoveChatItemAction; } /* @@ -73,6 +74,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 */ @@ -209,6 +215,12 @@ declare namespace Ytc { params: string; }; }; + /** Mod-only quick-action buttons (Remove/Timeout/Hide). */ + inlineActionButtons?: Array<{ + buttonRenderer?: { + icon?: { iconType?: string }; + }; + }>; } interface IPaidRenderer extends TextMessageRenderer { @@ -369,6 +381,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 */ @@ -458,6 +472,7 @@ declare namespace Ytc { params?: string; membershipGiftPurchase?: ParsedMembershipGiftPurchase; membershipGiftRedeem?: boolean; + canDelete?: boolean; } interface ParsedBonk { @@ -468,6 +483,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 { From dce3e0febfcff9f9e3c9920234ca56530d5867b3 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Wed, 13 May 2026 05:41:15 -0700 Subject: [PATCH 2/8] Make chat start out at the bottom of the chatbox rather than the top Also solves an issue where the message menu would cause it to run out of space and overflow --- src/components/Hyperchat.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Hyperchat.svelte b/src/components/Hyperchat.svelte index 26b3f874..fb41fb21 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -406,7 +406,7 @@ {#if $enableStickySuperchatBar} {/if} -
+
{#each messageActions as action (action.message.messageId)} From a8b5f91cd7349651f6656a16f14b8b7813ff34e3 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Wed, 13 May 2026 06:35:35 -0700 Subject: [PATCH 3/8] Fix menu opacity on deleted messages --- src/components/Message.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Message.svelte b/src/components/Message.svelte index 19ec5b74..1e9d71c8 100644 --- a/src/components/Message.svelte +++ b/src/components/Message.svelte @@ -95,12 +95,12 @@
{#if !hideName && $showProfileIcons} {/if} -
+
{#if !hideName} Date: Wed, 13 May 2026 06:38:01 -0700 Subject: [PATCH 4/8] Pointer cursor for "View deleted message" --- src/components/Message.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Message.svelte b/src/components/Message.svelte index 1e9d71c8..963e1068 100644 --- a/src/components/Message.svelte +++ b/src/components/Message.svelte @@ -195,7 +195,7 @@ runs={showOriginal ? hideOriginalRuns : deleted.viewOriginalText} {forceDark} {forceTLColor} - class="underline" + class="underline cursor-pointer" /> {/if} From 6f654219e56f4e1dedf49f6067539fd7b908fca7 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Wed, 13 May 2026 06:51:21 -0700 Subject: [PATCH 5/8] Avoid removing extra runs in viewOriginalText --- src/components/Message.svelte | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/Message.svelte b/src/components/Message.svelte index 963e1068..38d513dd 100644 --- a/src/components/Message.svelte +++ b/src/components/Message.svelte @@ -61,9 +61,16 @@ let showOriginal = false; $: displayRuns = deleted != null && !showOriginal ? deleted.replace : message.message; - $: hideOriginalRuns = deleted?.viewOriginalText?.slice(0, 1).map( - (r) => r.type === 'text' ? { ...r, text: 'Hide deleted message' } : r - ); + // 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); $: showUserMargin = $showProfileIcons || $showUsernames || $showTimestamps || @@ -192,7 +199,7 @@ class="ml-1 align-middle text-xs cursor-pointer text-deleted-light dark:text-deleted-dark bg-transparent border-0 p-0" > Date: Wed, 20 May 2026 12:21:05 -0400 Subject: [PATCH 6/8] document mod actions --- docs/YOUTUBE_ACTIONS.md | 59 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/YOUTUBE_ACTIONS.md b/docs/YOUTUBE_ACTIONS.md index 685e4dac..d09a2aab 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,60 @@ 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. + ## Keep Requests Correlated If you proxy Innertube calls through a background/service worker, keep request/response events correlated by request id. @@ -98,4 +152,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 - From 8fb277857a5e4665fe25dce3831995530418137f Mon Sep 17 00:00:00 2001 From: Kento Nishi Date: Wed, 20 May 2026 12:34:20 -0400 Subject: [PATCH 7/8] register mod action plan --- docs/YOUTUBE_ACTIONS.md | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/YOUTUBE_ACTIONS.md b/docs/YOUTUBE_ACTIONS.md index d09a2aab..e8405186 100644 --- a/docs/YOUTUBE_ACTIONS.md +++ b/docs/YOUTUBE_ACTIONS.md @@ -122,6 +122,55 @@ The hide/unhide flow therefore has two proven unhide sources: the context menu's 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. + - `HIDE_USER`: may remove that author's visible messages, matching the user-visible effect of hiding. + - pin, timeout, add moderator, remove moderator, unhide, and report: 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. From 9f536938f357fe6b4656573eb82b033a18d5e395 Mon Sep 17 00:00:00 2001 From: Kento Nishi Date: Wed, 20 May 2026 12:37:41 -0400 Subject: [PATCH 8/8] add mod actions --- docs/YOUTUBE_ACTIONS.md | 3 +- src/components/Hyperchat.svelte | 7 + src/components/Message.svelte | 5 +- src/components/ReportBanDialog.svelte | 19 +++ src/scripts/chat-interceptor.ts | 203 +++++++++++++++++++------- src/ts/chat-actions.ts | 37 ++++- src/ts/chat-constants.ts | 74 ++++++++++ src/ts/storage.ts | 7 + src/ts/typings/chat.d.ts | 1 + 9 files changed, 296 insertions(+), 60 deletions(-) diff --git a/docs/YOUTUBE_ACTIONS.md b/docs/YOUTUBE_ACTIONS.md index e8405186..1a7ea4e9 100644 --- a/docs/YOUTUBE_ACTIONS.md +++ b/docs/YOUTUBE_ACTIONS.md @@ -156,8 +156,9 @@ Implementation shape: 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, unhide, and report: show success/failure only. + - pin, timeout, add moderator, remove moderator, and unhide: show success/failure only. 8. Implement on MV2 first, then merge forward: - HyperChat `mv2` - HyperChat `main` diff --git a/src/components/Hyperchat.svelte b/src/components/Hyperchat.svelte index bc13bd15..da41f677 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -303,6 +303,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); @@ -334,6 +340,7 @@ }); break; } + if (!removesAuthorMessages(response.action)) break; messageActions = messageActions.filter( (a) => { if (isWelcome(a)) return true; diff --git a/src/components/Message.svelte b/src/components/Message.svelte index 61452897..d13a9e3f 100644 --- a/src/components/Message.svelte +++ b/src/components/Message.svelte @@ -74,7 +74,10 @@ if (isSelf) { return d.value === ChatUserActions.DELETE_MESSAGE && message.params != null; } - return d.value !== ChatUserActions.DELETE_MESSAGE; + if (message.params == null) { + return d.value === ChatUserActions.BLOCK || d.value === ChatUserActions.REPORT_USER; + } + return true; }); $: menuItems = visibleActions.map((d) => ({ icon: d.icon, diff --git a/src/components/ReportBanDialog.svelte b/src/components/ReportBanDialog.svelte index 4aea12be..844a9437 100644 --- a/src/components/ReportBanDialog.svelte +++ b/src/components/ReportBanDialog.svelte @@ -3,6 +3,7 @@ import type { ChatReportUserOptions } from '../ts/chat-constants'; import { reportDialog, + chatActionOptionDialog, alertDialog } from '../ts/storage'; import Dialog from './common/Dialog.svelte'; @@ -10,6 +11,7 @@ import RadioGroupStore from './common/RadioGroupStore.svelte'; import Button from 'smelte/src/components/Button'; $: optionStore = $reportDialog?.optionStore as Writable; + $: actionOptionStore = $chatActionOptionDialog?.optionStore as Writable; @@ -29,6 +31,23 @@
+ + {$chatActionOptionDialog?.title} +
+ +
+
+ +
+
+ {$alertDialog?.title}
diff --git a/src/scripts/chat-interceptor.ts b/src/scripts/chat-interceptor.ts index bc414fb6..c5d283b7 100644 --- a/src/scripts/chat-interceptor.ts +++ b/src/scripts/chat-interceptor.ts @@ -237,25 +237,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 +287,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 +394,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 +493,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/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..a4cb9596 100644 --- a/src/ts/typings/chat.d.ts +++ b/src/ts/typings/chat.d.ts @@ -157,6 +157,7 @@ declare namespace Chat { message: Ytc.ParsedMessage; action: ChatUserActions; reportOption?: ChatReportUserOptions; + actionOption?: string; } type BackgroundMessage =