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 1ed84b35..580f169d 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -22,6 +22,7 @@ ChatUserActions } from '../ts/chat-constants'; import { + buildDeletedObj, isAllEmoji, isChatMessage, isPrivileged, @@ -249,14 +250,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) => { @@ -353,6 +355,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) @@ -360,13 +370,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; @@ -515,7 +518,7 @@ {#if $enableStickySuperchatBar} {/if} -
+
{#each messageActions as action (action.message.messageId)} diff --git a/src/components/Message.svelte b/src/components/Message.svelte index 8204b9d8..546137f9 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, @@ -98,7 +107,7 @@ {#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/ts/chat-parser.ts b/src/ts/chat-parser.ts index 6734eaa5..1f8c2ac6 100644 --- a/src/ts/chat-parser.ts +++ b/src/ts/chat-parser.ts @@ -246,6 +246,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). @@ -259,7 +263,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}`; @@ -343,7 +348,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 }; }; @@ -471,6 +479,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 3f67d906..bf41e217 100644 --- a/src/ts/chat-utils.ts +++ b/src/ts/chat-utils.ts @@ -131,3 +131,12 @@ export const useReconnect = (connect: () => Promise): Re } }; }; + +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/messaging.ts b/src/ts/messaging.ts index 224a07a0..c90fafda 100644 --- a/src/ts/messaging.ts +++ b/src/ts/messaging.ts @@ -66,6 +66,7 @@ const buildInnertubeHeaders = (ytcfg: YtCfg) => { const visitorId = (ytcfg as any)?.data_?.VISITOR_DATA ?? ytcfg.data_.INNERTUBE_CONTEXT?.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', @@ -74,6 +75,7 @@ const buildInnertubeHeaders = (ytcfg: YtCfg) => { ...(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/queue.ts b/src/ts/queue.ts index 358d3983..07282082 100644 --- a/src/ts/queue.ts +++ b/src/ts/queue.ts @@ -1,5 +1,6 @@ import { parseChatResponse } from './chat-parser'; import type { Chat } from './typings/chat'; +import { buildDeletedObj } from './chat-utils'; interface QueueItem { data: T, next?: QueueItem } export interface Queue { @@ -196,7 +197,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 7d5127fa..d44e3881 100644 --- a/src/ts/typings/chat.d.ts +++ b/src/ts/typings/chat.d.ts @@ -4,6 +4,8 @@ import type { Unsubscriber, YtcQueue } from '../queue'; 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 fb9289f5..b42b71e4 100644 --- a/src/ts/typings/ytc.d.ts +++ b/src/ts/typings/ytc.d.ts @@ -62,6 +62,7 @@ declare namespace Ytc { replayChatItemAction?: ReplayChatItemAction; markChatItemsByAuthorAsDeletedAction?: AuthorBonkedAction; markChatItemAsDeletedAction?: MessageDeletedAction; + removeChatItemAction?: RemoveChatItemAction; } /* @@ -89,6 +90,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 */ @@ -223,6 +229,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; @@ -422,6 +434,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 */ @@ -511,6 +525,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). */ @@ -542,6 +557,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 {