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 ab89cdb5..cdce88b4 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) => { @@ -320,6 +321,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 +336,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; @@ -465,7 +467,7 @@ {#if $enableStickySuperchatBar} {/if} -
+
{#each messageActions as action (action.message.messageId)} diff --git a/src/components/Message.svelte b/src/components/Message.svelte index 1a4e3551..38d513dd 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,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, @@ -92,13 +101,13 @@ -
{#if !hideName && $showProfileIcons} {/if} -
+
{#if !hideName} {/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 bc414fb6..9a6cce7d 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 } : {}) }, 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/typings/chat.d.ts b/src/ts/typings/chat.d.ts index 1d40cbc8..03db68a5 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 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 {