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;