From 9c6314a439e063110a73115d67e7c41a25cca102 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Wed, 13 May 2026 04:17:29 -0700 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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" >