diff --git a/src/components/ChatSummary.svelte b/src/components/ChatSummary.svelte new file mode 100644 index 00000000..fded6f1e --- /dev/null +++ b/src/components/ChatSummary.svelte @@ -0,0 +1,71 @@ + + +{#if !dismissed} +
+
+
+ + + {#if shorten} + expand_more + {:else} + expand_less + {/if} + + + {#each summary.item.header as run} + {#if run.type === 'text'} + {run.text} + {/if} + {/each} +
+
+ + { dismissed = true; }} + > + close + + Dismiss + +
+
+ {#if !shorten && !dismissed} +
+ +
+
+ +
+ {/if} +
+{/if} diff --git a/src/components/Hyperchat.svelte b/src/components/Hyperchat.svelte index c92607f3..6fe727d9 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -6,6 +6,7 @@ import WelcomeMessage from './WelcomeMessage.svelte'; import Message from './Message.svelte'; import PinnedMessage from './PinnedMessage.svelte'; + import ChatSummary from './ChatSummary.svelte'; import PaidMessage from './PaidMessage.svelte'; import MembershipItem from './MembershipItem.svelte'; import ReportBanDialog from './ReportBanDialog.svelte'; @@ -28,6 +29,7 @@ showUsernames, showTimestamps, showUserBadges, + showChatSummary, refreshScroll, emojiRenderMode, useSystemEmojis, @@ -53,6 +55,7 @@ let messageActions: (Chat.MessageAction | Welcome)[] = []; const messageKeys = new Set(); let pinned: Ytc.ParsedPinned | null; + let summary: Ytc.ParsedSummary | null; let div: HTMLElement; let isAtBottom = true; let truncateInterval: number; @@ -183,6 +186,9 @@ case 'delete': onDelete(action.deletion); break; + case 'summary': + summary = action; + break; case 'pin': pinned = action; break; @@ -393,11 +399,18 @@ {/each} - {#if pinned} + {#if (summary && $showChatSummary) || pinned}
-
- -
+ {#if summary && $showChatSummary} +
+ +
+ {/if} + {#if pinned} +
+ +
+ {/if}
{/if} {#if !isAtBottom} diff --git a/src/components/settings/InterfaceSettings.svelte b/src/components/settings/InterfaceSettings.svelte index 7ddbdd36..0c90f3e8 100644 --- a/src/components/settings/InterfaceSettings.svelte +++ b/src/components/settings/InterfaceSettings.svelte @@ -11,7 +11,8 @@ useSystemEmojis, isDark, enableStickySuperchatBar, - enableHighlightedMentions + enableHighlightedMentions, + showChatSummary } from '../../ts/storage'; import { themeItems, emojiRenderItems } from '../../ts/chat-constants'; import Card from '../common/Card.svelte'; @@ -59,6 +60,7 @@ + diff --git a/src/ts/chat-parser.ts b/src/ts/chat-parser.ts index 1c2443d8..a026e51b 100644 --- a/src/ts/chat-parser.ts +++ b/src/ts/chat-parser.ts @@ -61,6 +61,51 @@ const parseMessageRuns = (runs?: Ytc.MessageRun[]): Ytc.ParsedRun[] => { return parsedRuns; }; +// takes an array of runs, finds newline-only runs, and splits the array by them, up to maxSplit times +// final output will have maximum length of maxSplit + 1 +// maxSplit = -1 will have no limit for splits +const splitRunsByNewline = (runs: Ytc.ParsedRun[], maxSplit: number = -1): Ytc.ParsedRun[][] => + runs.reduce((acc: Ytc.ParsedRun[][], run: Ytc.ParsedRun) => { + if (run.type === 'text' && run.text === '\n' && (maxSplit == -1 || acc.length <= maxSplit)) { + acc.push([]); + } else { + acc[acc.length - 1].push(run); + } + return acc; + }, [[]]); + +const parseChatSummary = (renderer: Ytc.AddChatItem, isEphemeral: boolean, bannerTimeoutMs: number): Ytc.ParsedSummary | undefined => { + if (!renderer.liveChatBannerChatSummaryRenderer) { + return; + } + const baseRenderer = renderer.liveChatBannerChatSummaryRenderer!; + const runs = parseMessageRuns(renderer.liveChatBannerChatSummaryRenderer?.chatSummary.runs); + const splitRuns = splitRunsByNewline(runs, 2); + if (splitRuns.length < 3) { + // YT probably changed the format, refuse to do anything to avoid breaking + return; + } + const subheader = splitRuns[1].map(run => { + if (run.type === 'text') { + // turn subheader into a link to YT's support page detailing the AI summary feature + return { type: 'link', text: run.text, url: 'https://support.google.com/youtube/thread/18138167?msgid=284199217' } as Ytc.ParsedLinkRun; + } else { + return run; + } + }); + const item: Ytc.ParsedSummary = { + type: 'summary', + item: { + header: splitRuns[0], + subheader: subheader, + message: splitRuns[2], + }, + id: baseRenderer.liveChatSummaryId, + showtime: isEphemeral ? (bannerTimeoutMs / 1000) : 0, + }; + return item; +} + const parseAddChatItemAction = (action: Ytc.AddChatItemAction, isReplay = false, liveTimeoutOrReplayMs = 0): Ytc.ParsedMessage | undefined => { const actionItem = action.item; const renderer = actionItem.liveChatTextMessageRenderer ?? @@ -183,8 +228,11 @@ const parseMessageDeletedAction = (action: Ytc.MessageDeletedAction): Ytc.Parsed }; }; -const parsePinnedMessageAction = (action: Ytc.AddPinnedAction): Ytc.ParsedPinned | undefined => { +const parseBannerAction = (action: Ytc.AddPinnedAction): Ytc.ParsedPinned | Ytc.ParsedSummary | undefined => { const baseRenderer = action.bannerRenderer.liveChatBannerRenderer; + if (baseRenderer.contents.liveChatBannerChatSummaryRenderer) { + return parseChatSummary(baseRenderer.contents, action.bannerProperties?.isEphemeral ?? false, action.bannerProperties?.bannerTimeoutMs ?? 0); + } const parsedContents = parseAddChatItemAction( { item: baseRenderer.contents }, true ); @@ -229,7 +277,7 @@ const processCommonAction = ( if (action.addChatItemAction) { return parseAddChatItemAction(action.addChatItemAction, isReplay, liveTimeoutOrReplayMs); } else if (action.addBannerToLiveChatCommand) { - return parsePinnedMessageAction(action.addBannerToLiveChatCommand); + return parseBannerAction(action.addBannerToLiveChatCommand); } else if (action.removeBannerForLiveChatCommand) { return { type: 'unpin' } as const; } else if (action.addLiveChatTickerItemAction) { diff --git a/src/ts/storage.ts b/src/ts/storage.ts index 1578e43d..1b8c7e89 100644 --- a/src/ts/storage.ts +++ b/src/ts/storage.ts @@ -49,6 +49,7 @@ export const showProfileIcons = stores.addSyncStore('hc.messages.showProfileIcon export const showUsernames = stores.addSyncStore('hc.messages.showUsernames', true); export const showTimestamps = stores.addSyncStore('hc.messages.showTimestamps', false); export const showUserBadges = stores.addSyncStore('hc.messages.showUserBadges', true); +export const showChatSummary = stores.addSyncStore('hc.messages.showChatSummary', true); export const lastClosedVersion = stores.addSyncStore('hc.lastClosedVersion', ''); export const showOnlyMemberChat = stores.addSyncStore('hc.showOnlyMemberChat', false); export const emojiRenderMode = stores.addSyncStore('hc.emojiRenderMode', YoutubeEmojiRenderMode.SHOW_ALL); diff --git a/src/ts/typings/ytc.d.ts b/src/ts/typings/ytc.d.ts index 0674c419..3daf38cf 100644 --- a/src/ts/typings/ytc.d.ts +++ b/src/ts/typings/ytc.d.ts @@ -88,6 +88,10 @@ declare namespace Ytc { }; }; }; + bannerProperties?: { + isEphemeral: boolean; + bannerTimeoutMs: number; + } } interface AddTickerAction { @@ -227,6 +231,15 @@ declare namespace Ytc { }; } + interface ChatSummaryRenderer { + liveChatSummaryId: string; + chatSummary: RunsObj; + icon?: { + /** Unlocalized string */ + iconType: string; + }; + } + interface PlaceholderRenderer { // No idea what the purpose of this is id: string; timestampUsec: IntString; @@ -248,6 +261,8 @@ declare namespace Ytc { liveChatSponsorshipsGiftPurchaseAnnouncementRenderer?: MembershipGiftPurchaseRenderer; /** Membership gift redemption */ liveChatSponsorshipsGiftRedemptionAnnouncementRenderer?: TextMessageRenderer; + /** AI Chat Summary */ + liveChatBannerChatSummaryRenderer?: ChatSummaryRenderer; /** ??? */ liveChatPlaceholderItemRenderer?: PlaceholderRenderer; } @@ -365,13 +380,24 @@ declare namespace Ytc { }; } + interface ParsedSummary { + type: 'summary'; + item: { + header: ParsedRun[]; + subheader: ParsedRun[]; + message: ParsedRun[]; + }; + id: string; + showtime: number; + } + interface ParsedTicker extends ParsedMessage { type: 'ticker'; tickerDuration: number; detailText?: string; } - type ParsedMisc = ParsedPinned | { type: 'unpin'}; + type ParsedMisc = ParsedPinned | ParsedSummary | { type: 'unpin' }; type ParsedTimedItem = ParsedMessage | ParsedTicker;