diff --git a/Extensions/UserScript/Return Youtube Dislike.user.js b/Extensions/UserScript/Return Youtube Dislike.user.js
index 7a728d9..41f89c6 100644
--- a/Extensions/UserScript/Return Youtube Dislike.user.js
+++ b/Extensions/UserScript/Return Youtube Dislike.user.js
@@ -442,7 +442,12 @@ function createVoteClient({
return current;
}
- return { ensureRegistered, submitVote };
+ async function getRegisteredUser() {
+ const credential = await credentialStore.load();
+ return isConfirmedCredential(credential) ? { userId: credential.userId } : null;
+ }
+
+ return { ensureRegistered, getRegisteredUser, submitVote };
}
diff --git a/Extensions/combined/_locales/en/messages.json b/Extensions/combined/_locales/en/messages.json
index 5001b5e..2a7460f 100644
--- a/Extensions/combined/_locales/en/messages.json
+++ b/Extensions/combined/_locales/en/messages.json
@@ -587,6 +587,104 @@
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
+ "discussion_title": { "message": "RYD comments" },
+ "discussion_subtitle": { "message": "A community discussion hosted by Return YouTube Dislike." },
+ "discussion_independent": { "message": "These comments are separate from YouTube." },
+ "discussion_open": { "message": "Open RYD comments" },
+ "discussion_close": { "message": "Close" },
+ "discussion_new": { "message": "Newest" },
+ "discussion_top": { "message": "Top" },
+ "discussion_sort": { "message": "Sort comments" },
+ "discussion_refresh": { "message": "Refresh" },
+ "discussion_loading": { "message": "Loading comments…" },
+ "discussion_empty": { "message": "No comments yet. Start the conversation." },
+ "discussion_replyEmpty": { "message": "No replies yet." },
+ "discussion_comment": { "message": "Comment" },
+ "discussion_post": { "message": "Post comment" },
+ "discussion_reply": { "message": "Reply" },
+ "discussion_postReply": { "message": "Post reply" },
+ "discussion_cancel": { "message": "Cancel" },
+ "discussion_save": { "message": "Save" },
+ "discussion_edit": { "message": "Edit" },
+ "discussion_delete": { "message": "Delete" },
+ "discussion_deleteConfirm": { "message": "Delete this comment?" },
+ "discussion_deleted": { "message": "Comment deleted" },
+ "discussion_edited": { "message": "edited" },
+ "discussion_replies": { "message": "Replies ($1)" },
+ "discussion_more": { "message": "Load more" },
+ "discussion_like": { "message": "Like" },
+ "discussion_dislike": { "message": "Dislike" },
+ "discussion_reacting": { "message": "Saving reaction…" },
+ "discussion_signInRequired": { "message": "Sign in from the extension popup to continue." },
+ "discussion_tierRequired": { "message": "Posting is available to active Commenter members and above." },
+ "discussion_join": { "message": "Become a Commenter" },
+ "discussion_gate": { "message": "Everyone can read and react. Active Commenter members and above can post." },
+ "discussion_nicknameRequired": { "message": "Choose your public RYD nickname before posting." },
+ "discussion_nicknameLabel": { "message": "Public RYD nickname" },
+ "discussion_nicknameHint": {
+ "message": "3–32 letters, numbers, spaces, dots, dashes or underscores. This name will appear with your comments."
+ },
+ "discussion_nicknameSave": { "message": "Save nickname" },
+ "discussion_nicknameTaken": { "message": "This nickname is already taken. Choose another." },
+ "discussion_nicknameInvalid": { "message": "Use 3–32 letters, numbers, spaces, dots, dashes or underscores." },
+ "discussion_nicknameChange": { "message": "Change nickname" },
+ "discussion_as": { "message": "Commenting as $1" },
+ "discussion_placeholder": { "message": "Add to the discussion…" },
+ "discussion_characters": { "message": "$1 / $2" },
+ "discussion_report": { "message": "Report" },
+ "discussion_reportTitle": { "message": "Report comment" },
+ "discussion_reportReason": { "message": "Reason" },
+ "discussion_reportSpam": { "message": "Spam" },
+ "discussion_reportAbuse": { "message": "Harassment or hate" },
+ "discussion_reportOther": { "message": "Other" },
+ "discussion_reportDetails": { "message": "Additional details (optional)" },
+ "discussion_reportSend": { "message": "Send report" },
+ "discussion_reportDone": { "message": "Report received. Thank you." },
+ "discussion_moderation": { "message": "Moderation" },
+ "discussion_queueEmpty": { "message": "No reports to review." },
+ "discussion_hide": { "message": "Hide comment" },
+ "discussion_restore": { "message": "Restore comment" },
+ "discussion_dismiss": { "message": "Dismiss reports" },
+ "discussion_ban": { "message": "Ban author" },
+ "discussion_unban": { "message": "Unban author" },
+ "discussion_moderationReason": { "message": "Moderation reason" },
+ "discussion_moderationSaved": { "message": "Moderation action saved." },
+ "discussion_reported": { "message": "$1 reports" },
+ "discussion_unavailable": { "message": "RYD comments are unavailable for this video." },
+ "discussion_commentUnavailable": { "message": "This comment is no longer available." },
+ "discussion_error": { "message": "Unable to connect. Please try again." },
+ "discussion_rateLimited": { "message": "Please wait a moment before trying again." },
+ "discussion_conflict": { "message": "This comment changed. Refresh and try again." },
+ "discussion_forbidden": { "message": "This action is not available for your account." },
+ "discussion_retry": { "message": "Try again" },
+ "discussion_notifications": { "message": "Notifications" },
+ "discussion_notificationEmpty": { "message": "You're all caught up." },
+ "discussion_notificationReply": { "message": "$1 replied to your comment" },
+ "discussion_notificationLike": { "message": "Someone liked your comment" },
+ "discussion_notificationLikes": { "message": "Your comment received $1 likes" },
+ "discussion_notificationDislike": { "message": "Someone disliked your comment" },
+ "discussion_notificationReaction": { "message": "Someone reacted to your comment" },
+ "discussion_someone": { "message": "Someone" },
+ "discussion_markRead": { "message": "Mark read" },
+ "discussion_markAllRead": { "message": "Mark all read" },
+ "discussion_view": { "message": "View discussion" },
+ "discussion_unread": { "message": "$1 unread notifications" },
+ "discussion_signedOutInbox": { "message": "Sign in to see replies and reactions to your comments." },
+ "discussion_notificationUnavailable": { "message": "This discussion is no longer available." },
+ "discussion_discussionTab": { "message": "Discussion" },
+ "discussion_nickname": { "message": "Nickname" },
+ "discussion_reportCount": { "message": "Reports" },
+ "discussion_reportStatus": { "message": "Report status" },
+ "discussion_openReports": { "message": "Open reports" },
+ "discussion_resolvedReports": { "message": "Dismissed reports" },
+ "discussion_author": { "message": "Author" },
+ "discussion_me": { "message": "You" },
+ "discussion_saving": { "message": "Saving…" },
+ "discussion_confirm": { "message": "Confirm" },
+ "discussion_notificationUpdated": { "message": "New activity on your comments" },
+ "discussion_showThread": { "message": "Show thread" },
+ "discussion_loadReplies": { "message": "Show replies ($1)" },
+ "patreonTierCommenter": { "message": "Commenter" },
"changelog_brand_logo_alt": {
"message": "Return YouTube Dislike logo"
}
diff --git a/Extensions/combined/_locales/ru/messages.json b/Extensions/combined/_locales/ru/messages.json
index 0e4ff5d..a38dfd6 100644
--- a/Extensions/combined/_locales/ru/messages.json
+++ b/Extensions/combined/_locales/ru/messages.json
@@ -566,6 +566,108 @@
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
+ "discussion_title": { "message": "Комментарии RYD" },
+ "discussion_subtitle": { "message": "Обсуждение сообщества Return YouTube Dislike." },
+ "discussion_independent": { "message": "Эти комментарии не связаны с комментариями YouTube." },
+ "discussion_open": { "message": "Открыть комментарии RYD" },
+ "discussion_close": { "message": "Закрыть" },
+ "discussion_new": { "message": "Сначала новые" },
+ "discussion_top": { "message": "Популярные" },
+ "discussion_sort": { "message": "Сортировка комментариев" },
+ "discussion_refresh": { "message": "Обновить" },
+ "discussion_loading": { "message": "Загрузка комментариев…" },
+ "discussion_empty": { "message": "Комментариев пока нет. Начните обсуждение." },
+ "discussion_replyEmpty": { "message": "Ответов пока нет." },
+ "discussion_comment": { "message": "Комментарий" },
+ "discussion_post": { "message": "Отправить" },
+ "discussion_reply": { "message": "Ответить" },
+ "discussion_postReply": { "message": "Отправить ответ" },
+ "discussion_cancel": { "message": "Отмена" },
+ "discussion_save": { "message": "Сохранить" },
+ "discussion_edit": { "message": "Изменить" },
+ "discussion_delete": { "message": "Удалить" },
+ "discussion_deleteConfirm": { "message": "Удалить этот комментарий?" },
+ "discussion_deleted": { "message": "Комментарий удалён" },
+ "discussion_edited": { "message": "изменён" },
+ "discussion_replies": { "message": "Ответы ($1)" },
+ "discussion_more": { "message": "Показать ещё" },
+ "discussion_like": { "message": "Нравится" },
+ "discussion_dislike": { "message": "Не нравится" },
+ "discussion_reacting": { "message": "Сохранение реакции…" },
+ "discussion_signInRequired": { "message": "Чтобы продолжить, войдите в аккаунт через меню расширения." },
+ "discussion_tierRequired": { "message": "Писать могут участники с активным уровнем Commenter и выше." },
+ "discussion_join": { "message": "Стать участником Commenter" },
+ "discussion_gate": {
+ "message": "Читать и оценивать могут все. Писать могут участники с активным уровнем Commenter и выше."
+ },
+ "discussion_nicknameRequired": { "message": "Перед отправкой выберите публичный псевдоним RYD." },
+ "discussion_nicknameLabel": { "message": "Публичный псевдоним RYD" },
+ "discussion_nicknameHint": {
+ "message": "От 3 до 32 букв, цифр, пробелов, точек, дефисов или знаков подчёркивания. Это имя будет видно рядом с вашими комментариями."
+ },
+ "discussion_nicknameSave": { "message": "Сохранить псевдоним" },
+ "discussion_nicknameTaken": { "message": "Этот псевдоним уже занят. Выберите другой." },
+ "discussion_nicknameInvalid": {
+ "message": "Используйте от 3 до 32 букв, цифр, пробелов, точек, дефисов или знаков подчёркивания."
+ },
+ "discussion_nicknameChange": { "message": "Изменить псевдоним" },
+ "discussion_as": { "message": "Вы пишете как $1" },
+ "discussion_placeholder": { "message": "Добавьте комментарий…" },
+ "discussion_characters": { "message": "$1 / $2" },
+ "discussion_report": { "message": "Пожаловаться" },
+ "discussion_reportTitle": { "message": "Жалоба на комментарий" },
+ "discussion_reportReason": { "message": "Причина" },
+ "discussion_reportSpam": { "message": "Спам" },
+ "discussion_reportAbuse": { "message": "Оскорбления или ненависть" },
+ "discussion_reportOther": { "message": "Другое" },
+ "discussion_reportDetails": { "message": "Дополнительные сведения (необязательно)" },
+ "discussion_reportSend": { "message": "Отправить жалобу" },
+ "discussion_reportDone": { "message": "Жалоба отправлена. Спасибо." },
+ "discussion_moderation": { "message": "Модерация" },
+ "discussion_queueEmpty": { "message": "Новых жалоб нет." },
+ "discussion_hide": { "message": "Скрыть комментарий" },
+ "discussion_restore": { "message": "Восстановить комментарий" },
+ "discussion_dismiss": { "message": "Отклонить жалобы" },
+ "discussion_ban": { "message": "Заблокировать автора" },
+ "discussion_unban": { "message": "Разблокировать автора" },
+ "discussion_moderationReason": { "message": "Причина модерации" },
+ "discussion_moderationSaved": { "message": "Действие модератора сохранено." },
+ "discussion_reported": { "message": "Жалоб: $1" },
+ "discussion_unavailable": { "message": "Комментарии RYD недоступны для этого видео." },
+ "discussion_commentUnavailable": { "message": "Этот комментарий больше недоступен." },
+ "discussion_error": { "message": "Не удалось подключиться. Попробуйте ещё раз." },
+ "discussion_rateLimited": { "message": "Немного подождите и попробуйте снова." },
+ "discussion_conflict": { "message": "Комментарий изменился. Обновите страницу и попробуйте снова." },
+ "discussion_forbidden": { "message": "Это действие недоступно для вашего аккаунта." },
+ "discussion_retry": { "message": "Повторить" },
+ "discussion_notifications": { "message": "Уведомления" },
+ "discussion_notificationEmpty": { "message": "Новых уведомлений нет." },
+ "discussion_notificationReply": { "message": "$1 ответил(а) на ваш комментарий" },
+ "discussion_notificationLike": { "message": "Кому-то понравился ваш комментарий" },
+ "discussion_notificationLikes": { "message": "Новых лайков у вашего комментария: $1" },
+ "discussion_notificationDislike": { "message": "Кому-то не понравился ваш комментарий" },
+ "discussion_notificationReaction": { "message": "Кто-то оценил ваш комментарий" },
+ "discussion_someone": { "message": "Кто-то" },
+ "discussion_markRead": { "message": "Отметить прочитанным" },
+ "discussion_markAllRead": { "message": "Прочитать все" },
+ "discussion_view": { "message": "Открыть обсуждение" },
+ "discussion_unread": { "message": "Непрочитанных уведомлений: $1" },
+ "discussion_signedOutInbox": { "message": "Войдите, чтобы видеть ответы и реакции на ваши комментарии." },
+ "discussion_notificationUnavailable": { "message": "Это обсуждение больше недоступно." },
+ "discussion_discussionTab": { "message": "Обсуждение" },
+ "discussion_nickname": { "message": "Псевдоним" },
+ "discussion_reportCount": { "message": "Жалобы" },
+ "discussion_reportStatus": { "message": "Статус жалоб" },
+ "discussion_openReports": { "message": "Открытые жалобы" },
+ "discussion_resolvedReports": { "message": "Закрытые жалобы" },
+ "discussion_author": { "message": "Автор" },
+ "discussion_me": { "message": "Вы" },
+ "discussion_saving": { "message": "Сохранение…" },
+ "discussion_confirm": { "message": "Подтвердить" },
+ "discussion_notificationUpdated": { "message": "Новая активность в ваших комментариях" },
+ "discussion_showThread": { "message": "Показать ветку" },
+ "discussion_loadReplies": { "message": "Показать ответы ($1)" },
+ "patreonTierCommenter": { "message": "Commenter" },
"changelog_brand_logo_alt": {
"message": "Логотип Return YouTube Dislike"
}
diff --git a/Extensions/combined/comments-bridge.js b/Extensions/combined/comments-bridge.js
new file mode 100644
index 0000000..ba15187
--- /dev/null
+++ b/Extensions/combined/comments-bridge.js
@@ -0,0 +1,138 @@
+(() => {
+ const REQUEST = "ryd-discussion-state-request";
+ const ATTRIBUTE = "data-ryd-discussion-native-state";
+ const INSTALL = Symbol.for("return-youtube-dislike.discussion-bridge");
+ if (window[INSTALL]) return;
+ window[INSTALL] = true;
+
+ function data(element) {
+ return element?.polymerController?.data || element?.data || null;
+ }
+
+ function hasDisabledNotice(value, depth = 0) {
+ if (!value || typeof value !== "object" || depth > 14) return false;
+ if (value.messageRenderer) {
+ const runs = value.messageRenderer.text?.runs || [];
+ if (
+ runs.some((run) => {
+ try {
+ const url = new URL(run.navigationEndpoint?.urlEndpoint?.url);
+ return url.hostname === "support.google.com" && url.pathname === "/youtube/answer/9706180";
+ } catch {
+ return false;
+ }
+ })
+ )
+ return true;
+ }
+ return Object.values(value).some((child) => hasDisabledNotice(child, depth + 1));
+ }
+
+ function positiveKidsFlag(value) {
+ return ["isMadeForKids", "madeForKids", "is_made_for_kids"].some((key) => value?.[key] === true);
+ }
+
+ function hasKidsMiniplayerNotice(response) {
+ const renderer = response?.playabilityStatus?.miniplayer?.miniplayerRenderer;
+ if (renderer?.playbackMode !== "PLAYBACK_MODE_PAUSED_ONLY") return false;
+ const warning = renderer.minimizedEndpoint?.openPopupAction?.popup?.notificationActionRenderer;
+ const command = warning?.actionButton?.buttonRenderer?.command;
+ return [command?.urlEndpoint?.url, command?.commandMetadata?.webCommandMetadata?.url].some((value) => {
+ if (typeof value !== "string") return false;
+ try {
+ const url = new URL(value, "https://www.youtube.com");
+ return (
+ url.protocol === "https:" &&
+ url.hostname === "support.google.com" &&
+ (url.pathname === "/youtube/answer/9632097" ||
+ (url.pathname === "/youtube/bin/answer.py" && url.searchParams.get("answer") === "9632097"))
+ );
+ } catch {
+ return false;
+ }
+ });
+ }
+
+ function isMadeForKids(root, videoId) {
+ if (
+ ["is-made-for-kids", "made-for-kids"].some(
+ (name) => root.hasAttribute(name) && root.getAttribute(name) !== "false",
+ )
+ )
+ return true;
+ const controller = root.polymerController || root;
+ const rootData = data(root);
+ if (
+ positiveKidsFlag(rootData) ||
+ ((!rootData?.videoDetails?.videoId || rootData.videoDetails.videoId === videoId) &&
+ positiveKidsFlag(rootData?.videoDetails))
+ )
+ return true;
+ const player = root.querySelector("#movie_player, #shorts-player") || document.querySelector("#shorts-player");
+ const candidates = [controller.playerData, controller.playerResponse, data(root)?.playerResponse];
+ try {
+ candidates.push(player?.getPlayerResponse?.());
+ } catch {
+ /* Player may be between videos. */
+ }
+ if (window.ytInitialPlayerResponse?.videoDetails?.videoId === videoId)
+ candidates.push(window.ytInitialPlayerResponse);
+ return candidates.some((value) => {
+ if (value?.videoDetails?.videoId !== videoId) return false;
+ const microformat = value.microformat?.playerMicroformatRenderer;
+ return (
+ positiveKidsFlag(value) ||
+ positiveKidsFlag(value.videoDetails) ||
+ hasKidsMiniplayerNotice(value) ||
+ ((!microformat?.videoId || microformat.videoId === videoId) && positiveKidsFlag(microformat))
+ );
+ });
+ }
+
+ function inspect(event) {
+ const root = event.target;
+ if (!(root instanceof Element) || !root.matches("ytd-watch-flexy, ytd-watch-grid, ytd-reel-video-renderer")) return;
+ const url = new URL(location.href);
+ const shorts = url.pathname.startsWith("/shorts/");
+ const videoId = shorts ? url.pathname.split("/")[2] : url.searchParams.get("v");
+ if (!/^[A-Za-z0-9_-]{11}$/.test(videoId || "")) return;
+ let disabled = false;
+ if (shorts) {
+ const player = document.querySelector("#shorts-player");
+ let playerId;
+ try {
+ playerId = player?.getVideoData?.()?.video_id;
+ } catch {
+ /* Player has not hydrated yet. */
+ }
+ const rootData = data(root);
+ const identity = root.getAttribute("video-id") || rootData?.videoId || rootData?.videoDetails?.videoId;
+ const linked = [...root.querySelectorAll("a[href*='/shorts/']")].some((anchor) => {
+ try {
+ return new URL(anchor.href).pathname === `/shorts/${videoId}`;
+ } catch {
+ return false;
+ }
+ });
+ const ownsCurrentPlayer = playerId === videoId && root.contains(player);
+ if (identity ? identity !== videoId : !linked && !ownsCurrentPlayer) return;
+ const actionData = data(root.querySelector("reel-action-bar-view-model"));
+ disabled = (actionData?.buttonViewModels || []).some((item) => {
+ const button = item.buttonViewModel;
+ return button?.iconName === "SHORTS_COMMENT" && button.state === "BUTTON_VIEW_MODEL_STATE_DISABLED";
+ });
+ const overlayButtons =
+ rootData?.overlay?.reelPlayerOverlayRenderer?.buttonBar?.reelActionBarViewModel?.buttonViewModels || [];
+ disabled ||= overlayButtons.some(
+ (item) =>
+ item.buttonViewModel?.iconName === "SHORTS_COMMENT" &&
+ item.buttonViewModel.state === "BUTTON_VIEW_MODEL_STATE_DISABLED",
+ );
+ } else {
+ if (url.pathname !== "/watch" || root.getAttribute("video-id") !== videoId) return;
+ disabled = [...root.querySelectorAll("ytd-comments")].some((comments) => hasDisabledNotice(data(comments)));
+ }
+ root.setAttribute(ATTRIBUTE, JSON.stringify({ videoId, disabled, madeForKids: isMadeForKids(root, videoId) }));
+ }
+ document.addEventListener(REQUEST, inspect, true);
+})();
diff --git a/Extensions/combined/content-style.css b/Extensions/combined/content-style.css
index 784275c..fb272a1 100644
--- a/Extensions/combined/content-style.css
+++ b/Extensions/combined/content-style.css
@@ -9,6 +9,478 @@
--yt-spec-brand-background-secondary: #000 !important; */
}
+.ryd-discussion,
+.ryd-discussion-popup,
+.ryd-discussion-toast {
+ --ryd-discussion-bg: var(--yt-spec-base-background, #fff);
+ --ryd-discussion-fg: var(--yt-spec-text-primary, #0f0f0f);
+ --ryd-discussion-muted: var(--yt-spec-text-secondary, #606060);
+ --ryd-discussion-surface: var(--yt-spec-badge-chip-background, #f2f2f2);
+ --ryd-discussion-line: var(--yt-spec-10-percent-layer, #dedede);
+ --ryd-discussion-accent: var(--yt-spec-call-to-action, #065fd4);
+ --ryd-discussion-on-accent: var(--yt-spec-inverted-text-primary, #fff);
+ color-scheme: light;
+ box-sizing: border-box;
+ color: var(--ryd-discussion-fg);
+ font:
+ 14px/1.5 Roboto,
+ Arial,
+ sans-serif;
+ text-align: start;
+}
+
+html[dark] .ryd-discussion,
+html[dark] .ryd-discussion-popup,
+html[dark] .ryd-discussion-toast {
+ --ryd-discussion-bg: var(--yt-spec-base-background, #0f0f0f);
+ --ryd-discussion-fg: var(--yt-spec-text-primary, #f1f1f1);
+ --ryd-discussion-muted: var(--yt-spec-text-secondary, #aaa);
+ --ryd-discussion-surface: var(--yt-spec-badge-chip-background, #272727);
+ --ryd-discussion-line: var(--yt-spec-10-percent-layer, #3f3f3f);
+ --ryd-discussion-accent: var(--yt-spec-call-to-action, #3ea6ff);
+ --ryd-discussion-on-accent: var(--yt-spec-inverted-text-primary, #0f0f0f);
+ color-scheme: dark;
+}
+
+.ryd-discussion *,
+.ryd-discussion-popup *,
+.ryd-discussion-toast * {
+ box-sizing: border-box;
+}
+.ryd-discussion [hidden],
+.ryd-discussion[hidden],
+.ryd-discussion-popup [hidden],
+.ryd-discussion-backdrop[hidden] {
+ display: none !important;
+}
+.ryd-discussion {
+ width: 100%;
+ margin: 20px 0 32px;
+ padding: 20px 0;
+ border-top: 1px solid var(--ryd-discussion-line);
+}
+.ryd-discussion-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+.ryd-discussion-brand,
+.ryd-discussion-header-actions,
+.ryd-discussion-toolbar,
+.ryd-discussion-form-actions,
+.ryd-discussion-comment-actions,
+.ryd-discussion-account,
+.ryd-discussion-comment-heading {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.ryd-discussion-header-actions {
+ gap: 2px;
+}
+.ryd-discussion-title {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 1.3;
+}
+.ryd-discussion-logo {
+ padding: 4px 6px;
+ background: #d92323;
+ color: #fff;
+ font-size: 10px;
+ font-weight: 800;
+ border-radius: 6px;
+ letter-spacing: -0.2px;
+}
+.ryd-discussion-description {
+ margin: 10px 0 0;
+ color: var(--ryd-discussion-muted);
+ font-size: 13px;
+}
+.ryd-discussion-independent {
+ margin: 2px 0 20px;
+ color: var(--ryd-discussion-muted);
+ font-size: 12px;
+}
+.ryd-discussion-subtitle {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+}
+.ryd-discussion-button,
+.ryd-discussion-close {
+ appearance: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ border: 0;
+ border-radius: 18px;
+ background: transparent;
+ color: inherit;
+ min-height: 34px;
+ padding: 6px 12px;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1.4;
+ cursor: pointer;
+ text-decoration: none;
+ white-space: normal;
+}
+.ryd-discussion-button:hover,
+.ryd-discussion-close:hover {
+ background: var(--ryd-discussion-surface);
+}
+.ryd-discussion-button[aria-pressed="true"] {
+ background: var(--ryd-discussion-surface);
+ color: var(--ryd-discussion-accent);
+}
+.ryd-discussion-button:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+.ryd-discussion-button:focus-visible,
+.ryd-discussion-close:focus-visible,
+.ryd-discussion-select:focus-visible,
+.ryd-discussion-textarea:focus-visible,
+.ryd-discussion-input:focus-visible,
+.ryd-discussion-launcher:focus-visible,
+.ryd-discussion-notification-link:focus-visible {
+ outline: 2px solid var(--ryd-discussion-accent);
+ outline-offset: 2px;
+}
+.ryd-discussion-primary {
+ background: var(--ryd-discussion-accent);
+ color: var(--ryd-discussion-on-accent);
+ padding-inline: 16px;
+}
+.ryd-discussion-primary:hover {
+ background: var(--ryd-discussion-accent);
+ filter: brightness(0.92);
+}
+.ryd-discussion-danger {
+ color: #dc3232;
+}
+.ryd-discussion-close {
+ font-size: 26px;
+ padding: 0;
+ height: 36px;
+ width: 36px;
+ min-width: 36px;
+}
+.ryd-discussion-composer {
+ margin-bottom: 20px;
+}
+.ryd-discussion-gate {
+ margin: 0 0 6px;
+ color: var(--ryd-discussion-muted);
+ font-size: 13px;
+}
+.ryd-discussion-link,
+.ryd-discussion-notification-link {
+ color: var(--ryd-discussion-accent);
+ text-decoration: none;
+}
+.ryd-discussion-link:hover,
+.ryd-discussion-notification-link:hover {
+ text-decoration: underline;
+}
+.ryd-discussion-form {
+ margin: 10px 0;
+ width: 100%;
+}
+.ryd-discussion-label {
+ display: block;
+ width: 100%;
+ color: var(--ryd-discussion-fg);
+ font-size: 13px;
+ margin: 6px 0;
+}
+.ryd-discussion-textarea,
+.ryd-discussion-input {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ margin-top: 6px;
+ border: 1px solid var(--ryd-discussion-line);
+ border-radius: 10px;
+ background: var(--ryd-discussion-bg);
+ color: var(--ryd-discussion-fg);
+ padding: 10px 12px;
+ font: inherit;
+ line-height: 1.5;
+}
+.ryd-discussion-textarea {
+ resize: vertical;
+ min-height: 78px;
+}
+.ryd-discussion-input {
+ max-width: 420px;
+}
+.ryd-discussion-textarea::placeholder {
+ color: var(--ryd-discussion-muted);
+}
+.ryd-discussion-form-actions {
+ justify-content: flex-end;
+ margin-top: 6px;
+}
+.ryd-discussion-counter {
+ margin-inline-end: auto;
+ color: var(--ryd-discussion-muted);
+ font-size: 11px;
+}
+.ryd-discussion-toolbar {
+ justify-content: space-between;
+ margin: 16px 0 12px;
+}
+.ryd-discussion-select {
+ border: 0;
+ border-radius: 8px;
+ padding: 8px 28px 8px 10px;
+ background: var(--ryd-discussion-surface);
+ color: inherit;
+ font: inherit;
+ font-size: 13px;
+}
+.ryd-discussion-status {
+ margin: 8px 0;
+ color: var(--ryd-discussion-muted);
+ font-size: 13px;
+}
+.ryd-discussion-status:empty {
+ display: none;
+}
+.ryd-discussion-comment {
+ padding: 16px 0;
+ border-bottom: 1px solid var(--ryd-discussion-line);
+ min-width: 0;
+}
+.ryd-discussion-comment:last-child {
+ border-bottom: 0;
+}
+.ryd-discussion-comment-heading {
+ gap: 8px;
+}
+.ryd-discussion-author {
+ font-size: 13px;
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+.ryd-discussion-avatar {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ min-width: 28px;
+ border-radius: 50%;
+ color: var(--ryd-discussion-muted);
+ background: var(--ryd-discussion-surface);
+ font-size: 13px;
+ font-weight: 500;
+}
+.ryd-discussion-meta {
+ color: var(--ryd-discussion-muted);
+ font-size: 11px;
+}
+.ryd-discussion-comment-text {
+ margin: 8px 0 6px;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ word-break: normal;
+ font-size: 14px;
+ line-height: 1.55;
+}
+.ryd-discussion-comment-actions {
+ gap: 2px;
+}
+.ryd-discussion-comment-actions .ryd-discussion-button {
+ padding-inline: 9px;
+}
+.ryd-discussion-thumb {
+ width: 18px;
+ height: 18px;
+ fill: currentColor;
+ flex-shrink: 0;
+}
+.ryd-discussion-thumb.is-down {
+ transform: rotate(180deg);
+}
+.ryd-discussion-reaction {
+ min-width: 42px;
+ font-variant-numeric: tabular-nums;
+}
+.ryd-discussion-replies {
+ margin-inline-start: 24px;
+ padding-inline-start: 12px;
+ border-inline-start: 2px solid var(--ryd-discussion-line);
+}
+.ryd-discussion-replies:empty {
+ display: none;
+}
+.ryd-discussion-reply {
+ padding-block: 12px;
+}
+.ryd-discussion-inline-editor:empty {
+ display: none;
+}
+.ryd-discussion-confirm {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 12px 0;
+ flex-wrap: wrap;
+}
+.ryd-discussion-more {
+ margin: 12px 0;
+ background: var(--ryd-discussion-surface);
+}
+.ryd-discussion-comment.is-highlighted {
+ border-radius: 10px;
+ padding-inline: 12px;
+ background: var(--ryd-discussion-surface);
+}
+.ryd-discussion-report {
+ border: 1px solid var(--ryd-discussion-line);
+ border-radius: 12px;
+ padding: 14px;
+ margin-top: 14px;
+}
+.ryd-discussion-report-reason {
+ color: var(--ryd-discussion-muted);
+ font-size: 12px;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.ryd-discussion-notification {
+ border-bottom: 1px solid var(--ryd-discussion-line);
+ padding: 12px 8px;
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.ryd-discussion-notification.is-unread {
+ background: var(--ryd-discussion-surface);
+ border-radius: 8px;
+}
+.ryd-discussion-notification-link {
+ flex: 1 1 100%;
+ font-size: 13px;
+ overflow-wrap: anywhere;
+}
+.ryd-discussion-inbox-list {
+ display: grid;
+ gap: 4px;
+}
+.ryd-discussion-launcher {
+ display: block;
+ margin: 8px auto;
+ width: 48px;
+ height: 48px;
+ border: 1px solid rgba(255, 255, 255, 0.35);
+ border-radius: 50%;
+ background: #d92323;
+ color: #fff;
+ font:
+ 700 12px/1 Roboto,
+ Arial,
+ sans-serif;
+ cursor: pointer;
+ box-shadow: 0 2px 8px #0003;
+}
+.ryd-discussion-backdrop {
+ position: fixed;
+ inset: 0;
+ background: #0008;
+ z-index: 2147483000;
+}
+.ryd-discussion-shorts {
+ position: fixed;
+ inset: 0 0 0 auto;
+ width: min(440px, 100vw);
+ height: 100dvh;
+ margin: 0;
+ padding: 20px;
+ background: var(--ryd-discussion-bg);
+ z-index: 2147483001;
+ border: 0;
+ box-shadow: -8px 0 32px #0003;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+.ryd-discussion-shorts .ryd-discussion-header {
+ align-items: flex-start;
+}
+.ryd-discussion-shorts .ryd-discussion-header-actions {
+ width: 100%;
+}
+.ryd-discussion-shorts .ryd-discussion-close {
+ margin-inline-start: auto;
+}
+.ryd-discussion-toast {
+ position: fixed;
+ inset-inline-end: 24px;
+ bottom: 24px;
+ z-index: 2147483002;
+ width: min(360px, calc(100vw - 32px));
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+ padding: 16px;
+ border-radius: 14px;
+ background: var(--ryd-discussion-bg);
+ border: 1px solid var(--ryd-discussion-line);
+ box-shadow: 0 6px 30px #0003;
+}
+.ryd-discussion-toast p {
+ margin: 5px 0;
+ font-size: 13px;
+}
+.ryd-discussion-toast .ryd-discussion-close {
+ margin-inline-start: auto;
+}
+.ryd-discussion-sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+@media (max-width: 600px) {
+ #ryd-discussion-panel {
+ max-inline-size: calc(100vw - 48px);
+ margin-inline: auto;
+ }
+ .ryd-discussion-header-actions {
+ width: 100%;
+ }
+ .ryd-discussion-title {
+ font-size: 18px;
+ }
+ .ryd-discussion-replies {
+ margin-inline-start: 10px;
+ padding-inline-start: 8px;
+ }
+ .ryd-discussion-shorts {
+ width: 100vw;
+ padding: 16px;
+ }
+ .ryd-discussion-toast {
+ inset-inline-end: 16px;
+ bottom: 16px;
+ }
+}
+
/* html:not(.style-scope)[dark], :not(.style-scope)[dark] {
--yt-spec-general-background-a: #000 !important;
--yt-spec-general-background-b: #000 !important;
diff --git a/Extensions/combined/manifest-chrome.json b/Extensions/combined/manifest-chrome.json
index fcf4a7a..8f7688b 100644
--- a/Extensions/combined/manifest-chrome.json
+++ b/Extensions/combined/manifest-chrome.json
@@ -11,55 +11,32 @@
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
- "host_permissions": [
- "*://*.youtube.com/*",
- "*://returnyoutubedislikeapi.com/*"
- ],
- "permissions": [
- "storage"
- ],
- "optional_permissions": [
- "identity"
- ],
+ "host_permissions": ["*://*.youtube.com/*", "*://returnyoutubedislikeapi.com/*"],
+ "permissions": ["storage", "alarms"],
+ "optional_permissions": ["identity"],
"action": {
"default_popup": "popup.html"
},
"content_scripts": [
{
- "matches": [
- "*://youtube.com/*",
- "*://www.youtube.com/*",
- "*://m.youtube.com/*"
- ],
- "exclude_matches": [
- "*://*.music.youtube.com/*"
- ],
- "js": [
- "ryd.content-script.js"
- ],
- "css": [
- "content-style.css"
- ]
+ "matches": ["*://youtube.com/*", "*://www.youtube.com/*", "*://m.youtube.com/*"],
+ "exclude_matches": ["*://*.music.youtube.com/*"],
+ "js": ["ryd.content-script.js"],
+ "css": ["content-style.css"]
}
],
"externally_connectable": {
- "matches": [
- "*://*.youtube.com/*"
- ]
+ "matches": ["*://*.youtube.com/*"]
},
"web_accessible_resources": [
{
- "resources": [
- "ryd.script.js",
- "menu-fixer.js"
- ],
- "matches": [
- "*://*.youtube.com/*"
- ]
+ "resources": ["ryd.script.js", "menu-fixer.js", "comments-bridge.js"],
+ "matches": ["*://*.youtube.com/*"]
}
],
"options_ui": {
"page": "popup.html",
"open_in_tab": false
- }
+ },
+ "minimum_chrome_version": "116"
}
diff --git a/Extensions/combined/manifest-firefox.json b/Extensions/combined/manifest-firefox.json
index dc33aee..503c179 100644
--- a/Extensions/combined/manifest-firefox.json
+++ b/Extensions/combined/manifest-firefox.json
@@ -12,7 +12,14 @@
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
- "permissions": ["activeTab", "*://*.youtube.com/*", "storage", "*://returnyoutubedislikeapi.com/*", "identity"],
+ "permissions": [
+ "activeTab",
+ "*://*.youtube.com/*",
+ "storage",
+ "*://returnyoutubedislikeapi.com/*",
+ "identity",
+ "alarms"
+ ],
"browser_specific_settings": {
"gecko": {
"id": "{762f9885-5a13-4abd-9c77-433dcd38b8fd}",
@@ -23,7 +30,6 @@
}
}
},
-
"browser_action": {
"default_popup": "popup.html"
},
@@ -40,5 +46,5 @@
"page": "popup.html",
"open_in_tab": false
},
- "web_accessible_resources": ["menu-fixer.js"]
+ "web_accessible_resources": ["menu-fixer.js", "comments-bridge.js"]
}
diff --git a/Extensions/combined/manifest-safari.json b/Extensions/combined/manifest-safari.json
index f6ca020..06f066c 100644
--- a/Extensions/combined/manifest-safari.json
+++ b/Extensions/combined/manifest-safari.json
@@ -12,7 +12,7 @@
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
- "permissions": ["activeTab", "*://*.youtube.com/*", "storage", "*://returnyoutubedislikeapi.com/*"],
+ "permissions": ["activeTab", "*://*.youtube.com/*", "storage", "*://returnyoutubedislikeapi.com/*", "alarms"],
"browser_action": {
"default_popup": "popup.html"
},
@@ -28,5 +28,5 @@
"options_ui": {
"page": "popup.html"
},
- "web_accessible_resources": ["menu-fixer.js"]
+ "web_accessible_resources": ["menu-fixer.js", "comments-bridge.js"]
}
diff --git a/Extensions/combined/popup.css b/Extensions/combined/popup.css
index e8fcb65..0eba8de 100644
--- a/Extensions/combined/popup.css
+++ b/Extensions/combined/popup.css
@@ -13,6 +13,26 @@
}
/*** MATERIAL 3 ***/
+.ryd-discussion-popup {
+ --yt-spec-base-background: var(--background);
+ --yt-spec-text-primary: #f5eded;
+ --yt-spec-text-secondary: #ccbebe;
+ --yt-spec-badge-chip-background: var(--tertiary);
+ --yt-spec-10-percent-layer: #624d4d;
+ --yt-spec-call-to-action: var(--primary);
+ margin: 0;
+ width: 100%;
+}
+.ryd-discussion-popup .ryd-discussion-inbox {
+ margin-top: 12px;
+ padding: 0 8px 12px;
+ max-height: 400px;
+ overflow-y: auto;
+}
+.ryd-discussion-popup .ryd-discussion-toolbar {
+ justify-content: flex-start;
+}
+
.m3-primary {
background: var(--primary) !important;
color: var(--background) !important;
diff --git a/Extensions/combined/popup.html b/Extensions/combined/popup.html
index 68f9c72..a37d6a8 100644
--- a/Extensions/combined/popup.html
+++ b/Extensions/combined/popup.html
@@ -5,6 +5,7 @@
__MSG_extensionName__
+
@@ -85,9 +86,19 @@
+
+
diff --git a/Extensions/combined/popup.js b/Extensions/combined/popup.js
index 1faf6d8..bbc0525 100644
--- a/Extensions/combined/popup.js
+++ b/Extensions/combined/popup.js
@@ -1,5 +1,6 @@
import { config as appConfig, getApiUrl, getApiEndpoint } from "./src/config.js";
import { getGitHubLoginErrorMessage } from "./src/github-login-error";
+import { initDiscussionPopup } from "./src/discussion/popup";
import {
hasAuthenticationDataPermission,
onAuthenticationDataPermissionRemoved,
@@ -47,6 +48,7 @@ function localizeHtmlPage() {
}
localizeHtmlPage();
+initDiscussionPopup();
/* Links */
createLink(config.links.website, "link_website");
@@ -195,6 +197,7 @@ function initPatreonAuth() {
const tierLabels = {
premium: chrome.i18n.getMessage("patreonTierPremium"),
+ commenter: chrome.i18n.getMessage("patreonTierCommenter"),
supporter: chrome.i18n.getMessage("patreonTierSupporter"),
basic: chrome.i18n.getMessage("patreonTierBasic"),
none: chrome.i18n.getMessage("patreonTierNone"),
diff --git a/Extensions/combined/ryd.background.js b/Extensions/combined/ryd.background.js
index e5ae7e8..8f4f261 100644
--- a/Extensions/combined/ryd.background.js
+++ b/Extensions/combined/ryd.background.js
@@ -2,6 +2,7 @@ import { config, getApiUrl, getApiEndpoint, getChangelogUrl } from "./src/config
import { createVoteClient } from "../common/vote-client";
import { createBrowserCredentialStore } from "./src/vote-client-adapter";
import { loginWithGitHub } from "./src/github-auth";
+import { createDiscussionBackground } from "./src/discussion-background";
import {
hasAuthenticationDataPermission,
onAuthenticationDataPermissionRemoved,
@@ -38,6 +39,14 @@ const voteClient = createVoteClient({
cryptoImpl: globalThis.crypto,
});
+const discussionBackground = createDiscussionBackground({
+ api,
+ voteClient,
+ hasConsent: async () =>
+ !usesFirefoxDataCollectionConsent() || (await hasAuthenticationDataPermission({ queryBackground: false })),
+});
+discussionBackground.initialize();
+
initExtConfig();
voteClient.ensureRegistered().catch((error) => console.error("Vote registration failed", error));
@@ -130,11 +139,15 @@ function queueAccountStorageMutation(operation) {
function clearPatreonAuth(done) {
const generation = ++authenticationGeneration;
+ discussionBackground.clearSession(true);
broadcastPatreonStatus(false, null, null, generation);
queueAccountStorageMutation((complete) => {
chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"], complete);
})
- .then(() => done?.())
+ .then(() => {
+ discussionBackground.resumeSession().catch(() => {});
+ done?.();
+ })
.catch((error) => console.error("Account session cleanup failed", error));
}
@@ -192,7 +205,10 @@ function extractOAuthParams(responseUrl) {
}
api.runtime.onMessage.addListener((request, sender, sendResponse) => {
- if (request.message === "patreon_logout") {
+ if (request?.message === "ryd_discussion") {
+ discussionBackground.handleMessage(request, sender).then(sendResponse);
+ return true;
+ } else if (request.message === "patreon_logout") {
clearPatreonAuth();
} else if (request.message === "ryd_has_authentication_consent") {
hasAuthenticationDataPermission({ queryBackground: false }).then((granted) => sendResponse({ granted }));
@@ -573,6 +589,7 @@ const sentIds = new Set();
let toSend = [];
function storageChangeHandler(changes, area) {
+ discussionBackground.onStorageChanged(changes, area);
if (changes.disableVoteSubmission !== undefined) {
handleDisableVoteSubmissionChangeEvent(changes.disableVoteSubmission.newValue);
}
diff --git a/Extensions/combined/ryd.background.spec.js b/Extensions/combined/ryd.background.spec.js
index 73204e5..1362987 100644
--- a/Extensions/combined/ryd.background.spec.js
+++ b/Extensions/combined/ryd.background.spec.js
@@ -189,7 +189,17 @@ describe("background account consent lifecycle", () => {
await flushPromises();
expect(stored).toEqual({});
expect(chrome.storage.sync.remove).toHaveBeenCalledTimes(1);
- expect(chrome.tabs.sendMessage.mock.calls.every(([, message]) => message.authenticated === false)).toBe(true);
+ expect(
+ chrome.tabs.sendMessage.mock.calls
+ .filter(([, message]) => message.message === "patreon_status_changed")
+ .every(([, message]) => message.authenticated === false),
+ ).toBe(true);
+ expect(
+ chrome.tabs.sendMessage.mock.calls
+ .filter(([, message]) => message.message === "ryd_discussion_event" && message.type === "session")
+ .every(([, message]) => message.data.authenticated === false),
+ ).toBe(true);
+
expect(response).toHaveBeenCalledWith(expect.objectContaining({ success: false }));
},
);
diff --git a/Extensions/combined/ryd.content-script.js b/Extensions/combined/ryd.content-script.js
index 5fa1a23..a7ceb7a 100644
--- a/Extensions/combined/ryd.content-script.js
+++ b/Extensions/combined/ryd.content-script.js
@@ -31,6 +31,7 @@ import {
reactionControlsCanInitialize,
} from "./src/initialization-cycle";
import { initPatreonFeatures } from "./src/patreon";
+import { initDiscussion } from "./src/discussion";
import { cancelObsoleteVoteDataRequests, clearVoteDataRequestCache } from "./src/vote-data-request";
import {
actionBarHasHydratedData,
@@ -94,6 +95,7 @@ const pageWorldHelpersReady = injectPageWorldHelpers();
await initExtConfig();
await pageWorldHelpersReady;
initPatreonFeatures();
+initDiscussion();
// Settings that affect page-world helpers must continue to update even when
// YouTube's reaction controls have not finished initializing.
diff --git a/Extensions/combined/src/discussion-background.js b/Extensions/combined/src/discussion-background.js
new file mode 100644
index 0000000..12bfac6
--- /dev/null
+++ b/Extensions/combined/src/discussion-background.js
@@ -0,0 +1,785 @@
+import { HubConnectionBuilder, HttpTransportType, LogLevel } from "@microsoft/signalr";
+import { createVoteClient, solvePuzzle } from "../../common/vote-client";
+import { discussionConfig } from "./discussion-config";
+
+const ANONYMOUS_SESSION = Object.freeze({
+ authenticated: false,
+ profile: null,
+ canPost: false,
+ isModerator: false,
+ membershipTier: "none",
+});
+const RECONNECT_ALARM = "ryd-discussion-reconnect";
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+const VIDEO_ID = /^[a-zA-Z0-9_-]{11}$/;
+const VIDEO_OPERATIONS = new Set([
+ "comments",
+ "replies",
+ "comment",
+ "createComment",
+ "editComment",
+ "deleteComment",
+ "react",
+ "report",
+]);
+const OPERATIONS = {
+ cancelScope: [],
+ capabilities: [],
+ session: [],
+ profile: ["nickname"],
+ comments: ["videoId", "sort", "cursor"],
+ replies: ["rootId", "cursor"],
+ comment: ["id"],
+ createComment: ["videoId", "text", "parentId", "replyToId", "requestId"],
+ editComment: ["id", "text"],
+ deleteComment: ["id"],
+ react: ["id", "value"],
+ report: ["id", "reason"],
+ reports: ["cursor", "status"],
+ dismissReport: ["id", "reason"],
+ moderateComment: ["id", "hidden", "reason"],
+ suspendProfile: ["id", "suspended", "reason"],
+ notifications: ["cursor"],
+ readNotifications: ["ids", "all"],
+};
+
+class DiscussionError extends Error {
+ constructor(code, message = code, status = 0) {
+ super(message);
+ this.code = code;
+ this.status = status;
+ }
+}
+
+function callBrowser(api, target, method, ...args) {
+ return new Promise((resolve, reject) => {
+ if (typeof target?.[method] !== "function") return resolve(undefined);
+ const callback = (value) =>
+ api.runtime?.lastError ? reject(new Error(api.runtime.lastError.message)) : resolve(value);
+ try {
+ const result = target[method](...args, callback);
+ if (result?.then) result.then(resolve, reject);
+ } catch (error) {
+ try {
+ Promise.resolve(target[method](...args)).then(resolve, reject);
+ } catch (_) {
+ reject(error);
+ }
+ }
+ });
+}
+
+function isYouTubePage(url) {
+ try {
+ const parsed = new URL(url);
+ return (
+ parsed.protocol === "https:" &&
+ parsed.hostname === "www.youtube.com" &&
+ (parsed.pathname === "/watch" || /^\/shorts\/[a-zA-Z0-9_-]{11}\/?$/.test(parsed.pathname))
+ );
+ } catch (_) {
+ return false;
+ }
+}
+
+function validateRequest(api, request, sender) {
+ if (sender?.id !== api.runtime.id) throw new DiscussionError("invalid_sender");
+ const extensionUrl = api.runtime.getURL?.("");
+ const ownPage = extensionUrl && typeof sender.url === "string" && sender.url.startsWith(extensionUrl);
+ const videoPage = sender.frameId === 0 && isYouTubePage(sender.url) && isYouTubePage(sender.tab?.url);
+ const operation = request?.operation;
+ const cancellationPage =
+ operation === "cancelScope" &&
+ sender.frameId === 0 &&
+ [sender.url, sender.tab?.url].every((value) => {
+ try {
+ return new URL(value).origin === "https://www.youtube.com";
+ } catch (_) {
+ return false;
+ }
+ });
+ if (!ownPage && !videoPage && !cancellationPage) throw new DiscussionError("invalid_sender");
+ const fields = Object.hasOwn(OPERATIONS, operation) ? OPERATIONS[operation] : null;
+ const payload = request.payload ?? {};
+ if (
+ !fields ||
+ !payload ||
+ typeof payload !== "object" ||
+ Array.isArray(payload) ||
+ Object.keys(payload).some((key) => !fields.includes(key))
+ )
+ throw new DiscussionError("invalid_request");
+ for (const key of ["id", "rootId", "parentId", "replyToId", "requestId"]) {
+ if (payload[key] != null && (typeof payload[key] !== "string" || !UUID.test(payload[key])))
+ throw new DiscussionError("invalid_request");
+ }
+ if (fields.includes("id") && !UUID.test(payload.id || "")) throw new DiscussionError("invalid_request");
+ if (fields.includes("rootId") && !UUID.test(payload.rootId || "")) throw new DiscussionError("invalid_request");
+ if (fields.includes("videoId") && !VIDEO_ID.test(payload.videoId || "")) throw new DiscussionError("invalid_request");
+ if (operation === "createComment" && !UUID.test(payload.requestId || ""))
+ throw new DiscussionError("invalid_request");
+ for (const [key, max] of [
+ ["text", 2000],
+ ["nickname", 32],
+ ["reason", 1000],
+ ["cursor", 2048],
+ ]) {
+ if (payload[key] != null && (typeof payload[key] !== "string" || payload[key].length > max))
+ throw new DiscussionError("invalid_request");
+ }
+ if (fields.includes("text") && !payload.text?.trim()) throw new DiscussionError("invalid_request");
+ if (operation === "profile" && (typeof payload.nickname !== "string" || payload.nickname.trim().length < 3))
+ throw new DiscussionError("invalid_request");
+ if (operation === "report" && !payload.reason?.trim()) throw new DiscussionError("invalid_request");
+ if (payload.sort != null && !["top", "new"].includes(payload.sort)) throw new DiscussionError("invalid_request");
+ if (payload.status != null && !["open", "resolved"].includes(payload.status))
+ throw new DiscussionError("invalid_request");
+ if (operation === "react" && ![-1, 0, 1].includes(payload.value)) throw new DiscussionError("invalid_request");
+ for (const key of ["hidden", "suspended"]) {
+ if (fields.includes(key) && typeof payload[key] !== "boolean") throw new DiscussionError("invalid_request");
+ }
+ if (
+ operation === "readNotifications" &&
+ !(payload.all === true && payload.ids == null) &&
+ !(
+ payload.all == null &&
+ Array.isArray(payload.ids) &&
+ payload.ids.length > 0 &&
+ payload.ids.length <= 100 &&
+ payload.ids.every((id) => typeof id === "string" && UUID.test(id))
+ )
+ )
+ throw new DiscussionError("invalid_request");
+ const scope = request.scope;
+ if (scope != null) {
+ if (
+ !scope ||
+ typeof scope !== "object" ||
+ Array.isArray(scope) ||
+ Object.keys(scope).some((key) => !["id", "videoId"].includes(key)) ||
+ typeof scope.id !== "string" ||
+ !UUID.test(scope.id) ||
+ typeof scope.videoId !== "string" ||
+ !VIDEO_ID.test(scope.videoId) ||
+ !Number.isInteger(sender.tab?.id) ||
+ (!videoPage && !cancellationPage) ||
+ (!VIDEO_OPERATIONS.has(operation) && operation !== "cancelScope")
+ )
+ throw new DiscussionError("invalid_scope");
+ if (operation !== "cancelScope") {
+ const current = new URL(sender.tab.url);
+ const videoId = current.pathname === "/watch" ? current.searchParams.get("v") : current.pathname.split("/")[2];
+ if (scope.videoId !== videoId || (payload.videoId && payload.videoId !== scope.videoId))
+ throw new DiscussionError("navigation_changed");
+ }
+ } else if (operation === "cancelScope" || (videoPage && VIDEO_OPERATIONS.has(operation))) {
+ throw new DiscussionError("scope_required");
+ }
+ return { operation, payload, scope };
+}
+
+function safeSession(data) {
+ const profile = data.profile;
+ return {
+ authenticated: data.authenticated === true,
+ profile: profile ? { id: profile.id, nickname: profile.nickname, suspended: profile.suspended === true } : null,
+ canPost: data.canPost === true,
+ isModerator: data.isModerator === true,
+ membershipTier: ["none", "supporter", "commenter", "premium"].includes(data.membershipTier)
+ ? data.membershipTier
+ : "none",
+ };
+}
+
+function createConnection(url, accessTokenFactory) {
+ return new HubConnectionBuilder()
+ .withUrl(url, { transport: HttpTransportType.WebSockets, skipNegotiation: true, accessTokenFactory })
+ .withAutomaticReconnect([0, 2000, 10000, 30000])
+ .withKeepAliveInterval(15000)
+ .withServerTimeout(45000)
+ .configureLogging(LogLevel.None)
+ .build();
+}
+
+export function createDiscussionBackground({
+ api,
+ voteClient,
+ fetchImpl = (...args) => fetch(...args),
+ hasConsent = async () => true,
+ settings = discussionConfig,
+ connectionFactory = createConnection,
+ solve = solvePuzzle,
+} = {}) {
+ const local = api.storage.local;
+ const origin = settings.apiOrigin.replace(/\/$/, "");
+ const pendingRequests = new Map();
+ const reactionQueues = new Map();
+ const navigationScopes = new Map();
+ let generation = 0;
+ let accountEpoch = 0;
+ let token = "";
+ let session = { ...ANONYMOUS_SESSION };
+ let connection = null;
+ let connecting = null;
+ let accountQueue = Promise.resolve();
+ let catchupQueue = Promise.resolve();
+ let stateQueue = Promise.resolve();
+ let rejectedToken = "";
+ let accountBlocked = false;
+ let hintedRevision = "0";
+ let hasInboxSnapshot = false;
+ let inbox = { items: [], unreadCount: 0, revision: "0", nextCursor: null };
+ let stopped = false;
+
+ const store = (method, ...args) => callBrowser(api, local, method, ...args);
+ const writeState = (expected, method, ...args) => {
+ const work = stateQueue
+ .catch(() => {})
+ .then(() => {
+ if (expected !== generation) return;
+ return store(method, ...args);
+ });
+ stateQueue = work;
+ return work;
+ };
+ const credentials = settings.testMode
+ ? createVoteClient({
+ apiBaseUrl: origin,
+ fetchImpl,
+ credentialStore: {
+ async load() {
+ return (await store("get", [settings.credentialStorageKey]))?.[settings.credentialStorageKey] ?? null;
+ },
+ async save(value) {
+ await store("set", { [settings.credentialStorageKey]: value });
+ },
+ async clear() {
+ await store("remove", [settings.credentialStorageKey]);
+ },
+ },
+ cryptoImpl: globalThis.crypto,
+ })
+ : voteClient;
+
+ function assertCurrent(expected) {
+ if (stopped || expected !== generation) throw new DiscussionError("session_changed");
+ }
+
+ function assertScope(scope) {
+ if (scope?.cancelled) throw new DiscussionError("navigation_changed", "The video changed");
+ }
+
+ function scopeFor(value, sender) {
+ if (!value) return null;
+ const documentId = sender.documentId || "top-frame";
+ const key = `${sender.tab.id}:${documentId}:${value.id}`;
+ if (!navigationScopes.has(key))
+ navigationScopes.set(key, {
+ tabId: sender.tab.id,
+ documentId,
+ videoId: value.videoId,
+ cancelled: false,
+ controllers: new Set(),
+ });
+ const scope = navigationScopes.get(key);
+ if (scope.videoId !== value.videoId) throw new DiscussionError("invalid_scope");
+ return scope;
+ }
+
+ function cancelScope(scope) {
+ scope.cancelled = true;
+ for (const controller of scope.controllers) controller.abort();
+ }
+
+ async function request(
+ path,
+ { method = "GET", body, auth = token, expected = generation, scope, accountBound = true } = {},
+ ) {
+ const assertRequest = () => assertCurrent(accountBound ? expected : generation);
+ assertRequest();
+ assertScope(scope);
+ const controller = new AbortController();
+ pendingRequests.set(controller, accountBound);
+ scope?.controllers.add(controller);
+ const timeout = setTimeout(() => controller.abort(), 20000);
+ try {
+ const response = await fetchImpl(`${origin}/api/discussion${path}`, {
+ method,
+ credentials: "omit",
+ cache: "no-store",
+ signal: controller.signal,
+ headers: {
+ Accept: "application/json",
+ ...(body == null ? {} : { "Content-Type": "application/json" }),
+ ...(auth ? { Authorization: `Bearer ${auth}` } : {}),
+ },
+ ...(body == null ? {} : { body: JSON.stringify(body) }),
+ });
+ assertRequest();
+ assertScope(scope);
+ const data = response.status === 204 ? null : await response.json().catch(() => null);
+ assertRequest();
+ assertScope(scope);
+ if (!response.ok) {
+ if (
+ response.status === 401 &&
+ data?.code === "authentication_required" &&
+ accountBound &&
+ auth &&
+ auth === token &&
+ path !== "/me"
+ ) {
+ rejectedToken = token;
+ clearSession();
+ }
+ throw new DiscussionError(data?.code || "request_failed", data?.message || "Request failed", response.status);
+ }
+ return data;
+ } catch (error) {
+ assertScope(scope);
+ if (error instanceof DiscussionError) throw error;
+ throw new DiscussionError(
+ controller.signal.aborted ? "request_cancelled" : "network_error",
+ "Discussion is unavailable",
+ );
+ } finally {
+ clearTimeout(timeout);
+ pendingRequests.delete(controller);
+ scope?.controllers.delete(controller);
+ }
+ }
+
+ async function broadcast(type, data, expected = generation) {
+ const message = { message: "ryd_discussion_event", type, data };
+ if (expected !== generation || stopped) return;
+ const tabs = await callBrowser(
+ api,
+ api.tabs,
+ "query",
+ type === "toast" ? { active: true, lastFocusedWindow: true } : {},
+ );
+ if (expected !== generation || stopped) return;
+ const targets = (tabs || []).filter((tab) => isYouTubePage(tab.url));
+ await Promise.allSettled(
+ (type === "toast" ? targets.slice(0, 1) : targets).map((tab) =>
+ callBrowser(api, api.tabs, "sendMessage", tab.id, message),
+ ),
+ );
+ if (type !== "toast" && expected === generation) {
+ await callBrowser(api, api.runtime, "sendMessage", message).catch(() => {});
+ }
+ }
+
+ async function updateBadge(expected = generation) {
+ if (expected !== generation || stopped) return;
+ const action = api.action || api.browserAction;
+ await callBrowser(api, action, "setBadgeText", {
+ text: inbox.unreadCount ? String(Math.min(inbox.unreadCount, 99)) + (inbox.unreadCount > 99 ? "+" : "") : "",
+ }).catch(() => {});
+ if (expected === generation)
+ await callBrowser(api, action, "setBadgeBackgroundColor", { color: "#cc0000" }).catch(() => {});
+ }
+
+ async function publishInbox(expected, changed = [], showToast = false) {
+ assertCurrent(expected);
+ await writeState(expected, "set", {
+ [settings.stateStorageKey]: { profileId: session.profile?.id ?? null, ...inbox },
+ });
+ assertCurrent(expected);
+ await updateBadge(expected);
+ await broadcast("inbox", inbox, expected);
+ if (showToast) {
+ const notification = changed.find((item) => !item.read && ["reply", "likes"].includes(item.type));
+ if (notification) await broadcast("toast", notification, expected);
+ }
+ }
+
+ function catchup(showToast = false, expected = generation) {
+ const work = catchupQueue
+ .catch(() => {})
+ .then(async () => {
+ assertCurrent(expected);
+ if (!session.authenticated || !token) return;
+ const since = inbox.revision;
+ const isChanges = hasInboxSnapshot;
+ const changed = [];
+ let cursor = null;
+ let page;
+ do {
+ const params = new URLSearchParams();
+ if (isChanges) params.set("afterRevision", since);
+ if (cursor) params.set("cursor", cursor);
+ page = await request(`/notifications${params.size ? `?${params}` : ""}`, { expected });
+ changed.push(...(page.items || []));
+ cursor = page.nextCursor;
+ } while (cursor && isChanges);
+ assertCurrent(expected);
+ const previous = new Map(inbox.items.map((item) => [item.id, item]));
+ const novel = changed.filter((item) => previous.get(item.id)?.revision !== item.revision);
+ for (const item of changed) previous.set(item.id, item);
+ inbox = {
+ items: [...previous.values()]
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
+ .slice(0, 100),
+ nextCursor: isChanges ? inbox.nextCursor : page.nextCursor,
+ unreadCount: page.unreadCount,
+ revision: page.revision,
+ };
+ hasInboxSnapshot = true;
+ await publishInbox(expected, novel, showToast && isChanges);
+ });
+ catchupQueue = work;
+ return work;
+ }
+
+ async function connect(expected = generation) {
+ if (connecting || connection || !session.authenticated || !token || stopped) return connecting;
+ const work = (async () => {
+ assertCurrent(expected);
+ const activeToken = token;
+ const socket = connectionFactory(`${origin}/api/discussion/notifications/hub`, async () => {
+ assertCurrent(expected);
+ const ticket = await request("/notifications/ticket", { method: "POST", auth: activeToken, expected });
+ return ticket.token;
+ });
+ connection = socket;
+ socket.on("NotificationsChanged", (event) => {
+ const revision = event?.revision;
+ if (expected !== generation || typeof revision !== "string" || !/^(0|[1-9][0-9]*)$/.test(revision)) return;
+ if (BigInt(revision) <= BigInt(inbox.revision) || BigInt(revision) <= BigInt(hintedRevision)) return;
+ hintedRevision = revision;
+ catchup(true, expected).catch(() => {
+ if (expected === generation) hintedRevision = inbox.revision;
+ });
+ });
+ socket.onclose(() => {
+ if (connection === socket) connection = null;
+ });
+ socket.onreconnected(() => {
+ if (expected !== generation || connection !== socket || stopped) return;
+ catchup(false, expected).catch(() => {});
+ });
+ try {
+ await socket.start();
+ assertCurrent(expected);
+ await catchup(false, expected).catch(() => {});
+ } catch (error) {
+ if (connection === socket) connection = null;
+ await socket.stop().catch(() => {});
+ throw error;
+ }
+ })();
+ connecting = work;
+ try {
+ await work;
+ } finally {
+ if (connecting === work) connecting = null;
+ }
+ }
+
+ function clearSession(blockAccount = false, preserveQueuedRequests = false) {
+ if (blockAccount) accountBlocked = true;
+ if (!preserveQueuedRequests) accountEpoch++;
+ generation++;
+ for (const [controller, accountBound] of pendingRequests) if (accountBound) controller.abort();
+ const previousConnection = connection;
+ connection = null;
+ connecting = null;
+ previousConnection?.stop().catch(() => {});
+ token = "";
+ hintedRevision = "0";
+ hasInboxSnapshot = false;
+ session = { ...ANONYMOUS_SESSION };
+ inbox = { items: [], unreadCount: 0, revision: "0", nextCursor: null };
+ writeState(generation, "remove", [settings.stateStorageKey]).catch(() => {});
+ updateBadge().catch(() => {});
+ broadcast("session", session).catch(() => {});
+ broadcast("inbox", inbox).catch(() => {});
+ }
+
+ function refreshSession() {
+ const work = accountQueue
+ .catch(() => {})
+ .then(async () => {
+ const beforeRead = generation;
+ if (stopped || accountBlocked) return session;
+ const consent = await hasConsent();
+ assertCurrent(beforeRead);
+ let nextToken = "";
+ if (consent) {
+ if (settings.testMode) {
+ const data = await store("get", [settings.authStorageKey]);
+ nextToken = data?.[settings.authStorageKey]?.sessionToken || "";
+ } else {
+ const data = await callBrowser(api, api.storage.sync, "get", [
+ "patreonAuthenticated",
+ "patreonSessionToken",
+ ]);
+ nextToken = data?.patreonAuthenticated ? data.patreonSessionToken || "" : "";
+ }
+ }
+ assertCurrent(beforeRead);
+ if (
+ (nextToken && nextToken === rejectedToken) ||
+ (nextToken === token && (!nextToken || session.authenticated))
+ )
+ return session;
+ clearSession(false, true);
+ const expected = generation;
+ token = nextToken;
+ if (!token) return session;
+ try {
+ session = safeSession(await request("/me", { expected }));
+ } catch (error) {
+ assertCurrent(expected);
+ if (error.status !== 401) throw error;
+ rejectedToken = token;
+ token = "";
+ session = { ...ANONYMOUS_SESSION };
+ return session;
+ }
+ assertCurrent(expected);
+ await broadcast("session", session, expected);
+ await catchup(false, expected).catch(() => {});
+ connect(expected).catch(() => {});
+ return session;
+ });
+ accountQueue = work;
+ return work;
+ }
+
+ async function withReactions(result, expected, single = false, scope) {
+ assertScope(scope);
+ const items = single ? [result] : result.items || [];
+ if (!items.length) return result;
+ try {
+ const registered = await credentials.getRegisteredUser();
+ assertCurrent(expected);
+ assertScope(scope);
+ if (!registered) {
+ for (const item of items) item.myReaction = 0;
+ return result;
+ }
+ const { userId } = registered;
+ const values = await request("/reactions/state", {
+ method: "POST",
+ body: { userId, commentIds: items.map((item) => item.id) },
+ expected,
+ scope,
+ });
+ const ownValues = new Map((values.items || []).map((item) => [item.commentId, item.value]));
+ for (const item of items) item.myReaction = ownValues.get(item.id) ?? 0;
+ } catch (error) {
+ assertCurrent(expected);
+ assertScope(scope);
+ // Public reading remains available when stored vote credentials are unavailable.
+ }
+ return result;
+ }
+
+ function react(payload, expected, scope) {
+ const previous = reactionQueues.get(payload.id) || Promise.resolve();
+ const work = previous
+ .catch(() => {})
+ .then(async () => {
+ assertCurrent(expected);
+ assertScope(scope);
+ const { userId } = await credentials.ensureRegistered();
+ assertCurrent(expected);
+ assertScope(scope);
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const puzzle = await request(`/comments/${payload.id}/reaction/challenge`, {
+ method: "POST",
+ body: { userId, value: payload.value },
+ expected,
+ scope,
+ });
+ const solved = await solve(puzzle);
+ assertCurrent(expected);
+ assertScope(scope);
+ if (!solved) continue;
+ return request(`/comments/${payload.id}/reaction/confirm`, {
+ method: "POST",
+ body: { userId, challengeId: puzzle.challengeId, solution: solved.solution },
+ expected,
+ scope,
+ });
+ }
+ throw new DiscussionError("puzzle_failed", "Please try the reaction again");
+ });
+ reactionQueues.set(payload.id, work);
+ work
+ .finally(() => {
+ if (reactionQueues.get(payload.id) === work) reactionQueues.delete(payload.id);
+ })
+ .catch(() => {});
+ return work;
+ }
+
+ async function execute(operation, payload, scope) {
+ assertScope(scope);
+ if (operation === "capabilities") return request("/capabilities", { auth: "", accountBound: false });
+ const queuedEpoch = accountEpoch;
+ await refreshSession();
+ if (queuedEpoch !== accountEpoch) throw new DiscussionError("session_changed");
+ assertScope(scope);
+ const expected = generation;
+ const { id, cursor } = payload;
+ const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
+ const send = (path, options = {}) => request(path, { ...options, scope });
+ switch (operation) {
+ case "session":
+ return session;
+ case "profile": {
+ const data = safeSession(
+ await send("/me/profile", { method: "PUT", body: { nickname: payload.nickname }, expected }),
+ );
+ session = data;
+ await broadcast("session", session, expected);
+ return data;
+ }
+ case "comments":
+ return withReactions(
+ await send(
+ `/videos/${payload.videoId}/comments?sort=${payload.sort || "top"}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
+ { expected },
+ ),
+ expected,
+ false,
+ scope,
+ );
+ case "replies":
+ return withReactions(
+ await send(`/comments/${payload.rootId}/replies${query}`, { expected }),
+ expected,
+ false,
+ scope,
+ );
+ case "comment":
+ return withReactions(await send(`/comments/${id}`, { expected }), expected, true, scope);
+ case "createComment": {
+ const { videoId, ...body } = payload;
+ return send(`/videos/${videoId}/comments`, { method: "POST", body, expected });
+ }
+ case "editComment":
+ return send(`/comments/${id}`, { method: "PATCH", body: { text: payload.text }, expected });
+ case "deleteComment":
+ return send(`/comments/${id}`, { method: "DELETE", expected });
+ case "react":
+ return react(payload, expected, scope);
+ case "report":
+ return send(`/comments/${id}/reports`, { method: "POST", body: { reason: payload.reason }, expected });
+ case "reports":
+ return request(
+ `/moderation/reports?status=${payload.status || "open"}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
+ { expected },
+ );
+ case "dismissReport":
+ return request(`/moderation/reports/${id}/dismiss`, {
+ method: "POST",
+ body: { reason: payload.reason },
+ expected,
+ });
+ case "moderateComment":
+ return request(`/moderation/comments/${id}`, {
+ method: "POST",
+ body: { hidden: payload.hidden, reason: payload.reason },
+ expected,
+ });
+ case "suspendProfile":
+ return request(`/moderation/profiles/${id}/suspension`, {
+ method: "POST",
+ body: { suspended: payload.suspended, reason: payload.reason },
+ expected,
+ });
+ case "notifications":
+ return request(`/notifications${query}`, { expected });
+ case "readNotifications": {
+ const data = await request("/notifications/read", { method: "POST", body: payload, expected });
+ await catchup(false, expected);
+ return data;
+ }
+ default:
+ throw new DiscussionError("invalid_request");
+ }
+ }
+
+ async function handleMessage(requestMessage, sender) {
+ try {
+ const { operation, payload, scope: scopeValue } = validateRequest(api, requestMessage, sender);
+ const scope = scopeFor(scopeValue, sender);
+ if (operation === "cancelScope") {
+ cancelScope(scope);
+ return { ok: true, data: { cancelled: true } };
+ }
+ const data = await execute(operation, payload, scope);
+ assertScope(scope);
+ if (
+ scope &&
+ ((data?.videoId && data.videoId !== scope.videoId) ||
+ data?.items?.some((item) => item.videoId && item.videoId !== scope.videoId))
+ )
+ throw new DiscussionError("navigation_changed");
+ return { ok: true, data };
+ } catch (error) {
+ return {
+ ok: false,
+ error: {
+ code: error.code || "request_failed",
+ message: error.message || "Request failed",
+ status: error.status || 0,
+ },
+ };
+ }
+ }
+
+ function onStorageChanged(changes, area) {
+ const changed = settings.testMode
+ ? area === "local" && changes[settings.authStorageKey]
+ : area === "sync" && (changes.patreonAuthenticated || changes.patreonSessionToken || changes.patreonUser);
+ if (changed) {
+ rejectedToken = "";
+ clearSession();
+ refreshSession().catch(() => {});
+ }
+ }
+
+ function initialize() {
+ api.tabs?.onRemoved?.addListener((tabId) => {
+ for (const [key, scope] of navigationScopes)
+ if (scope.tabId === tabId) {
+ cancelScope(scope);
+ navigationScopes.delete(key);
+ }
+ });
+ api.alarms?.onAlarm?.addListener((alarm) => {
+ if (alarm.name !== RECONNECT_ALARM || stopped) return;
+ refreshSession()
+ .then(() => Promise.allSettled([catchup(false), connect()]))
+ .catch(() => {});
+ });
+ // Alarm recovery also covers service-worker termination and offline browser startup.
+ api.alarms?.create(RECONNECT_ALARM, { periodInMinutes: 1 });
+ refreshSession().catch(() => {});
+ }
+
+ return {
+ handleMessage,
+ refreshSession,
+ clearSession,
+ resumeSession() {
+ accountBlocked = false;
+ return refreshSession();
+ },
+ onStorageChanged,
+ initialize,
+ stop() {
+ clearSession();
+ stopped = true;
+ for (const controller of pendingRequests.keys()) controller.abort();
+ api.alarms?.clear(RECONNECT_ALARM);
+ },
+ };
+}
+
+export { ANONYMOUS_SESSION, createConnection, validateRequest };
diff --git a/Extensions/combined/src/discussion-background.spec.js b/Extensions/combined/src/discussion-background.spec.js
new file mode 100644
index 0000000..9bb1999
--- /dev/null
+++ b/Extensions/combined/src/discussion-background.spec.js
@@ -0,0 +1,574 @@
+import { createDiscussionBackground, ANONYMOUS_SESSION } from "./discussion-background";
+
+const ID = "11111111-1111-4111-8111-111111111111";
+const PROFILE_ID = "22222222-2222-4222-8222-222222222222";
+const NOTIFICATION_ID = "33333333-3333-4333-8333-333333333333";
+const VIDEO_ID = "video123456";
+const ORIGIN = "https://returnyoutubedislikeapi.com";
+const SCOPE = { id: "44444444-4444-4444-8444-444444444444", videoId: VIDEO_ID };
+const VIDEO_OPERATIONS = new Set([
+ "comments",
+ "replies",
+ "comment",
+ "createComment",
+ "editComment",
+ "deleteComment",
+ "react",
+ "report",
+ "cancelScope",
+]);
+const sender = {
+ id: "extension-id",
+ url: `https://www.youtube.com/watch?v=${VIDEO_ID}`,
+ tab: { id: 1, url: `https://www.youtube.com/watch?v=${VIDEO_ID}` },
+ frameId: 0,
+};
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+const deferred = () => {
+ let resolve;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+};
+
+function storageArea(data = {}) {
+ return {
+ data,
+ get: jest.fn((keys, callback) => callback(Object.fromEntries(keys.map((key) => [key, data[key]])))),
+ set: jest.fn((values, callback) => {
+ Object.assign(data, values);
+ callback?.();
+ }),
+ remove: jest.fn((keys, callback) => {
+ keys.forEach((key) => delete data[key]);
+ callback?.();
+ }),
+ };
+}
+
+function harness({ authenticated = false, consent = true, fetchOverride, testMode = false, solveOverride } = {}) {
+ const sync = storageArea(authenticated ? { patreonAuthenticated: true, patreonSessionToken: "account-token" } : {});
+ const local = storageArea();
+ let alarmListener;
+ const api = {
+ runtime: {
+ id: "extension-id",
+ getURL: (path) => `chrome-extension://extension-id/${path}`,
+ sendMessage: jest.fn((message, callback) => callback?.()),
+ },
+ storage: { sync, local },
+ action: {
+ setBadgeText: jest.fn((value, callback) => callback?.()),
+ setBadgeBackgroundColor: jest.fn((value, callback) => callback?.()),
+ },
+ tabs: {
+ query: jest.fn((query, callback) =>
+ callback([
+ { id: 1, url: sender.url },
+ { id: 2, url: sender.url },
+ { id: 3, url: "https://example.com/" },
+ ]),
+ ),
+ sendMessage: jest.fn((id, message, callback) => callback?.()),
+ },
+ alarms: {
+ create: jest.fn(),
+ clear: jest.fn(),
+ onAlarm: {
+ addListener: (listener) => {
+ alarmListener = listener;
+ },
+ },
+ },
+ };
+ const notification = {
+ id: NOTIFICATION_ID,
+ type: "likes",
+ commentId: ID,
+ rootId: ID,
+ videoId: VIDEO_ID,
+ actorNickname: null,
+ likeCount: 1,
+ read: false,
+ createdAt: "2026-09-06T10:00:00Z",
+ updatedAt: "2026-09-06T10:00:00Z",
+ revision: "1",
+ };
+ let inbox = {
+ items: authenticated ? [notification] : [],
+ unreadCount: authenticated ? 1 : 0,
+ revision: "1",
+ nextCursor: null,
+ };
+ const calls = [];
+ const fetchImpl = jest.fn(async (url, options) => {
+ const path = url.replace(`${ORIGIN}/api/discussion`, "");
+ const body = options.body ? JSON.parse(options.body) : null;
+ calls.push({ path, body, options, url });
+ if (fetchOverride) {
+ const override = await fetchOverride(path, options, body);
+ if (override) return override;
+ }
+ let data;
+ if (path === "/me" || path === "/me/profile")
+ data = {
+ authenticated: true,
+ canPost: true,
+ isModerator: false,
+ membershipTier: "commenter",
+ profile: { id: PROFILE_ID, nickname: "Tester", suspended: false, providerId: "private" },
+ token: "private",
+ userId: "private",
+ };
+ else if (path.startsWith("/notifications/ticket"))
+ data = { token: "hub-ticket", expiresAt: "2026-09-06T10:01:00Z" };
+ else if (path.startsWith("/notifications")) data = inbox;
+ else if (path.endsWith("/reaction/challenge"))
+ data = { challengeId: ID, challenge: "AAAAAAAAAAAAAAAAAAAAAA==", difficulty: 1 };
+ else if (path.endsWith("/reaction/confirm")) data = { commentId: ID, likes: 1, dislikes: 0, myReaction: 1 };
+ else if (path === "/reactions/state") data = { items: [{ commentId: ID, value: -1 }] };
+ else if (path.includes("/comments"))
+ data = {
+ items: [{ id: ID, videoId: VIDEO_ID, author: { id: PROFILE_ID, nickname: "Tester" }, text: "Hello" }],
+ nextCursor: null,
+ };
+ else data = { enabled: true };
+ return { ok: true, status: 200, json: async () => structuredClone(data) };
+ });
+ const sockets = [];
+ const connectionFactory = jest.fn((url, tokenFactory) => {
+ const handlers = {};
+ const socket = {
+ url,
+ tokenFactory,
+ handlers,
+ on: (name, handler) => {
+ handlers[name] = handler;
+ },
+ onclose: (handler) => {
+ handlers.close = handler;
+ },
+ onreconnected: (handler) => {
+ handlers.reconnected = handler;
+ },
+ start: jest.fn(async () => {
+ await tokenFactory();
+ }),
+ stop: jest.fn(async () => {}),
+ };
+ sockets.push(socket);
+ return socket;
+ });
+ const voteClient = {
+ ensureRegistered: jest.fn(async () => ({ userId: "existing-voter-secret" })),
+ getRegisteredUser: jest.fn(async () => ({ userId: "existing-voter-secret" })),
+ };
+ const client = createDiscussionBackground({
+ api,
+ voteClient,
+ fetchImpl,
+ hasConsent: async () => consent,
+ connectionFactory,
+ solve: solveOverride || jest.fn(async () => ({ solution: "AAAAAA==" })),
+ settings: {
+ apiOrigin: ORIGIN,
+ testMode,
+ authStorageKey: "testAuth",
+ credentialStorageKey: "testCredentials",
+ stateStorageKey: "discussionState",
+ },
+ });
+ return {
+ api,
+ client,
+ calls,
+ sockets,
+ fetchImpl,
+ voteClient,
+ sync,
+ local,
+ notification,
+ setInbox: (value) => {
+ inbox = value;
+ },
+ alarm: () => alarmListener({ name: "ryd-discussion-reconnect" }),
+ send: (operation, payload = {}, source = sender, scope = SCOPE) =>
+ client.handleMessage(
+ {
+ message: "ryd_discussion",
+ operation,
+ payload,
+ ...(VIDEO_OPERATIONS.has(operation) && scope ? { scope } : {}),
+ },
+ source,
+ ),
+ };
+}
+
+describe("discussion worker boundary", () => {
+ test("public comments do not create a voter identity before the first reaction", async () => {
+ const h = harness();
+ h.voteClient.getRegisteredUser.mockResolvedValue(null);
+ const response = await h.send("comments", { videoId: VIDEO_ID, sort: "new" });
+ expect(response.ok).toBe(true);
+ expect(response.data.items[0].myReaction).toBe(0);
+ expect(h.voteClient.ensureRegistered).not.toHaveBeenCalled();
+ expect(h.calls.some((call) => call.path === "/reactions/state")).toBe(false);
+ });
+ test.each([
+ ["comments", { videoId: VIDEO_ID, userId: "forged" }, sender],
+ ["session", { sessionToken: "forged" }, sender],
+ ["react", { id: ID, value: 1, recipient: "forged" }, sender],
+ ["session", {}, { ...sender, id: "different-extension" }],
+ ["session", {}, { ...sender, frameId: 1 }],
+ ["session", {}, { ...sender, url: "https://www.youtube.com.evil.test/watch?v=video123456" }],
+ ["fetch", { url: "https://example.com" }, sender],
+ ["react", { id: ID, value: 2 }, sender],
+ ["createComment", { videoId: VIDEO_ID, text: "Hi" }, sender],
+ ])("rejects invalid %s requests before sending network traffic", async (operation, payload, source) => {
+ const h = harness();
+ expect((await h.send(operation, payload, source)).ok).toBe(false);
+ expect(h.fetchImpl).not.toHaveBeenCalled();
+ });
+
+ test("public reads enrich viewer reactions without exposing the voter credential", async () => {
+ const h = harness();
+ const response = await h.send("comments", { videoId: VIDEO_ID, sort: "new" });
+ expect(response.ok).toBe(true);
+ expect(response.data.items[0].myReaction).toBe(-1);
+ expect(JSON.stringify(response)).not.toContain("existing-voter-secret");
+ expect(h.calls.find((call) => call.path === "/reactions/state").body).toEqual({
+ userId: "existing-voter-secret",
+ commentIds: [ID],
+ });
+ expect(h.calls.every((call) => !call.options.headers.Authorization)).toBe(true);
+ expect(h.sockets).toHaveLength(0);
+ });
+
+ test("preserves the same anonymous reaction identity across sign-in and sign-out", async () => {
+ const h = harness();
+ await h.send("react", { id: ID, value: 1 });
+ Object.assign(h.sync.data, { patreonAuthenticated: true, patreonSessionToken: "account-token" });
+ await h.send("react", { id: ID, value: -1 });
+ delete h.sync.data.patreonSessionToken;
+ await h.send("react", { id: ID, value: 0 });
+ expect(h.calls.filter((call) => call.path.endsWith("/reaction/challenge")).map((call) => call.body)).toEqual([
+ { userId: "existing-voter-secret", value: 1 },
+ { userId: "existing-voter-secret", value: -1 },
+ { userId: "existing-voter-secret", value: 0 },
+ ]);
+ expect(h.sync.set).not.toHaveBeenCalled();
+ expect(h.sync.remove).not.toHaveBeenCalled();
+ expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION);
+ h.client.stop();
+ });
+
+ test("returns a safe server session to the popup and uses only hub tickets on sockets", async () => {
+ const h = harness({ authenticated: true });
+ const response = await h.send(
+ "session",
+ {},
+ { id: "extension-id", url: "chrome-extension://extension-id/popup.html" },
+ );
+ await flush();
+ expect(response.data.profile).toEqual({ id: PROFILE_ID, nickname: "Tester", suspended: false });
+ expect(JSON.stringify(response)).not.toMatch(/private|account-token|existing-voter-secret/);
+ expect(h.sockets).toHaveLength(1);
+ expect(await h.sockets[0].tokenFactory()).toBe("hub-ticket");
+ expect(h.sockets[0].url).not.toContain("token");
+ h.client.stop();
+ });
+
+ test("does not read or transmit stored account credentials after consent removal", async () => {
+ const h = harness({ authenticated: true, consent: false });
+ expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION);
+ expect(h.sync.get).not.toHaveBeenCalled();
+ expect(h.fetchImpl).not.toHaveBeenCalled();
+ });
+
+ test("test accounts never fall back to real Patreon storage", async () => {
+ const h = harness({ authenticated: true, testMode: true });
+ expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION);
+ expect(h.sync.get).not.toHaveBeenCalled();
+ expect(h.fetchImpl).not.toHaveBeenCalled();
+ });
+
+ test("discards an account response arriving after logout", async () => {
+ const pending = deferred();
+ const h = harness({ authenticated: true, fetchOverride: (path) => (path === "/me" ? pending.promise : undefined) });
+ const response = h.send("session");
+ await flush();
+ h.client.clearSession();
+ pending.resolve({
+ ok: true,
+ status: 200,
+ json: async () => ({ authenticated: true, canPost: true, profile: { id: PROFILE_ID, nickname: "Old account" } }),
+ });
+ expect((await response).ok).toBe(false);
+ expect(
+ h.api.tabs.sendMessage.mock.calls.some(([, event]) => event.type === "session" && event.data.authenticated),
+ ).toBe(false);
+ expect(h.sockets).toHaveLength(0);
+ });
+
+ test("the public capability probe survives account initialization and logout", async () => {
+ const pending = deferred();
+ let signal;
+ const h = harness({
+ fetchOverride: (path, options) => {
+ if (path !== "/capabilities") return;
+ signal = options.signal;
+ return pending.promise;
+ },
+ });
+ const response = h.send("capabilities");
+ await flush();
+ h.client.clearSession();
+ expect(signal.aborted).toBe(false);
+ pending.resolve({ ok: true, status: 200, json: async () => ({ enabled: true }) });
+ expect(await response).toEqual({ ok: true, data: { enabled: true } });
+ expect(h.calls[0].options.headers.Authorization).toBeUndefined();
+ h.client.stop();
+ });
+
+ test("does not restore an old account while logout storage cleanup is pending", async () => {
+ const h = harness({ authenticated: true });
+ await h.send("session");
+ h.client.clearSession(true);
+ expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION);
+ delete h.sync.data.patreonSessionToken;
+ await h.client.resumeSession();
+ expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION);
+ h.client.stop();
+ });
+
+ test("a request queued across an account change cannot execute under the replacement account", async () => {
+ const held = deferred();
+ const h = harness({ authenticated: true, fetchOverride: (path) => (path === "/me" ? held.promise : undefined) });
+ const reading = h.send("session");
+ await flush();
+ const queued = h.send("createComment", { videoId: VIDEO_ID, text: "Old account draft", requestId: ID });
+ h.client.clearSession(true);
+ held.resolve({ ok: true, status: 200, json: async () => ({ authenticated: true }) });
+ expect((await reading).ok).toBe(false);
+ expect((await queued).error.code).toBe("session_changed");
+ expect(h.calls.some((call) => call.path.includes("/videos/"))).toBe(false);
+ h.client.stop();
+ });
+
+ test("moderation reports accept only open or resolved status and encode their cursor", async () => {
+ const h = harness();
+ expect((await h.send("reports", { status: "resolved", cursor: "opaque&value" })).ok).toBe(true);
+ expect(h.calls.at(-1).path).toBe("/moderation/reports?status=resolved&cursor=opaque%26value");
+ expect((await h.send("reports", { status: "hidden" })).error.code).toBe("invalid_request");
+ });
+
+ test("serializes reactions so a slow confirmation cannot overwrite a newer choice", async () => {
+ const pending = deferred();
+ let confirms = 0;
+ const h = harness({
+ fetchOverride: (path) => (path.endsWith("/reaction/confirm") && confirms++ === 0 ? pending.promise : undefined),
+ });
+ const first = h.send("react", { id: ID, value: 1 });
+ const second = h.send("react", { id: ID, value: -1 });
+ await flush();
+ expect(h.calls.filter((call) => call.path.endsWith("/reaction/challenge"))).toHaveLength(1);
+ pending.resolve({
+ ok: true,
+ status: 200,
+ json: async () => ({ commentId: ID, likes: 1, dislikes: 0, myReaction: 1 }),
+ });
+ await Promise.all([first, second]);
+ expect(h.calls.filter((call) => call.path.endsWith("/reaction/challenge")).map((call) => call.body.value)).toEqual([
+ 1, -1,
+ ]);
+ });
+
+ test("updates grouped notification revisions and emits one active-tab toast without replaying history", async () => {
+ const h = harness({ authenticated: true });
+ await h.send("session");
+ await flush();
+ expect(h.api.tabs.sendMessage.mock.calls.filter(([, event]) => event.type === "toast")).toHaveLength(0);
+ expect(h.api.action.setBadgeText).toHaveBeenLastCalledWith({ text: "1" }, expect.any(Function));
+ const updated = { ...h.notification, revision: "2", likeCount: 2, updatedAt: "2026-09-06T10:05:00Z" };
+ h.setInbox({ items: [updated], unreadCount: 1, revision: "2", nextCursor: null });
+ h.sockets[0].handlers.NotificationsChanged({ revision: "2" });
+ await flush();
+ await flush();
+ h.sockets[0].handlers.NotificationsChanged({ revision: "2" });
+ await flush();
+ const toasts = h.api.tabs.sendMessage.mock.calls.filter(([, event]) => event.type === "toast");
+ expect(toasts).toHaveLength(1);
+ expect(toasts[0][0]).toBe(1);
+ expect(toasts[0][1].data.likeCount).toBe(2);
+ expect(h.calls.some((call) => call.path === "/notifications?afterRevision=1")).toBe(true);
+ h.client.stop();
+ });
+
+ test("alarm reconnects a closed socket and silently catches up notifications", async () => {
+ const h = harness({ authenticated: true });
+ h.client.initialize();
+ await flush();
+ await flush();
+ h.sockets[0].handlers.close();
+ h.alarm();
+ await flush();
+ await flush();
+ expect(h.sockets).toHaveLength(2);
+ expect(h.api.tabs.sendMessage.mock.calls.filter(([, event]) => event.type === "toast")).toHaveLength(0);
+ h.client.stop();
+ });
+
+ test("a reconnect callback from a cleared account cannot request its old inbox", async () => {
+ const h = harness({ authenticated: true });
+ await h.send("session");
+ await flush();
+ const oldSocket = h.sockets[0];
+ h.client.clearSession(true);
+ await flush();
+ const count = h.calls.length;
+ oldSocket.handlers.reconnected();
+ await flush();
+ expect(h.calls).toHaveLength(count);
+ h.client.stop();
+ });
+
+ test("an expired cached account clears its inbox, badge and socket without changing the voter identity", async () => {
+ const h = harness({
+ authenticated: true,
+ fetchOverride: (path) =>
+ path.includes("cursor=expired")
+ ? { ok: false, status: 401, json: async () => ({ code: "authentication_required" }) }
+ : undefined,
+ });
+ await h.send("session");
+ await flush();
+ expect((await h.send("notifications", { cursor: "expired" })).error.status).toBe(401);
+ await flush();
+ expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION);
+ expect(h.sockets[0].stop).toHaveBeenCalled();
+ expect(h.api.action.setBadgeText).toHaveBeenLastCalledWith({ text: "" }, expect.any(Function));
+ expect(
+ h.api.tabs.sendMessage.mock.calls.some(([, event]) => event.type === "inbox" && event.data.items.length === 0),
+ ).toBe(true);
+ expect((await h.send("react", { id: ID, value: -1 })).ok).toBe(true);
+ const challenge = h.calls.findLast((call) => call.path.endsWith("/reaction/challenge"));
+ expect(challenge.body.userId).toBe("existing-voter-secret");
+ expect(challenge.options.headers.Authorization).toBeUndefined();
+ h.client.stop();
+ });
+
+ test("live inbox preserves history pagination without advancing its baseline from a manual history page", async () => {
+ let revision = "25";
+ const h = harness({
+ authenticated: true,
+ fetchOverride: (path) =>
+ path.includes("cursor=manual-history")
+ ? { ok: true, status: 200, json: async () => ({ items: [], revision: "999", nextCursor: null }) }
+ : path.includes("afterRevision=")
+ ? {
+ ok: true,
+ status: 200,
+ json: async () => ({
+ items: revision === "26" ? [{ ...h.notification, revision }] : [],
+ unreadCount: 25,
+ revision,
+ nextCursor: null,
+ }),
+ }
+ : undefined,
+ });
+ h.setInbox({ items: [h.notification], unreadCount: 25, revision: "25", nextCursor: "older-history" });
+ await h.send("session");
+ await flush();
+ expect((await h.send("notifications", { cursor: "manual-history" })).data.revision).toBe("999");
+ revision = "26";
+ h.sockets[0].handlers.NotificationsChanged({ revision: "26" });
+ await flush();
+ await flush();
+ const event = h.api.tabs.sendMessage.mock.calls.findLast(([, event]) => event.type === "inbox")[1];
+ expect(event.data.nextCursor).toBe("older-history");
+ expect(h.calls.findLast((call) => call.path.includes("afterRevision=")).path).toBe(
+ "/notifications?afterRevision=25",
+ );
+ expect(event.data.revision).toBe("26");
+ h.client.stop();
+ });
+
+ test("a voter registration rejection does not clear a valid logged-in account or its inbox", async () => {
+ const h = harness({
+ authenticated: true,
+ fetchOverride: (path) =>
+ path.endsWith("/reaction/challenge")
+ ? { ok: false, status: 401, json: async () => ({ code: "voter_registration_required" }) }
+ : undefined,
+ });
+ await h.send("session");
+ await flush();
+ expect((await h.send("react", { id: ID, value: 1 })).error.code).toBe("voter_registration_required");
+ expect((await h.send("session")).data.authenticated).toBe(true);
+ expect(h.sockets[0].stop).not.toHaveBeenCalled();
+ expect(h.api.action.setBadgeText).toHaveBeenLastCalledWith({ text: "1" }, expect.any(Function));
+ h.client.stop();
+ });
+
+ test("navigation aborts only the selected document scope and keeps another document and inbox request alive", async () => {
+ const pending = [];
+ let hold = false;
+ const h = harness({
+ authenticated: true,
+ fetchOverride: (path, options) => {
+ if (!hold || (!path.startsWith("/videos/") && !path.includes("cursor=pending"))) return;
+ return new Promise((resolve, reject) => {
+ pending.push({ path, signal: options.signal, resolve });
+ options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
+ });
+ },
+ });
+ await h.send("session");
+ await flush();
+ hold = true;
+ const firstSource = { ...sender, documentId: "document-one" };
+ const secondSource = { ...sender, documentId: "document-two" };
+ const first = h.send("comments", { videoId: VIDEO_ID }, firstSource);
+ const second = h.send("comments", { videoId: VIDEO_ID }, secondSource);
+ const inbox = h.send("notifications", { cursor: "pending" });
+ await flush();
+ expect(pending).toHaveLength(3);
+ expect((await h.send("cancelScope", {}, firstSource)).ok).toBe(true);
+ expect(pending[0].signal.aborted).toBe(true);
+ expect(pending[1].signal.aborted).toBe(false);
+ expect(pending[2].signal.aborted).toBe(false);
+ for (const request of pending.slice(1))
+ request.resolve({ ok: true, status: 200, json: async () => ({ items: [], nextCursor: null }) });
+ expect((await first).error.code).toBe("navigation_changed");
+ expect((await second).ok).toBe(true);
+ expect((await inbox).ok).toBe(true);
+ h.client.stop();
+ });
+
+ test("cancelled scopes cannot confirm a solved puzzle or start a queued reaction", async () => {
+ const solved = deferred();
+ const h = harness({ solveOverride: () => solved.promise });
+ const first = h.send("react", { id: ID, value: 1 });
+ const queued = h.send("react", { id: ID, value: -1 });
+ await flush();
+ await h.send("cancelScope");
+ solved.resolve({ solution: "AAAAAA==" });
+ expect((await first).error.code).toBe("navigation_changed");
+ expect((await queued).error.code).toBe("navigation_changed");
+ expect(h.calls.filter((call) => call.path.endsWith("/reaction/challenge"))).toHaveLength(1);
+ expect(h.calls.filter((call) => call.path.endsWith("/reaction/confirm"))).toHaveLength(0);
+ expect((await h.send("comments", { videoId: VIDEO_ID })).error.code).toBe("navigation_changed");
+ });
+
+ test("video operations require a scope bound to the current tab video", async () => {
+ const h = harness();
+ expect((await h.send("comments", { videoId: VIDEO_ID }, sender, null)).error.code).toBe("scope_required");
+ expect(
+ (await h.send("comments", { videoId: VIDEO_ID }, sender, { ...SCOPE, videoId: "other123456" })).error.code,
+ ).toBe("navigation_changed");
+ expect(h.fetchImpl).not.toHaveBeenCalled();
+ });
+});
diff --git a/Extensions/combined/src/discussion-config.js b/Extensions/combined/src/discussion-config.js
new file mode 100644
index 0000000..745fcba
--- /dev/null
+++ b/Extensions/combined/src/discussion-config.js
@@ -0,0 +1,11 @@
+import { getApiUrl } from "./config";
+
+const testOrigin = typeof __RYD_DISCUSSION_TEST_ORIGIN__ === "string" ? __RYD_DISCUSSION_TEST_ORIGIN__ : "";
+
+export const discussionConfig = Object.freeze({
+ apiOrigin: testOrigin || getApiUrl(),
+ testMode: Boolean(testOrigin),
+ authStorageKey: testOrigin ? "rydDiscussionTestAuth" : null,
+ credentialStorageKey: "rydDiscussionTestCredentials",
+ stateStorageKey: testOrigin ? "rydDiscussionTestState" : "rydDiscussionState",
+});
diff --git a/Extensions/combined/src/discussion-navigation-fetch.spec.js b/Extensions/combined/src/discussion-navigation-fetch.spec.js
new file mode 100644
index 0000000..628e084
--- /dev/null
+++ b/Extensions/combined/src/discussion-navigation-fetch.spec.js
@@ -0,0 +1,94 @@
+import { createServer } from "node:http";
+import { createDiscussionBackground } from "./discussion-background";
+
+test("navigation cancels a real HTTP fetch while another tab and the inbox finish normally", async () => {
+ const videoId = "abcdefghijk";
+ const otherVideoId = "zyxwvutsrqp";
+ const held = [];
+ let allRequested;
+ let outgoingClosed;
+ const requested = new Promise((resolve) => {
+ allRequested = resolve;
+ });
+ const closed = new Promise((resolve) => {
+ outgoingClosed = resolve;
+ });
+ const server = createServer((request, response) => {
+ held.push({ url: request.url, response });
+ if (request.url.includes(videoId)) response.once("close", outgoingClosed);
+ if (held.length === 3) allRequested();
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const area = {
+ get: (keys, callback) => callback({}),
+ set: (values, callback) => callback?.(),
+ remove: (keys, callback) => callback?.(),
+ };
+ const api = {
+ runtime: { id: "extension-id", getURL: (path) => `chrome-extension://extension-id/${path}` },
+ storage: { sync: area, local: area },
+ };
+ const signals = new Map();
+ const client = createDiscussionBackground({
+ api,
+ voteClient: { ensureRegistered: async () => ({ userId: "unused" }) },
+ settings: { apiOrigin: `http://127.0.0.1:${server.address().port}`, testMode: false, stateStorageKey: "state" },
+ fetchImpl: (url, options) => {
+ signals.set(new URL(url).pathname, options.signal);
+ return fetch(url, options);
+ },
+ });
+ const sender = (id, video) => ({
+ id: "extension-id",
+ frameId: 0,
+ documentId: `document-${id}`,
+ url: `https://www.youtube.com/watch?v=${video}`,
+ tab: { id, url: `https://www.youtube.com/watch?v=${video}` },
+ });
+ const scope = { id: "44444444-4444-4444-8444-444444444444", videoId };
+ const send = (operation, payload, source, value) =>
+ client.handleMessage(
+ {
+ message: "ryd_discussion",
+ operation,
+ payload,
+ ...(value ? { scope: value } : {}),
+ },
+ source,
+ );
+ try {
+ const outgoing = send("comments", { videoId }, sender(1, videoId), scope);
+ const other = send("comments", { videoId: otherVideoId }, sender(2, otherVideoId), {
+ ...scope,
+ videoId: otherVideoId,
+ });
+ const inbox = send("notifications", { cursor: "pending" }, sender(1, videoId));
+ await requested;
+ const cancellation = await send(
+ "cancelScope",
+ {},
+ {
+ ...sender(1, videoId),
+ tab: { id: 1, url: "https://www.youtube.com/" },
+ },
+ scope,
+ );
+ expect(cancellation.ok).toBe(true);
+ expect((await outgoing).error.code).toBe("navigation_changed");
+ await closed;
+ expect(signals.get(`/api/discussion/videos/${videoId}/comments`).aborted).toBe(true);
+ expect(signals.get(`/api/discussion/videos/${otherVideoId}/comments`).aborted).toBe(false);
+ expect(signals.get("/api/discussion/notifications").aborted).toBe(false);
+ for (const { url, response } of held)
+ if (!url.includes(videoId)) {
+ response.writeHead(200, { "Content-Type": "application/json" });
+ response.end(JSON.stringify({ items: [], nextCursor: null }));
+ }
+ expect((await other).ok).toBe(true);
+ expect((await inbox).ok).toBe(true);
+ } finally {
+ client.stop();
+ server.closeAllConnections();
+ await new Promise((resolve) => server.close(resolve));
+ }
+});
diff --git a/Extensions/combined/src/discussion-notifications-socket.spec.js b/Extensions/combined/src/discussion-notifications-socket.spec.js
new file mode 100644
index 0000000..7b4290e
--- /dev/null
+++ b/Extensions/combined/src/discussion-notifications-socket.spec.js
@@ -0,0 +1,136 @@
+import { WebSocketServer } from "ws";
+import { createDiscussionBackground, createConnection } from "./discussion-background";
+
+test("real SignalR events and reconnects fetch durable notifications with fresh hub tickets", async () => {
+ const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
+ await new Promise((resolve) => server.once("listening", resolve));
+ const profileId = "22222222-2222-4222-8222-222222222222";
+ let peer;
+ const receivedTickets = [];
+ let ticketRequests = 0;
+ let connected;
+ const ready = new Promise((resolve) => {
+ connected = resolve;
+ });
+ server.on("connection", (socket, request) => {
+ peer = socket;
+ receivedTickets.push(request.headers.authorization);
+ expect(request.headers.authorization).toBe(`Bearer hub-ticket-${ticketRequests}`);
+ expect(request.url).toBe("/api/discussion/notifications/hub");
+ socket.on("message", (bytes) => {
+ const message = JSON.parse(bytes.toString().split("\u001e")[0]);
+ if (message.protocol) {
+ socket.send("{}\u001e");
+ connected();
+ }
+ });
+ });
+ let resolveToast;
+ let resolveReconnectedInbox;
+ const reconnectedInbox = new Promise((resolve) => {
+ resolveReconnectedInbox = resolve;
+ });
+ let toasts = 0;
+ const toast = new Promise((resolve) => {
+ resolveToast = resolve;
+ });
+ const area = {
+ get: (keys, callback) => callback({ patreonAuthenticated: true, patreonSessionToken: "account-token" }),
+ set: (values, callback) => callback?.(),
+ remove: (keys, callback) => callback?.(),
+ };
+ const api = {
+ runtime: {
+ id: "extension-id",
+ getURL: (path) => `chrome-extension://extension-id/${path}`,
+ sendMessage: (message, callback) => callback?.(),
+ },
+ storage: { sync: area, local: area },
+ tabs: {
+ query: (query, callback) => callback([{ id: 1, url: "https://www.youtube.com/watch?v=video123456" }]),
+ sendMessage: (id, message, callback) => {
+ if (message.type === "toast") {
+ toasts++;
+ resolveToast(message.data);
+ }
+ if (message.type === "inbox" && message.data.revision === "2") resolveReconnectedInbox(message.data);
+ callback?.();
+ },
+ },
+ };
+ let revision = "0";
+ const notification = {
+ id: "33333333-3333-4333-8333-333333333333",
+ commentId: "11111111-1111-4111-8111-111111111111",
+ type: "reply",
+ read: false,
+ revision: "1",
+ updatedAt: "2026-09-06T10:05:00Z",
+ };
+ const notificationReads = [];
+ const client = createDiscussionBackground({
+ api,
+ voteClient: { ensureRegistered: async () => ({ userId: "unused" }) },
+ settings: { apiOrigin: "https://returnyoutubedislikeapi.com", testMode: false, stateStorageKey: "state" },
+ connectionFactory: (url, tokenFactory) =>
+ createConnection(`http://127.0.0.1:${server.address().port}/api/discussion/notifications/hub`, tokenFactory),
+ fetchImpl: async (url) => {
+ let data;
+ if (url.endsWith("/me"))
+ data = { authenticated: true, profile: { id: profileId, nickname: "Tester" }, canPost: true };
+ else if (url.endsWith("/ticket")) data = { token: `hub-ticket-${++ticketRequests}` };
+ else {
+ notificationReads.push(url);
+ data = {
+ items: revision === "0" ? [] : [notification],
+ unreadCount: revision === "0" ? 0 : 1,
+ revision,
+ nextCursor: null,
+ };
+ }
+ return { ok: true, status: 200, json: async () => data };
+ },
+ });
+ try {
+ await client.refreshSession();
+ await ready;
+ // Let the socket's initial durable catchup complete before sending a new event.
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ const before = notificationReads.length;
+ revision = "1";
+ peer.send(`${JSON.stringify({ type: 1, target: "NotificationsChanged", arguments: [{ revision }] })}\u001e`);
+ expect(await toast).toEqual(notification);
+ expect(notificationReads.length).toBeGreaterThan(before);
+ expect(notificationReads.at(-1)).toContain("afterRevision=0");
+ revision = "2";
+ notification.revision = revision;
+ peer.terminate();
+ const recovered = await reconnectedInbox;
+ expect(recovered.revision).toBe("2");
+ expect(receivedTickets).toEqual(["Bearer hub-ticket-1", "Bearer hub-ticket-2"]);
+ expect(notificationReads.at(-1)).toContain("afterRevision=1");
+ expect(toasts).toBe(1);
+ } finally {
+ client.stop();
+ for (const socket of server.clients) socket.terminate();
+ await new Promise((resolve) => server.close(resolve));
+ }
+});
+
+test("the configured SignalR retry policy stops after four reconnect attempts", async () => {
+ const connection = createConnection(
+ "https://returnyoutubedislikeapi.com/api/discussion/notifications/hub",
+ async () => "ticket",
+ );
+ const policy = connection._reconnectPolicy;
+ expect(
+ [0, 1, 2, 3, 4].map((previousRetryCount) =>
+ policy.nextRetryDelayInMilliseconds({
+ previousRetryCount,
+ elapsedMilliseconds: 0,
+ retryReason: new Error("disconnected"),
+ }),
+ ),
+ ).toEqual([0, 2000, 10000, 30000, null]);
+ await connection.stop();
+});
diff --git a/Extensions/combined/src/discussion/bridge.spec.js b/Extensions/combined/src/discussion/bridge.spec.js
new file mode 100644
index 0000000..48363cb
--- /dev/null
+++ b/Extensions/combined/src/discussion/bridge.spec.js
@@ -0,0 +1,218 @@
+/**
+ * @jest-environment jsdom
+ * @jest-environment-options {"url":"https://www.youtube.com/watch?v=abcdefghijk"}
+ */
+
+const source = require("fs").readFileSync(require("path").join(__dirname, "../../comments-bridge.js"), "utf8");
+const VIDEO = "abcdefghijk";
+let handler;
+
+beforeEach(() => {
+ document.body.replaceChildren();
+ window.history.replaceState({}, "", `/watch?v=${VIDEO}`);
+ delete window[Symbol.for("return-youtube-dislike.discussion-bridge")];
+ const spy = jest.spyOn(document, "addEventListener");
+ window.eval(source);
+ handler = spy.mock.calls.find(([name]) => name === "ryd-discussion-state-request")[1];
+ spy.mockRestore();
+});
+afterEach(() => {
+ document.removeEventListener("ryd-discussion-state-request", handler, true);
+ delete window.ytInitialPlayerResponse;
+});
+
+function state(root) {
+ root.dispatchEvent(new Event("ryd-discussion-state-request"));
+ return JSON.parse(root.getAttribute("data-ryd-discussion-native-state"));
+}
+function watch() {
+ document.body.innerHTML = ``;
+ const root = document.querySelector("ytd-watch-flexy");
+ root.querySelector("ytd-comments").polymerController = {
+ data: {
+ contents: [
+ {
+ messageRenderer: {
+ text: {
+ runs: [
+ { text: "Комментарии отключены." },
+ {
+ navigationEndpoint: {
+ urlEndpoint: { url: "https://support.google.com/youtube/answer/9706180?hl=ru" },
+ },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ };
+ return root;
+}
+
+function miniplayerNotice(
+ text,
+ helpUrl = "//support.google.com/youtube/bin/answer.py?answer=9632097&nohelpkit=1&hl=en",
+) {
+ return {
+ miniplayer: {
+ miniplayerRenderer: {
+ playbackMode: "PLAYBACK_MODE_PAUSED_ONLY",
+ minimizedEndpoint: {
+ openPopupAction: {
+ popup: {
+ notificationActionRenderer: {
+ responseText: { simpleText: text },
+ actionButton: {
+ buttonRenderer: {
+ command: {
+ urlEndpoint: { url: helpUrl },
+ commandMetadata: { webCommandMetadata: { url: helpUrl } },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+}
+
+const ENGLISH_KIDS_NOTICE = "Miniplayer is off for videos made for kids. Tap play to resume";
+
+test("reads disabled watch evidence from the page-owned controller without text matching", () => {
+ expect(state(watch())).toEqual({ videoId: VIDEO, disabled: true, madeForKids: false });
+});
+
+test("only an explicit kids flag for the current video excludes the discussion", () => {
+ const root = watch();
+ window.ytInitialPlayerResponse = { videoDetails: { videoId: "zyxwvutsrqp", isMadeForKids: true } };
+ expect(state(root).madeForKids).toBe(false);
+ window.ytInitialPlayerResponse = {
+ videoDetails: { videoId: VIDEO },
+ microformat: { playerMicroformatRenderer: { isFamilySafe: true } },
+ };
+ expect(state(root).madeForKids).toBe(false);
+ window.ytInitialPlayerResponse.videoDetails.isMadeForKids = true;
+ expect(state(root).madeForKids).toBe(true);
+});
+
+test("recognizes the current video's explicit miniplayer kids notice", () => {
+ const root = watch();
+ window.ytInitialPlayerResponse = {
+ videoDetails: { videoId: VIDEO },
+ playabilityStatus: miniplayerNotice(ENGLISH_KIDS_NOTICE),
+ };
+ expect(state(root).madeForKids).toBe(true);
+ window.ytInitialPlayerResponse.videoDetails.videoId = "zyxwvutsrqp";
+ expect(state(root).madeForKids).toBe(false);
+});
+
+test.each([
+ ["en", "//support.google.com/youtube/bin/answer.py?answer=9632097&nohelpkit=1&hl=en"],
+ ["ru", "//support.google.com/youtube/bin/answer.py?answer=9632097&nohelpkit=1&hl=ru"],
+ ["ru", "https://support.google.com/youtube/answer/9632097?hl=ru"],
+])("uses the current miniplayer's made-for-kids help link independently of %s wording", (language, helpUrl) => {
+ const root = watch();
+ document.documentElement.lang = language;
+ window.ytInitialPlayerResponse = {
+ videoDetails: { videoId: VIDEO },
+ playabilityStatus: miniplayerNotice("Localized miniplayer notice", helpUrl),
+ };
+ expect(state(root).madeForKids).toBe(true);
+ delete window.ytInitialPlayerResponse.playabilityStatus.miniplayer.miniplayerRenderer.minimizedEndpoint
+ .openPopupAction.popup.notificationActionRenderer.actionButton.buttonRenderer.command.urlEndpoint;
+ expect(state(root).madeForKids).toBe(true);
+ window.ytInitialPlayerResponse.playabilityStatus.miniplayer.miniplayerRenderer.playbackMode = "PLAYBACK_MODE_ALLOW";
+ expect(state(root).madeForKids).toBe(false);
+});
+
+test.each([
+ "https://support.google.com/youtube/answer/9706180",
+ "https://support.google.com.example.test/youtube/answer/9632097",
+ "https://support.google.com/youtube/bin/answer.py?answer=9706180",
+ "",
+])("does not infer a kids restriction from miniplayer text with an unrelated or absent help link: %s", (helpUrl) => {
+ const root = watch();
+ window.ytInitialPlayerResponse = {
+ videoDetails: { videoId: VIDEO },
+ playabilityStatus: miniplayerNotice(ENGLISH_KIDS_NOTICE, helpUrl),
+ };
+ expect(state(root).madeForKids).toBe(false);
+});
+
+test("ignores matching miniplayer text in descriptions, recommendations and advertisements", () => {
+ const root = watch();
+ const kids = { videoDetails: { videoId: "zyxwvutsrqp" }, playabilityStatus: miniplayerNotice(ENGLISH_KIDS_NOTICE) };
+ window.ytInitialPlayerResponse = {
+ videoDetails: { videoId: VIDEO, shortDescription: ENGLISH_KIDS_NOTICE },
+ playabilityStatus: miniplayerNotice(
+ "Miniplayer is unavailable. Please try again.",
+ "https://support.google.com/youtube/answer/9706180",
+ ),
+ endscreen: { videos: [kids] },
+ adPlacements: [kids],
+ };
+ expect(state(root).madeForKids).toBe(false);
+});
+
+test("finds a disabled Shorts comment button through its controller model", () => {
+ window.history.replaceState({}, "", `/shorts/${VIDEO}`);
+ document.body.innerHTML = ``;
+ const root = document.querySelector("ytd-reel-video-renderer");
+ root.querySelector("reel-action-bar-view-model").polymerController = {
+ data: {
+ buttonViewModels: [
+ { buttonViewModel: { iconName: "SHORTS_COMMENT", state: "BUTTON_VIEW_MODEL_STATE_DISABLED" } },
+ ],
+ },
+ };
+ expect(state(root)).toEqual({ videoId: VIDEO, disabled: true, madeForKids: false });
+ root.querySelector("reel-action-bar-view-model").polymerController.data.buttonViewModels[0].buttonViewModel.state =
+ "BUTTON_VIEW_MODEL_STATE_ACTIVE";
+ expect(state(root).disabled).toBe(false);
+});
+
+test.each(["isMadeForKids", "madeForKids", "is_made_for_kids"])(
+ "accepts current response and microformat %s flags but ignores unrelated nested videos",
+ (flag) => {
+ const root = watch();
+ window.ytInitialPlayerResponse = {
+ videoDetails: { videoId: VIDEO },
+ endscreen: { videos: [{ videoId: "zyxwvutsrqp", [flag]: true }] },
+ adPlacements: [{ videoDetails: { videoId: "zyxwvutsrqp", [flag]: true } }],
+ microformat: { playerMicroformatRenderer: { recommendations: [{ [flag]: true }] } },
+ };
+ expect(state(root).madeForKids).toBe(false);
+ window.ytInitialPlayerResponse.microformat.playerMicroformatRenderer[flag] = true;
+ expect(state(root).madeForKids).toBe(true);
+ window.ytInitialPlayerResponse.microformat.playerMicroformatRenderer.videoId = "zyxwvutsrqp";
+ expect(state(root).madeForKids).toBe(false);
+ window.ytInitialPlayerResponse[flag] = true;
+ expect(state(root).madeForKids).toBe(true);
+ },
+);
+
+test("does not expose a result for an outgoing Shorts renderer", () => {
+ window.history.replaceState({}, "", `/shorts/${VIDEO}`);
+ document.body.innerHTML = '';
+ expect(state(document.querySelector("ytd-reel-video-renderer"))).toBeNull();
+});
+
+test("recognizes an unmarked current reel by contained player identity while rejecting retained reels", () => {
+ window.history.replaceState({}, "", `/shorts/${VIDEO}`);
+ document.body.innerHTML =
+ "";
+ document.getElementById("shorts-player").getVideoData = () => ({ video_id: VIDEO });
+ for (const action of document.querySelectorAll("reel-action-bar-view-model"))
+ action.data = {
+ buttonViewModels: [
+ { buttonViewModel: { iconName: "SHORTS_COMMENT", state: "BUTTON_VIEW_MODEL_STATE_DISABLED" } },
+ ],
+ };
+ expect(state(document.getElementById("retained"))).toBeNull();
+ expect(state(document.getElementById("current"))).toEqual({ videoId: VIDEO, disabled: true, madeForKids: false });
+});
diff --git a/Extensions/combined/src/discussion/client.js b/Extensions/combined/src/discussion/client.js
new file mode 100644
index 0000000..428e576
--- /dev/null
+++ b/Extensions/combined/src/discussion/client.js
@@ -0,0 +1,88 @@
+function runtimeApi() {
+ return typeof chrome !== "undefined" ? chrome.runtime : globalThis.browser?.runtime;
+}
+
+export function requestDiscussion(operation, payload = {}, scope) {
+ return new Promise((resolve, reject) => {
+ const runtime = runtimeApi();
+ if (!runtime?.sendMessage) {
+ reject(Object.assign(new Error("Discussion service unavailable"), { code: "unavailable" }));
+ return;
+ }
+ let settled = false;
+ const finish = (response) => {
+ if (settled) return;
+ settled = true;
+ if (runtime.lastError) {
+ reject(Object.assign(new Error(runtime.lastError.message), { code: "unavailable" }));
+ } else if (response?.ok) {
+ resolve(response.data);
+ } else {
+ reject(Object.assign(new Error(response?.error?.message || "Discussion request failed"), response?.error));
+ }
+ };
+ try {
+ const result = runtime.sendMessage(
+ { message: "ryd_discussion", operation, payload, ...(scope ? { scope } : {}) },
+ finish,
+ );
+ if (result?.then)
+ result.then(finish, (error) => {
+ if (!settled) {
+ settled = true;
+ reject(error);
+ }
+ });
+ } catch (error) {
+ reject(error);
+ }
+ });
+}
+
+const VIDEO_OPERATIONS = new Set([
+ "comments",
+ "replies",
+ "comment",
+ "createComment",
+ "editComment",
+ "deleteComment",
+ "react",
+ "report",
+]);
+
+export function createDiscussionRequestScope(videoId, request = requestDiscussion) {
+ const bytes = new Uint8Array(16);
+ globalThis.crypto.getRandomValues(bytes);
+ bytes[6] = (bytes[6] & 15) | 64;
+ bytes[8] = (bytes[8] & 63) | 128;
+ const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
+ const scope = {
+ id: `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`,
+ videoId,
+ };
+ let cancelled = false;
+ const cancelledError = () => Object.assign(new Error("The video changed"), { code: "navigation_changed" });
+ return {
+ request(operation, payload = {}) {
+ if (cancelled) return Promise.reject(cancelledError());
+ return request(operation, payload, VIDEO_OPERATIONS.has(operation) ? scope : undefined).then((data) => {
+ if (cancelled) throw cancelledError();
+ return data;
+ });
+ },
+ cancel() {
+ if (cancelled) return;
+ cancelled = true;
+ request("cancelScope", {}, scope).catch(() => {});
+ },
+ };
+}
+
+export function subscribeDiscussionEvents(listener) {
+ const event = runtimeApi()?.onMessage;
+ const receive = (message) => {
+ if (message?.message === "ryd_discussion_event") listener(message);
+ };
+ event?.addListener(receive);
+ return () => event?.removeListener(receive);
+}
diff --git a/Extensions/combined/src/discussion/client.spec.js b/Extensions/combined/src/discussion/client.spec.js
new file mode 100644
index 0000000..a76e93c
--- /dev/null
+++ b/Extensions/combined/src/discussion/client.spec.js
@@ -0,0 +1,76 @@
+/** @jest-environment jsdom */
+import { createDiscussionRequestScope, requestDiscussion, subscribeDiscussionEvents } from "./client";
+
+afterEach(() => {
+ delete global.chrome;
+});
+
+test("uses a typed worker envelope and returns only the response data", async () => {
+ const sendMessage = jest.fn((message, done) => done({ ok: true, data: { items: [] } }));
+ global.chrome = { runtime: { sendMessage } };
+ await expect(requestDiscussion("comments", { videoId: "abcdefghijk", sort: "top" })).resolves.toEqual({ items: [] });
+ expect(sendMessage.mock.calls[0][0]).toEqual({
+ message: "ryd_discussion",
+ operation: "comments",
+ payload: { videoId: "abcdefghijk", sort: "top" },
+ });
+});
+
+test("preserves safe server error codes and status for translated feedback", async () => {
+ global.chrome = {
+ runtime: {
+ sendMessage: (_, done) => done({ ok: false, error: { code: "nickname_taken", message: "Taken", status: 409 } }),
+ },
+ };
+ await expect(requestDiscussion("profile", { nickname: "Writer" })).rejects.toMatchObject({
+ code: "nickname_taken",
+ status: 409,
+ });
+});
+
+test("subscribes to discussion-only events and removes its listener", () => {
+ let receive;
+ const removeListener = jest.fn();
+ global.chrome = {
+ runtime: {
+ onMessage: {
+ addListener: (listener) => {
+ receive = listener;
+ },
+ removeListener,
+ },
+ },
+ };
+ const callback = jest.fn();
+ const unsubscribe = subscribeDiscussionEvents(callback);
+ receive({ message: "patreon_status_changed" });
+ expect(callback).not.toHaveBeenCalled();
+ const event = { message: "ryd_discussion_event", type: "inbox", data: { items: [] } };
+ receive(event);
+ expect(callback).toHaveBeenCalledWith(event);
+ unsubscribe();
+ expect(removeListener).toHaveBeenCalledWith(receive);
+});
+
+test("a navigation scope sends cancellation and rejects a response that arrives afterwards", async () => {
+ let complete;
+ const sendMessage = jest.fn((message, done) => {
+ if (message.operation === "comments") complete = done;
+ else done({ ok: true, data: { cancelled: true } });
+ });
+ global.chrome = { runtime: { sendMessage } };
+ const scoped = createDiscussionRequestScope("abcdefghijk");
+ const response = scoped.request("comments", { videoId: "abcdefghijk" });
+ const scope = sendMessage.mock.calls[0][0].scope;
+ scoped.cancel();
+ expect(sendMessage.mock.calls[1][0]).toEqual({
+ message: "ryd_discussion",
+ operation: "cancelScope",
+ payload: {},
+ scope,
+ });
+ complete({ ok: true, data: { items: [] } });
+ await expect(response).rejects.toMatchObject({ code: "navigation_changed" });
+ await expect(scoped.request("comments")).rejects.toMatchObject({ code: "navigation_changed" });
+ expect(sendMessage).toHaveBeenCalledTimes(2);
+});
diff --git a/Extensions/combined/src/discussion/detector.js b/Extensions/combined/src/discussion/detector.js
new file mode 100644
index 0000000..efee49d
--- /dev/null
+++ b/Extensions/combined/src/discussion/detector.js
@@ -0,0 +1,87 @@
+export const NATIVE_STATE_ATTRIBUTE = "data-ryd-discussion-native-state";
+export const NATIVE_STATE_REQUEST = "ryd-discussion-state-request";
+
+export function discussionRoute(url = location.href) {
+ try {
+ const parsed = new URL(url);
+ if (parsed.hostname !== "www.youtube.com") return null;
+ const surface = parsed.pathname === "/watch" ? "watch" : parsed.pathname.startsWith("/shorts/") ? "shorts" : null;
+ const videoId = surface === "shorts" ? parsed.pathname.split("/")[2] : parsed.searchParams.get("v");
+ return surface && /^[A-Za-z0-9_-]{11}$/.test(videoId || "") ? { videoId, surface } : null;
+ } catch {
+ return null;
+ }
+}
+
+export function isVisibleSurface(root) {
+ if (!root?.isConnected || root.closest("[hidden], [aria-hidden='true'], [inert]")) return false;
+ for (let element = root; element; element = element.parentElement) {
+ const style = getComputedStyle(element);
+ if (style.display === "none" || style.visibility === "hidden") return false;
+ }
+ return true;
+}
+
+function nativeState(root) {
+ root.removeAttribute(NATIVE_STATE_ATTRIBUTE);
+ root.dispatchEvent(new Event(NATIVE_STATE_REQUEST, { bubbles: false }));
+ let state;
+ try {
+ state = JSON.parse(root.getAttribute(NATIVE_STATE_ATTRIBUTE));
+ } catch {
+ return null;
+ } finally {
+ root.removeAttribute(NATIVE_STATE_ATTRIBUTE);
+ }
+ return state;
+}
+
+function disabledWatchNotice(root) {
+ return [...root.querySelectorAll("ytd-comments ytd-message-renderer a[href]")].some((anchor) => {
+ try {
+ const url = new URL(anchor.href);
+ return (
+ url.hostname === "support.google.com" && url.pathname === "/youtube/answer/9706180" && isVisibleSurface(anchor)
+ );
+ } catch {
+ return false;
+ }
+ });
+}
+
+export function findDiscussionSurface(route = discussionRoute()) {
+ if (!route) return null;
+ const roots = document.querySelectorAll(
+ route.surface === "watch" ? "ytd-watch-flexy, ytd-watch-grid" : "ytd-reel-video-renderer",
+ );
+ for (const root of roots) {
+ if (!isVisibleSurface(root)) continue;
+ if (route.surface === "shorts") {
+ const rect = root.getBoundingClientRect();
+ if (
+ rect.width <= 0 ||
+ rect.height <= 0 ||
+ rect.bottom <= 0 ||
+ rect.right <= 0 ||
+ rect.top >= innerHeight ||
+ rect.left >= innerWidth
+ )
+ continue;
+ }
+ if (route.surface === "watch" && root.getAttribute("video-id") !== route.videoId) continue;
+ const state = nativeState(root);
+ if (
+ state?.madeForKids === true ||
+ ["is-made-for-kids", "made-for-kids"].some((key) => root.hasAttribute(key) && root.getAttribute(key) !== "false")
+ )
+ continue;
+ const disabled = state?.videoId === route.videoId && state.disabled === true;
+ if (!disabled && !(route.surface === "watch" && disabledWatchNotice(root))) continue;
+ const host =
+ route.surface === "watch"
+ ? root.querySelector("ytd-comments")
+ : root.querySelector("reel-action-bar-view-model, #actions");
+ if (host) return { ...route, root, host };
+ }
+ return null;
+}
diff --git a/Extensions/combined/src/discussion/detector.spec.js b/Extensions/combined/src/discussion/detector.spec.js
new file mode 100644
index 0000000..50d3244
--- /dev/null
+++ b/Extensions/combined/src/discussion/detector.spec.js
@@ -0,0 +1,97 @@
+/** @jest-environment jsdom */
+
+import { discussionRoute, findDiscussionSurface, NATIVE_STATE_ATTRIBUTE, NATIVE_STATE_REQUEST } from "./detector";
+
+const VIDEO = "abcdefghijk";
+let listener;
+
+beforeEach(() => {
+ document.body.replaceChildren();
+ document.documentElement.lang = "en";
+});
+afterEach(() => {
+ if (listener) document.removeEventListener(NATIVE_STATE_REQUEST, listener, true);
+ listener = null;
+});
+
+function watch(videoId = VIDEO) {
+ const root = document.createElement("ytd-watch-flexy");
+ root.setAttribute("video-id", videoId);
+ root.innerHTML = "";
+ document.body.append(root);
+ return root;
+}
+
+function respond(state) {
+ listener = (event) => event.target.setAttribute(NATIVE_STATE_ATTRIBUTE, JSON.stringify(state));
+ document.addEventListener(NATIVE_STATE_REQUEST, listener, true);
+}
+
+test("only supports canonical desktop watch and Shorts routes with a valid identity", () => {
+ expect(discussionRoute(`https://www.youtube.com/watch?v=${VIDEO}`)).toEqual({ videoId: VIDEO, surface: "watch" });
+ expect(discussionRoute(`https://www.youtube.com/shorts/${VIDEO}`)).toEqual({ videoId: VIDEO, surface: "shorts" });
+ for (const url of [
+ "https://www.youtube.com/",
+ "https://www.youtube.com/watch?v=oops",
+ `https://m.youtube.com/watch?v=${VIDEO}`,
+ `https://www.youtube.com.evil.test/watch?v=${VIDEO}`,
+ ])
+ expect(discussionRoute(url)).toBeNull();
+});
+
+test.each(["Comments are turned off.", "Комментарии отключены."])(
+ "recognizes the native disabled help endpoint independent of text: %s",
+ (text) => {
+ const root = watch();
+ root.querySelector("ytd-comments").innerHTML =
+ `${text}Подробнее`;
+ expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })?.root).toBe(root);
+ },
+);
+
+test("missing comments and generic messages never count as disabled", () => {
+ const root = watch();
+ root.querySelector("ytd-comments").innerHTML = "Loading comments";
+ expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })).toBeNull();
+});
+
+test("accepts positive native disabled evidence when kids status is unknown", () => {
+ const root = watch();
+ respond({ videoId: VIDEO, disabled: true });
+ expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })?.host).toBe(root.querySelector("ytd-comments"));
+ expect(root.hasAttribute(NATIVE_STATE_ATTRIBUTE)).toBe(false);
+});
+
+test("positive made-for-kids proof overrides disabled proof and help link", () => {
+ watch();
+ respond({ videoId: VIDEO, disabled: true, madeForKids: true });
+ expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })).toBeNull();
+});
+
+test("ignores stale hidden roots and stale bridge identity", () => {
+ const hidden = watch();
+ hidden.hidden = true;
+ watch("zyxwvutsrqp");
+ respond({ videoId: VIDEO, disabled: true });
+ expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })).toBeNull();
+ hidden.hidden = false;
+ document.removeEventListener(NATIVE_STATE_REQUEST, listener, true);
+ respond({ videoId: "zyxwvutsrqp", disabled: true });
+ expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })).toBeNull();
+});
+
+test("Shorts requires a current native disabled state and action surface", () => {
+ document.body.innerHTML =
+ "";
+ document.querySelector("ytd-reel-video-renderer").getBoundingClientRect = () => ({
+ width: 400,
+ height: 700,
+ top: 0,
+ left: 0,
+ bottom: 700,
+ right: 400,
+ });
+ expect(findDiscussionSurface({ videoId: VIDEO, surface: "shorts" })).toBeNull();
+ respond({ videoId: VIDEO, disabled: true, madeForKids: false });
+ expect(findDiscussionSurface({ videoId: VIDEO, surface: "shorts" })?.host.tagName).toBe("REEL-ACTION-BAR-VIEW-MODEL");
+});
diff --git a/Extensions/combined/src/discussion/dom.js b/Extensions/combined/src/discussion/dom.js
new file mode 100644
index 0000000..9eb7037
--- /dev/null
+++ b/Extensions/combined/src/discussion/dom.js
@@ -0,0 +1,72 @@
+export function element(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text != null) node.textContent = text;
+ return node;
+}
+
+export function button(text, onClick, className = "ryd-discussion-button") {
+ const node = element("button", className, text);
+ node.type = "button";
+ if (onClick) node.addEventListener("click", onClick);
+ return node;
+}
+
+export function statusNode() {
+ const node = element("p", "ryd-discussion-status");
+ node.setAttribute("role", "status");
+ node.setAttribute("aria-live", "polite");
+ return node;
+}
+
+export function isDiscussionCommentId(value) {
+ return /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value || "");
+}
+
+export function discussionLinkTarget(value = location.href) {
+ try {
+ const url = new URL(value);
+ if (url.protocol !== "https:" || url.hostname !== "www.youtube.com") return null;
+ const videoId =
+ url.pathname === "/watch"
+ ? url.searchParams.get("v")
+ : /^\/shorts\/([A-Za-z0-9_-]{11})\/?$/.exec(url.pathname)?.[1];
+ const commentId = new URLSearchParams(url.hash.slice(1)).get("rydComment") || url.searchParams.get("rydComment");
+ return /^[A-Za-z0-9_-]{11}$/.test(videoId || "") && isDiscussionCommentId(commentId)
+ ? { videoId, commentId }
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+export function threadUrl(notification) {
+ if (!/^[A-Za-z0-9_-]{11}$/.test(notification?.videoId || "") || !isDiscussionCommentId(notification?.commentId))
+ return null;
+ const url = new URL("https://www.youtube.com/watch");
+ url.searchParams.set("v", notification.videoId);
+ url.hash = new URLSearchParams({ rydComment: notification.commentId }).toString();
+ return url.href;
+}
+
+export function shortDate(value, language) {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime())
+ ? ""
+ : date.toLocaleDateString(language, { month: "short", day: "numeric", year: "numeric" });
+}
+
+export function thumbIcon(dislike = false) {
+ const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ icon.setAttribute("viewBox", "0 0 24 24");
+ icon.setAttribute("aria-hidden", "true");
+ icon.classList.add("ryd-discussion-thumb");
+ if (dislike) icon.classList.add("is-down");
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute(
+ "d",
+ "M7 10v12H2V10h5Zm2 12V10l5-8 1.1.4c.9.4 1.4 1.4 1.2 2.4L15.5 9H20a2 2 0 0 1 2 2v2l-3 9H9ZM3.5 11.5v9H5.5v-9h-2Zm7 9h7.4l2.6-7.7V11a.5.5 0 0 0-.5-.5h-6.3l1.1-6a.8.8 0 0 0-.2-.6L10.5 10.4v10.1Z",
+ );
+ icon.append(path);
+ return icon;
+}
diff --git a/Extensions/combined/src/discussion/dom.spec.js b/Extensions/combined/src/discussion/dom.spec.js
new file mode 100644
index 0000000..93bde4e
--- /dev/null
+++ b/Extensions/combined/src/discussion/dom.spec.js
@@ -0,0 +1,37 @@
+/** @jest-environment jsdom */
+
+import { discussionLinkTarget, isDiscussionCommentId, threadUrl } from "./dom";
+
+const videoId = "abcdefghijk";
+const commentId = "11111111-1111-4111-8111-111111111111";
+
+test("notification links carry a validated fragment through a native video URL", () => {
+ const url = threadUrl({ videoId, commentId });
+ expect(new URL(url).searchParams.has("rydComment")).toBe(false);
+ expect(discussionLinkTarget(url)).toEqual({ videoId, commentId });
+ expect(discussionLinkTarget(`https://www.youtube.com/shorts/${videoId}#rydComment=${commentId}`)).toEqual({
+ videoId,
+ commentId,
+ });
+ expect(discussionLinkTarget(`https://www.youtube.com/watch?v=${videoId}&rydComment=${commentId}`)).toEqual({
+ videoId,
+ commentId,
+ });
+});
+
+test.each([
+ `https://example.com/watch?v=${videoId}#rydComment=${commentId}`,
+ `http://www.youtube.com/watch?v=${videoId}#rydComment=${commentId}`,
+ `https://www.youtube.com/feed?v=${videoId}#rydComment=${commentId}`,
+ `https://www.youtube.com/watch?v=short#rydComment=${commentId}`,
+ `https://www.youtube.com/watch?v=${videoId}#rydComment=${"-".repeat(36)}`,
+ "javascript:alert(1)",
+])("rejects an invalid discussion destination: %s", (url) => {
+ expect(discussionLinkTarget(url)).toBeNull();
+});
+
+test("UUID validation rejects same-length malformed identifiers", () => {
+ expect(isDiscussionCommentId(commentId)).toBe(true);
+ expect(isDiscussionCommentId("1".repeat(36))).toBe(false);
+ expect(threadUrl({ videoId, commentId: "-".repeat(36) })).toBeNull();
+});
diff --git a/Extensions/combined/src/discussion/inbox.js b/Extensions/combined/src/discussion/inbox.js
new file mode 100644
index 0000000..563ca01
--- /dev/null
+++ b/Extensions/combined/src/discussion/inbox.js
@@ -0,0 +1,174 @@
+import { requestDiscussion } from "./client";
+import { discussionText as t, discussionError, discussionLanguage } from "./localize";
+import { element, button, statusNode, threadUrl, shortDate } from "./dom";
+
+export function notificationText(notification) {
+ return notification.type === "reply"
+ ? t("notificationReply", [notification.actorNickname || t("someone")])
+ : notification.likeCount > 1
+ ? t("notificationLikes", [notification.likeCount])
+ : t("notificationLike");
+}
+
+export function createDiscussionInbox({ request = requestDiscussion, authenticated = true } = {}) {
+ const node = element("section", "ryd-discussion-inbox");
+ const heading = element("div", "ryd-discussion-toolbar");
+ heading.append(element("h3", "ryd-discussion-subtitle", t("notifications")));
+ const readAll = button(t("markAllRead"), async () => {
+ readAll.disabled = true;
+ try {
+ await request("readNotifications", { all: true });
+ await load();
+ } catch (error) {
+ status.textContent = discussionError(error);
+ } finally {
+ readAll.disabled = false;
+ }
+ });
+ heading.append(readAll);
+ const list = element("div", "ryd-discussion-inbox-list");
+ const status = statusNode();
+ const more = button(t("more"), () => load(true));
+ more.hidden = true;
+ node.append(heading, status, list, more);
+ let items = [];
+ let cursor = null;
+ let disposed = false;
+ let generation = 0;
+ let hasPage = false;
+ let accountId = null;
+
+ function render() {
+ list.replaceChildren();
+ readAll.hidden = !authenticated || !items.some((item) => !item.read);
+ if (!authenticated) {
+ status.textContent = t("signedOutInbox");
+ more.hidden = true;
+ return;
+ }
+ status.textContent = items.length ? "" : t("notificationEmpty");
+ for (const item of items) {
+ const row = element("article", `ryd-discussion-notification${item.read ? "" : " is-unread"}`);
+ row.dataset.notificationId = item.id;
+ const link = element("a", "ryd-discussion-notification-link", notificationText(item));
+ const url = threadUrl(item);
+ if (url) {
+ link.href = url;
+ link.target = "_blank";
+ link.rel = "noopener noreferrer";
+ link.addEventListener("click", () => {
+ request("readNotifications", { ids: [item.id] })
+ .then(() => {
+ item.read = true;
+ render();
+ })
+ .catch(() => {});
+ });
+ }
+ const date = element("time", "ryd-discussion-meta", shortDate(item.createdAt, discussionLanguage()));
+ date.dateTime = item.createdAt;
+ row.append(link, date);
+ if (!item.read)
+ row.append(
+ button(t("markRead"), async (event) => {
+ event.currentTarget.disabled = true;
+ try {
+ await request("readNotifications", { ids: [item.id] });
+ item.read = true;
+ render();
+ } catch (error) {
+ status.textContent = discussionError(error);
+ event.target.disabled = false;
+ }
+ }),
+ );
+ list.append(row);
+ }
+ more.hidden = !cursor;
+ }
+
+ async function load(append = false) {
+ if (disposed || !authenticated) {
+ render();
+ return;
+ }
+ const current = ++generation;
+ status.textContent = t("loading");
+ more.disabled = true;
+ try {
+ const result = await request("notifications", append && cursor ? { cursor } : {});
+ if (disposed || current !== generation) return;
+ const combined = append ? [...items, ...(result.items || [])] : result.items || [];
+ items = [...new Map(combined.map((item) => [item.id, item])).values()];
+ cursor = result.nextCursor;
+ hasPage = true;
+ render();
+ } catch (error) {
+ if (!disposed && current === generation) status.textContent = discussionError(error);
+ } finally {
+ more.disabled = false;
+ }
+ }
+
+ return {
+ node,
+ load,
+ setSession(session) {
+ authenticated = session?.authenticated === true;
+ const nextId = authenticated ? session.profile?.id || null : null;
+ generation++;
+ if (!authenticated || nextId !== accountId) {
+ items = [];
+ cursor = null;
+ hasPage = false;
+ }
+ accountId = nextId;
+ render();
+ },
+ update(inbox, { live = false } = {}) {
+ if (disposed || !authenticated) return;
+ generation++;
+ if (live) {
+ items = [...new Map([...items, ...(inbox?.items || [])].map((item) => [item.id, item])).values()].sort((a, b) =>
+ String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt)),
+ );
+ } else {
+ items = inbox?.items || [];
+ }
+ if (!live || !hasPage) cursor = inbox?.nextCursor;
+ hasPage = true;
+ render();
+ },
+ destroy() {
+ disposed = true;
+ generation++;
+ node.remove();
+ },
+ };
+}
+
+export function showDiscussionToast(notification) {
+ document.querySelector(".ryd-discussion-toast")?.remove();
+ const toast = element("aside", "ryd-discussion-toast");
+ toast.setAttribute("role", "status");
+ toast.setAttribute("aria-live", "polite");
+ const copy = element("div");
+ copy.append(element("strong", "", t("title")), element("p", "", notificationText(notification)));
+ const url = threadUrl(notification);
+ if (url) {
+ const link = element("a", "ryd-discussion-button", t("view"));
+ link.href = url;
+ link.dataset.notificationId = notification.id;
+ link.addEventListener("click", () =>
+ requestDiscussion("readNotifications", { ids: [notification.id] }).catch(() => {}),
+ );
+ copy.append(link);
+ }
+ toast.append(
+ copy,
+ button("×", () => toast.remove(), "ryd-discussion-close"),
+ );
+ toast.lastChild.setAttribute("aria-label", t("close"));
+ document.body.append(toast);
+ setTimeout(() => toast.remove(), 10000);
+}
diff --git a/Extensions/combined/src/discussion/inbox.spec.js b/Extensions/combined/src/discussion/inbox.spec.js
new file mode 100644
index 0000000..7f77c6f
--- /dev/null
+++ b/Extensions/combined/src/discussion/inbox.spec.js
@@ -0,0 +1,101 @@
+/** @jest-environment jsdom */
+
+import { createDiscussionInbox, notificationText } from "./inbox";
+import { threadUrl } from "./dom";
+
+const ID = "11111111-1111-4111-8111-111111111111";
+const notification = {
+ id: ID,
+ videoId: "abcdefghijk",
+ commentId: ID,
+ type: "reply",
+ actorNickname: "Reader",
+ createdAt: "2026-09-01T00:00:00Z",
+ read: false,
+};
+let inbox;
+beforeEach(() => {
+ document.body.replaceChildren();
+ document.documentElement.lang = "en";
+});
+afterEach(() => inbox?.destroy());
+
+test("builds a canonical discussion link rather than accepting an arbitrary destination", () => {
+ expect(threadUrl(notification)).toBe(`https://www.youtube.com/watch?v=abcdefghijk#rydComment=${ID}`);
+ expect(threadUrl({ ...notification, videoId: "javascript:alert(1)" })).toBeNull();
+});
+
+test("renders nicknames as text and identifies each notification for inbox actions", async () => {
+ const request = jest
+ .fn()
+ .mockResolvedValue({ items: [{ ...notification, actorNickname: "
" }], nextCursor: null });
+ inbox = createDiscussionInbox({ request });
+ document.body.append(inbox.node);
+ await inbox.load();
+ expect(inbox.node.querySelector("img")).toBeNull();
+ expect(inbox.node.querySelector("[data-notification-id]").dataset.notificationId).toBe(ID);
+ expect(inbox.node.textContent).toContain("
");
+});
+
+test("logout prevents an older inbox response from exposing the previous account's rows", async () => {
+ let resolve;
+ const request = jest.fn(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ inbox = createDiscussionInbox({ request });
+ document.body.append(inbox.node);
+ const pending = inbox.load();
+ inbox.setSession({ authenticated: false });
+ resolve({ items: [notification] });
+ await pending;
+ expect(inbox.node.querySelector("[data-notification-id]")).toBeNull();
+});
+
+test("a newer shared inbox broadcast cannot be overwritten by an older pending read", async () => {
+ let resolve;
+ const request = jest.fn(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ inbox = createDiscussionInbox({ request });
+ document.body.append(inbox.node);
+ const pending = inbox.load();
+ inbox.update({ items: [notification], unreadCount: 1 });
+ resolve({ items: [], unreadCount: 0 });
+ await pending;
+ expect(inbox.node.querySelector("[data-notification-id]")).not.toBeNull();
+});
+
+test("anonymous likes notifications do not expose a reaction identity", () => {
+ document.documentElement.lang = "ru";
+ expect(notificationText({ type: "likes", actorNickname: null })).toBe("Кому-то понравился ваш комментарий");
+});
+
+test("a live update preserves loaded history and the remaining pagination cursor", async () => {
+ const older = { ...notification, id: "22222222-2222-4222-8222-222222222222", createdAt: "2026-08-31T00:00:00Z" };
+ const newest = { ...notification, id: "33333333-3333-4333-8333-333333333333", createdAt: "2026-09-02T00:00:00Z" };
+ const request = jest.fn().mockResolvedValue({ items: [older], nextCursor: "oldest-page" });
+ inbox = createDiscussionInbox({ request });
+ inbox.update({ items: [notification], nextCursor: "older-page" });
+ await inbox.load(true);
+ inbox.update({ items: [newest, notification], nextCursor: "older-page" }, { live: true });
+ expect([...inbox.node.querySelectorAll("[data-notification-id]")].map((node) => node.dataset.notificationId)).toEqual(
+ [newest.id, notification.id, older.id],
+ );
+ await inbox.load(true);
+ expect(request).toHaveBeenLastCalledWith("notifications", { cursor: "oldest-page" });
+});
+
+test("an account replacement clears old rows and pagination even without an intervening anonymous event", () => {
+ inbox = createDiscussionInbox();
+ inbox.setSession({ authenticated: true, profile: { id: "first-account" } });
+ inbox.update({ items: [notification], nextCursor: "older-page" });
+ inbox.setSession({ authenticated: true, profile: { id: "second-account" } });
+ expect(inbox.node.querySelector("[data-notification-id]")).toBeNull();
+ expect(inbox.node.lastChild.hidden).toBe(true);
+});
diff --git a/Extensions/combined/src/discussion/index.js b/Extensions/combined/src/discussion/index.js
new file mode 100644
index 0000000..0827488
--- /dev/null
+++ b/Extensions/combined/src/discussion/index.js
@@ -0,0 +1,284 @@
+import { createDiscussionRequestScope, requestDiscussion, subscribeDiscussionEvents } from "./client";
+import { discussionRoute, findDiscussionSurface } from "./detector";
+import { discussionLanguage, discussionText as t } from "./localize";
+import { createDiscussionPanel } from "./panel";
+import { showDiscussionToast } from "./inbox";
+import { button, element, discussionLinkTarget, isDiscussionCommentId } from "./dom";
+
+let installed = null;
+
+export function initDiscussion({ request = requestDiscussion } = {}) {
+ if (installed) return installed;
+ let panel = null;
+ let panelScope = null;
+ let surface = null;
+ let launcher = null;
+ let backdrop = null;
+ let drawerOpenVideoId = null;
+ let focusedCommentKey = null;
+ let session = { authenticated: false, profile: null, canPost: false, isModerator: false };
+ let capabilities = {};
+ let inbox = null;
+ let disposed = false;
+ let navigationPending = false;
+ let navigationOrigin = null;
+ let language = discussionLanguage();
+ let scheduled = null;
+ let sessionGeneration = 0;
+
+ const bridge = document.createElement("script");
+ bridge.src = chrome.runtime.getURL("comments-bridge.js");
+ bridge.onload = () => {
+ bridge.remove();
+ refresh();
+ };
+ bridge.onerror = () => {
+ bridge.remove();
+ refresh();
+ };
+ (document.head || document.documentElement).append(bridge);
+
+ function clear() {
+ focusedCommentKey = null;
+ panelScope?.cancel();
+ panelScope = null;
+ panel?.destroy();
+ panel = null;
+ launcher?.remove();
+ launcher = null;
+ backdrop?.remove();
+ backdrop = null;
+ surface = null;
+ }
+
+ function closeDrawer() {
+ drawerOpenVideoId = null;
+ if (!panel || surface?.surface !== "shorts") return;
+ panel.root.hidden = true;
+ if (backdrop) backdrop.hidden = true;
+ launcher?.setAttribute("aria-expanded", "false");
+ launcher?.focus();
+ }
+
+ function openDrawer() {
+ if (!panel || surface?.surface !== "shorts") return;
+ drawerOpenVideoId = surface.videoId;
+ panel.root.hidden = false;
+ backdrop.hidden = false;
+ launcher.setAttribute("aria-expanded", "true");
+ panel.root.focus();
+ }
+
+ async function focusLinkedComment(target, force = false) {
+ if (!target || !panel || surface?.videoId !== target.videoId || discussionRoute()?.videoId !== target.videoId)
+ return;
+ const key = `${target.videoId}:${target.commentId}`;
+ if (!force && focusedCommentKey === key) return;
+ focusedCommentKey = key;
+ if (surface.surface === "shorts") openDrawer();
+ await panel.focusComment(target.commentId);
+ }
+
+ function notificationLinkClick(event) {
+ if (
+ event.defaultPrevented ||
+ event.button !== 0 ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.shiftKey ||
+ event.altKey
+ )
+ return;
+ const anchor = event.target?.closest?.(".ryd-discussion-notification-link, .ryd-discussion-toast a[href]");
+ if (!anchor || anchor.hasAttribute("download") || (anchor.target && anchor.target !== "_self")) return;
+ const target = discussionLinkTarget(anchor.href);
+ if (!target || !panel || surface?.videoId !== target.videoId || discussionRoute()?.videoId !== target.videoId)
+ return;
+ event.preventDefault();
+ event.stopPropagation();
+ const url = new URL(location.href);
+ url.searchParams.delete("rydComment");
+ url.hash = new URLSearchParams({ rydComment: target.commentId }).toString();
+ history.replaceState(history.state, "", url.href);
+ const notificationId = anchor.closest("[data-notification-id]")?.dataset.notificationId;
+ if (isDiscussionCommentId(notificationId)) request("readNotifications", { ids: [notificationId] }).catch(() => {});
+ focusLinkedComment(target, true);
+ }
+
+ async function refresh() {
+ if (disposed) return;
+ const route = discussionRoute();
+ const linkedTarget = discussionLinkTarget();
+ if (route?.surface !== "shorts" || route.videoId !== drawerOpenVideoId) drawerOpenVideoId = null;
+ if (navigationPending && route?.videoId === navigationOrigin) return;
+ if (navigationPending && route?.videoId !== navigationOrigin) navigationPending = false;
+ const nextLanguage = discussionLanguage();
+ if (nextLanguage !== language) {
+ language = nextLanguage;
+ clear();
+ }
+ const next = capabilities.enabled === true ? findDiscussionSurface(route) : null;
+ if (!next) {
+ clear();
+ return;
+ }
+ if (
+ surface?.videoId === next.videoId &&
+ surface?.root === next.root &&
+ surface?.host === next.host &&
+ panel?.root.isConnected &&
+ (!launcher || launcher.isConnected)
+ ) {
+ await focusLinkedComment(linkedTarget);
+ return;
+ }
+ clear();
+ surface = next;
+ panelScope = createDiscussionRequestScope(next.videoId, request);
+ panel = createDiscussionPanel({
+ videoId: next.videoId,
+ surface: next.surface,
+ session,
+ capabilities,
+ request: panelScope.request,
+ ...(next.surface === "shorts" ? { onClose: closeDrawer } : {}),
+ });
+ if (inbox) panel.setInbox(inbox);
+ if (next.surface === "watch") {
+ next.host.insertAdjacentElement("afterend", panel.root);
+ } else {
+ launcher = button("RYD", openDrawer, "ryd-discussion-launcher");
+ launcher.id = "ryd-discussion-launcher";
+ launcher.dataset.videoId = next.videoId;
+ launcher.setAttribute("aria-label", t("open"));
+ launcher.setAttribute("title", t("open"));
+ launcher.setAttribute("aria-haspopup", "dialog");
+ launcher.setAttribute("aria-expanded", "false");
+ next.host.insertAdjacentElement("afterend", launcher);
+ backdrop = element("div", "ryd-discussion-backdrop");
+ backdrop.hidden = true;
+ backdrop.addEventListener("click", closeDrawer);
+ panel.root.hidden = true;
+ document.body.append(backdrop, panel.root);
+ if (drawerOpenVideoId === next.videoId) openDrawer();
+ }
+ const currentPanel = panel;
+ await currentPanel.load();
+ if (currentPanel !== panel || disposed) return;
+ await focusLinkedComment(discussionLinkTarget() || linkedTarget);
+ }
+
+ function schedule() {
+ if (disposed || scheduled) return;
+ scheduled = setTimeout(() => {
+ scheduled = null;
+ refresh();
+ }, 150);
+ }
+ function navigationStart() {
+ drawerOpenVideoId = null;
+ navigationPending = true;
+ navigationOrigin = discussionRoute()?.videoId;
+ clear();
+ }
+ function navigationFinish() {
+ navigationPending = false;
+ refresh();
+ }
+ function keyboard(event) {
+ if (!panel || surface?.surface !== "shorts" || panel.root.hidden) return;
+ if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ closeDrawer();
+ }
+ if (event.key === "Tab") {
+ const focusable = [...panel.root.querySelectorAll("button, input, textarea, select, a[href]")].filter(
+ (node) => !node.disabled && !node.closest("[hidden]"),
+ );
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.root)) {
+ event.preventDefault();
+ last?.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first?.focus();
+ }
+ }
+ }
+ const observer = new MutationObserver(schedule);
+ observer.observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["lang", "video-id", "is-active", "hidden"],
+ });
+ document.addEventListener("yt-navigate-start", navigationStart);
+ document.addEventListener("yt-navigate-finish", navigationFinish);
+ window.addEventListener("pagehide", navigationStart);
+ window.addEventListener("pageshow", navigationFinish);
+ window.addEventListener("hashchange", refresh);
+ window.addEventListener("popstate", refresh);
+ document.addEventListener("click", notificationLinkClick, true);
+ document.addEventListener("keydown", keyboard, true);
+ const timer = setInterval(refresh, 1000);
+ const unsubscribe = subscribeDiscussionEvents((event) => {
+ if (disposed) return;
+ if (event.type === "session") {
+ sessionGeneration++;
+ session = event.data;
+ panel?.setSession(session);
+ if (!session?.authenticated) {
+ inbox = null;
+ document.querySelector(".ryd-discussion-toast")?.remove();
+ }
+ }
+ if (event.type === "inbox") {
+ inbox = event.data;
+ panel?.setInbox(inbox);
+ }
+ if (event.type === "toast" && session.authenticated) showDiscussionToast(event.data);
+ });
+ const initialSession = sessionGeneration;
+ request("session")
+ .then((value) => {
+ if (!disposed && initialSession === sessionGeneration) {
+ session = value;
+ panel?.setSession(session);
+ }
+ })
+ .catch(() => {});
+ request("capabilities")
+ .then((value) => {
+ if (!disposed) {
+ capabilities = value || {};
+ refresh();
+ }
+ })
+ .catch(() => {});
+ refresh();
+ installed = {
+ refresh,
+ destroy() {
+ disposed = true;
+ drawerOpenVideoId = null;
+ clear();
+ clearInterval(timer);
+ clearTimeout(scheduled);
+ observer.disconnect();
+ unsubscribe();
+ bridge.remove();
+ document.removeEventListener("yt-navigate-start", navigationStart);
+ document.removeEventListener("yt-navigate-finish", navigationFinish);
+ window.removeEventListener("pagehide", navigationStart);
+ window.removeEventListener("pageshow", navigationFinish);
+ window.removeEventListener("hashchange", refresh);
+ window.removeEventListener("popstate", refresh);
+ document.removeEventListener("click", notificationLinkClick, true);
+ document.removeEventListener("keydown", keyboard, true);
+ installed = null;
+ },
+ };
+ return installed;
+}
diff --git a/Extensions/combined/src/discussion/index.spec.js b/Extensions/combined/src/discussion/index.spec.js
new file mode 100644
index 0000000..f84b21e
--- /dev/null
+++ b/Extensions/combined/src/discussion/index.spec.js
@@ -0,0 +1,349 @@
+/**
+ * @jest-environment jsdom
+ * @jest-environment-options {"url":"https://www.youtube.com/watch?v=abcdefghijk"}
+ */
+
+import { initDiscussion } from "./index";
+import { NATIVE_STATE_REQUEST, NATIVE_STATE_ATTRIBUTE } from "./detector";
+import { threadUrl } from "./dom";
+
+const VIDEO = "abcdefghijk";
+const OTHER = "zyxwvutsrqp";
+const ROOT_COMMENT = "11111111-1111-4111-8111-111111111111";
+const REPLY_COMMENT = "22222222-2222-4222-8222-222222222222";
+const flush = async () => {
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+};
+let feature;
+let listener;
+let eventListener;
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ document.body.innerHTML = ``;
+ window.history.replaceState({}, "", `/watch?v=${VIDEO}`);
+ document.documentElement.lang = "en";
+ global.chrome = {
+ runtime: {
+ getURL: (path) => `chrome-extension://test/${path}`,
+ onMessage: {
+ addListener: (value) => {
+ eventListener = value;
+ },
+ removeListener: jest.fn(),
+ },
+ },
+ };
+ listener = (event) =>
+ event.target.setAttribute(
+ NATIVE_STATE_ATTRIBUTE,
+ JSON.stringify({ videoId: event.target.getAttribute("video-id"), disabled: true, madeForKids: false }),
+ );
+ document.addEventListener(NATIVE_STATE_REQUEST, listener, true);
+});
+afterEach(() => {
+ feature?.destroy();
+ document.removeEventListener(NATIVE_STATE_REQUEST, listener, true);
+ jest.useRealTimers();
+ delete global.chrome;
+});
+
+function request(operation) {
+ if (operation === "session") return Promise.resolve({ authenticated: false, canPost: false, profile: null });
+ if (operation === "capabilities") return Promise.resolve({ enabled: true });
+ return Promise.resolve({ items: [], nextCursor: null });
+}
+
+function shortsFixture(videoId = VIDEO) {
+ window.history.replaceState({}, "", `/shorts/${videoId}`);
+ document.body.innerHTML = ``;
+ const reel = document.querySelector("ytd-reel-video-renderer");
+ reel.getBoundingClientRect = () => ({ width: 400, height: 600, top: 0, left: 0, right: 400, bottom: 600 });
+ return reel;
+}
+
+function linkRequest(operation, payload) {
+ if (operation === "session")
+ return Promise.resolve({ authenticated: true, canPost: false, profile: { id: "reader" } });
+ if (operation === "comment")
+ return Promise.resolve({
+ id: payload.id,
+ videoId: VIDEO,
+ parentId: payload.id === REPLY_COMMENT ? ROOT_COMMENT : null,
+ replyToId: payload.id === REPLY_COMMENT ? ROOT_COMMENT : null,
+ author: { id: "author", nickname: "Writer" },
+ text: payload.id === REPLY_COMMENT ? "Linked reply" : "Root comment",
+ createdAt: "2026-09-01T00:00:00Z",
+ replyCount: 1,
+ likes: 0,
+ dislikes: 0,
+ });
+ return request(operation);
+}
+
+function notificationAnchor({ target = "", videoId = VIDEO } = {}) {
+ const toast = document.createElement("aside");
+ toast.className = "ryd-discussion-toast";
+ const anchor = document.createElement("a");
+ anchor.href = threadUrl({ videoId, commentId: REPLY_COMMENT });
+ anchor.target = target;
+ anchor.dataset.notificationId = ROOT_COMMENT;
+ toast.append(anchor);
+ document.body.append(toast);
+ return anchor;
+}
+
+test("repeated native observer checks mount exactly one panel and fetch once", async () => {
+ const call = jest.fn(request);
+ feature = initDiscussion({ request: call });
+ await flush();
+ jest.advanceTimersByTime(4000);
+ await flush();
+ expect(document.querySelectorAll("#ryd-discussion-panel")).toHaveLength(1);
+ expect(call.mock.calls.filter(([operation]) => operation === "comments")).toHaveLength(1);
+});
+
+test.each(["pending", "unavailable", "disabled"])(
+ "%s capabilities leave the native comments page untouched",
+ async (state) => {
+ const native = document.body.innerHTML;
+ const call = jest.fn((operation) => {
+ if (operation !== "capabilities") return request(operation);
+ if (state === "pending") return new Promise(() => {});
+ if (state === "unavailable") return Promise.reject(Object.assign(new Error("Not found"), { status: 404 }));
+ return Promise.resolve({ enabled: false });
+ });
+ feature = initDiscussion({ request: call });
+ await flush();
+ jest.advanceTimersByTime(4000);
+ await flush();
+ expect(document.body.innerHTML).toBe(native);
+ expect(call.mock.calls.some(([operation]) => operation === "comments")).toBe(false);
+ },
+);
+
+test("an enabled service still shows subsequent comment request failures", async () => {
+ feature = initDiscussion({
+ request: (operation) =>
+ operation === "comments"
+ ? Promise.reject(Object.assign(new Error("Unavailable"), { code: "network_error" }))
+ : request(operation),
+ });
+ await flush();
+ expect(document.getElementById("ryd-discussion-panel")).not.toBeNull();
+ expect(document.querySelector(".ryd-discussion-thread > .ryd-discussion-status").textContent).toBe(
+ "Unable to connect. Please try again.",
+ );
+});
+
+test("navigation removes outgoing content immediately and ignores its delayed response", async () => {
+ let resolve;
+ const call = jest.fn((operation) =>
+ operation === "comments"
+ ? new Promise((done) => {
+ resolve = done;
+ })
+ : request(operation),
+ );
+ feature = initDiscussion({ request: call });
+ await flush();
+ document.dispatchEvent(new Event("yt-navigate-start"));
+ expect(document.getElementById("ryd-discussion-panel")).toBeNull();
+ const commentsScope = call.mock.calls.find(([operation]) => operation === "comments")[2];
+ expect(commentsScope.videoId).toBe(VIDEO);
+ expect(call).toHaveBeenCalledWith("cancelScope", {}, commentsScope);
+ resolve({ items: [{ id: "late", text: "Old private draft" }] });
+ await flush();
+ window.history.replaceState({}, "", `/watch?v=${OTHER}`);
+ document.querySelector("ytd-watch-flexy").setAttribute("video-id", OTHER);
+ call.mockImplementation(request);
+ document.dispatchEvent(new Event("yt-navigate-finish"));
+ await flush();
+ expect(document.getElementById("ryd-discussion-panel").dataset.videoId).toBe(OTHER);
+ expect(document.body.textContent).not.toContain("Old private draft");
+});
+
+test("a logout event wins over an older initial session read", async () => {
+ let resolve;
+ feature = initDiscussion({
+ request: (operation) =>
+ operation === "session"
+ ? new Promise((done) => {
+ resolve = done;
+ })
+ : request(operation),
+ });
+ eventListener({
+ message: "ryd_discussion_event",
+ type: "session",
+ data: { authenticated: false, canPost: false, profile: null },
+ });
+ resolve({ authenticated: true, canPost: true, profile: { id: "old", nickname: "Previous" } });
+ await flush();
+ expect(document.querySelector("textarea[name='comment']")).toBeNull();
+ expect(document.body.textContent).not.toContain("Previous");
+});
+
+test("restoring a cached page recreates the panel with a fresh request scope", async () => {
+ const call = jest.fn(request);
+ feature = initDiscussion({ request: call });
+ await flush();
+ const previousScope = call.mock.calls.find(([operation]) => operation === "comments")[2];
+ window.dispatchEvent(new Event("pagehide"));
+ expect(document.getElementById("ryd-discussion-panel")).toBeNull();
+ expect(call).toHaveBeenCalledWith("cancelScope", {}, previousScope);
+ window.dispatchEvent(new Event("pageshow"));
+ await flush();
+ const reads = call.mock.calls.filter(([operation]) => operation === "comments");
+ expect(reads).toHaveLength(2);
+ expect(reads[1][2].videoId).toBe(VIDEO);
+ expect(reads[1][2].id).not.toBe(previousScope.id);
+ expect(document.querySelectorAll("#ryd-discussion-panel")).toHaveLength(1);
+});
+
+test("switching YouTube language refreshes the panel language", async () => {
+ feature = initDiscussion({ request });
+ await flush();
+ document.documentElement.lang = "ru";
+ await feature.refresh();
+ expect(document.querySelector(".ryd-discussion-title").textContent).toBe("Комментарии RYD");
+});
+
+test("an opened Shorts drawer survives a transiently hidden and replaced same-video container", async () => {
+ const oldReel = shortsFixture();
+ const call = jest.fn(request);
+ feature = initDiscussion({ request: call });
+ await flush();
+ document.getElementById("ryd-discussion-launcher").click();
+ const oldDrawer = document.getElementById("ryd-discussion-drawer");
+ const oldScope = call.mock.calls.find(([operation]) => operation === "comments")[2];
+ expect(oldDrawer.hidden).toBe(false);
+ oldReel.hidden = true;
+ await feature.refresh();
+ expect(document.getElementById("ryd-discussion-drawer")).toBeNull();
+ expect(call).toHaveBeenCalledWith("cancelScope", {}, oldScope);
+ shortsFixture();
+ await feature.refresh();
+ const nextDrawer = document.getElementById("ryd-discussion-drawer");
+ expect(nextDrawer).not.toBe(oldDrawer);
+ expect(nextDrawer.hidden).toBe(false);
+ expect(document.querySelector(".ryd-discussion-backdrop").hidden).toBe(false);
+ expect(document.getElementById("ryd-discussion-launcher").getAttribute("aria-expanded")).toBe("true");
+ expect(document.querySelectorAll("#ryd-discussion-drawer")).toHaveLength(1);
+ const nextScope = call.mock.calls.filter(([operation]) => operation === "comments").at(-1)[2];
+ expect(nextScope.videoId).toBe(VIDEO);
+ expect(nextScope.id).not.toBe(oldScope.id);
+});
+
+test("closing a Shorts drawer clears its intent before the native container is replaced", async () => {
+ shortsFixture();
+ feature = initDiscussion({ request });
+ await flush();
+ document.getElementById("ryd-discussion-launcher").click();
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ shortsFixture();
+ await feature.refresh();
+ expect(document.getElementById("ryd-discussion-drawer").hidden).toBe(true);
+ expect(document.getElementById("ryd-discussion-launcher").getAttribute("aria-expanded")).toBe("false");
+});
+
+test.each(["navigation-event", "different-video"])(
+ "%s does not carry an opened Shorts drawer into the next panel",
+ async (change) => {
+ shortsFixture();
+ feature = initDiscussion({ request });
+ await flush();
+ document.getElementById("ryd-discussion-launcher").click();
+ if (change === "navigation-event") document.dispatchEvent(new Event("yt-navigate-start"));
+ shortsFixture(change === "different-video" ? OTHER : VIDEO);
+ if (change === "navigation-event") document.dispatchEvent(new Event("yt-navigate-finish"));
+ else await feature.refresh();
+ await flush();
+ expect(document.getElementById("ryd-discussion-drawer").hidden).toBe(true);
+ },
+);
+
+test("remembered Shorts open intent cannot retain an ineligible panel", async () => {
+ const reel = shortsFixture();
+ feature = initDiscussion({ request });
+ await flush();
+ document.getElementById("ryd-discussion-launcher").click();
+ reel.setAttribute("made-for-kids", "true");
+ await feature.refresh();
+ expect(document.getElementById("ryd-discussion-drawer")).toBeNull();
+ expect(document.getElementById("ryd-discussion-launcher")).toBeNull();
+ expect(document.querySelector(".ryd-discussion-backdrop")).toBeNull();
+});
+
+test("a hash target focuses a reply in an existing panel and switches from inbox without stealing focus again", async () => {
+ const call = jest.fn(linkRequest);
+ feature = initDiscussion({ request: call });
+ await flush();
+ const original = document.getElementById("ryd-discussion-panel");
+ original.querySelector("[data-action=inbox]").click();
+ window.history.replaceState({}, "", `/watch?v=${VIDEO}#rydComment=${REPLY_COMMENT}`);
+ window.dispatchEvent(new Event("hashchange"));
+ await flush();
+ expect(document.getElementById("ryd-discussion-panel")).toBe(original);
+ expect(original.querySelector(".ryd-discussion-inbox").hidden).toBe(true);
+ expect(document.activeElement.dataset.commentId).toBe(REPLY_COMMENT);
+ const before = call.mock.calls.filter(([operation]) => operation === "comment").length;
+ const tab = original.querySelector("[data-action=discussion]");
+ tab.focus();
+ await feature.refresh();
+ expect(document.activeElement).toBe(tab);
+ expect(call.mock.calls.filter(([operation]) => operation === "comment")).toHaveLength(before);
+ window.history.replaceState({}, "", `/watch?v=${VIDEO}#rydComment=${ROOT_COMMENT}`);
+ window.dispatchEvent(new Event("popstate"));
+ await flush();
+ expect(document.activeElement.dataset.commentId).toBe(ROOT_COMMENT);
+});
+
+test.each(["watch", "shorts"])(
+ "a same-video %s toast click focuses directly before native navigation",
+ async (surface) => {
+ if (surface === "shorts") shortsFixture();
+ const call = jest.fn(linkRequest);
+ feature = initDiscussion({ request: call });
+ await flush();
+ const anchor = notificationAnchor();
+ const click = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
+ anchor.dispatchEvent(click);
+ expect(click.defaultPrevented).toBe(true);
+ expect(location.hash).toBe(`#rydComment=${REPLY_COMMENT}`);
+ expect(location.pathname).toBe(surface === "shorts" ? `/shorts/${VIDEO}` : "/watch");
+ await flush();
+ expect(document.activeElement.dataset.commentId).toBe(REPLY_COMMENT);
+ expect(call).toHaveBeenCalledWith("readNotifications", { ids: [ROOT_COMMENT] });
+ if (surface === "shorts") expect(document.getElementById("ryd-discussion-drawer").hidden).toBe(false);
+ },
+);
+
+test.each([
+ { ctrlKey: true },
+ { metaKey: true },
+ { shiftKey: true },
+ { altKey: true },
+ { button: 1 },
+ { target: "_blank" },
+ { videoId: OTHER },
+])("notification links preserve modified clicks and separate destinations: %j", async (options) => {
+ feature = initDiscussion({ request: linkRequest });
+ await flush();
+ const anchor = notificationAnchor(options);
+ let preventedByFeature;
+ anchor.addEventListener("click", (event) => {
+ preventedByFeature = event.defaultPrevented;
+ event.preventDefault();
+ });
+ anchor.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, ...options }));
+ expect(preventedByFeature).toBe(false);
+ expect(location.hash).toBe("");
+});
+
+test("a new page opened with a fragment focuses its target after initial panel hydration", async () => {
+ window.history.replaceState({}, "", `/watch?v=${VIDEO}#rydComment=${REPLY_COMMENT}`);
+ feature = initDiscussion({ request: linkRequest });
+ await flush();
+ expect(document.activeElement.dataset.commentId).toBe(REPLY_COMMENT);
+});
diff --git a/Extensions/combined/src/discussion/localize.js b/Extensions/combined/src/discussion/localize.js
new file mode 100644
index 0000000..393d40e
--- /dev/null
+++ b/Extensions/combined/src/discussion/localize.js
@@ -0,0 +1,44 @@
+import english from "../../_locales/en/messages.json";
+import russian from "../../_locales/ru/messages.json";
+
+export function discussionLanguage(language = document.documentElement.lang || navigator.language) {
+ return /^ru(?:-|$)/i.test(language || "") ? "ru" : "en";
+}
+
+export function discussionText(key, substitutions = [], language) {
+ const name = `discussion_${key}`;
+ const catalog = discussionLanguage(language) === "ru" ? russian : english;
+ const text = catalog[name]?.message || english[name]?.message || key;
+ return text.replace(/\$(\d+)/g, (_, number) => String(substitutions[Number(number) - 1] ?? ""));
+}
+
+export function discussionError(error) {
+ const keys = {
+ nickname_required: "nicknameRequired",
+ nickname_taken: "nicknameTaken",
+ invalid_nickname: "nicknameInvalid",
+ membership_tier_insufficient: "tierRequired",
+ membership_inactive: "tierRequired",
+ invalid_session: "signInRequired",
+ session_upgrade_required: "signInRequired",
+ unauthorized: "signInRequired",
+ comments_not_allowed: "unavailable",
+ comments_disabled_required: "unavailable",
+ made_for_kids: "unavailable",
+ video_not_eligible: "unavailable",
+ comment_not_found: "commentUnavailable",
+ not_found: "commentUnavailable",
+ rate_limited: "rateLimited",
+ conflict: "conflict",
+ forbidden: "forbidden",
+ };
+ const statusKey =
+ error?.status === 401
+ ? "signInRequired"
+ : error?.status === 403
+ ? "forbidden"
+ : error?.status === 429
+ ? "rateLimited"
+ : "error";
+ return discussionText(keys[error?.code] || statusKey);
+}
diff --git a/Extensions/combined/src/discussion/panel.js b/Extensions/combined/src/discussion/panel.js
new file mode 100644
index 0000000..0f6b9c2
--- /dev/null
+++ b/Extensions/combined/src/discussion/panel.js
@@ -0,0 +1,700 @@
+import { requestDiscussion } from "./client";
+import { discussionText as t, discussionError, discussionLanguage } from "./localize";
+import { element, button, statusNode, shortDate, thumbIcon, isDiscussionCommentId } from "./dom";
+import { createDiscussionInbox } from "./inbox";
+
+const SIGNED_OUT = { authenticated: false, canPost: false, isModerator: false, profile: null };
+
+export function createDiscussionPanel({
+ videoId,
+ surface = "watch",
+ session = SIGNED_OUT,
+ capabilities = {},
+ request = requestDiscussion,
+ onClose,
+} = {}) {
+ const maxLength = capabilities.maxCommentLength || 2000;
+ const root = element("section", `ryd-discussion ryd-discussion-${surface}`);
+ root.id = surface === "shorts" ? "ryd-discussion-drawer" : "ryd-discussion-panel";
+ root.dataset.videoId = videoId;
+ root.setAttribute("aria-label", t("title"));
+ if (surface === "shorts") {
+ root.setAttribute("role", "dialog");
+ root.setAttribute("aria-modal", "true");
+ root.tabIndex = -1;
+ }
+ const header = element("header", "ryd-discussion-header");
+ const brand = element("div", "ryd-discussion-brand");
+ brand.append(element("span", "ryd-discussion-logo", "RYD"), element("h2", "ryd-discussion-title", t("title")));
+ const headerButtons = element("div", "ryd-discussion-header-actions");
+ const inboxButton = button(t("notifications"), () => showTab("inbox"));
+ const moderationButton = button(t("moderation"), () => showTab("moderation"));
+ const discussionButton = button(t("discussionTab"), () => showTab("discussion"));
+ inboxButton.dataset.action = "inbox";
+ moderationButton.dataset.action = "moderation";
+ discussionButton.dataset.action = "discussion";
+ headerButtons.append(discussionButton, inboxButton, moderationButton);
+ if (onClose) {
+ const close = button("×", onClose, "ryd-discussion-close");
+ close.setAttribute("aria-label", t("close"));
+ headerButtons.append(close);
+ }
+ header.append(brand, headerButtons);
+ const intro = element("p", "ryd-discussion-description", t("subtitle"));
+ const independent = element("p", "ryd-discussion-independent", t("independent"));
+ const body = element("div", "ryd-discussion-body");
+ const discussion = element("div", "ryd-discussion-thread");
+ const composer = element("div", "ryd-discussion-composer");
+ const toolbar = element("div", "ryd-discussion-toolbar");
+ const sort = element("select", "ryd-discussion-select");
+ sort.setAttribute("aria-label", t("sort"));
+ for (const value of ["top", "new"]) {
+ const option = element("option", "", t(value));
+ option.value = value;
+ sort.append(option);
+ }
+ sort.addEventListener("change", () => load());
+ const refresh = button(t("refresh"), () => load());
+ toolbar.append(sort, refresh);
+ const status = statusNode();
+ const list = element("div", "ryd-discussion-comments");
+ const more = button(t("more"), () => load(true), "ryd-discussion-button ryd-discussion-more");
+ more.hidden = true;
+ discussion.append(composer, toolbar, status, list, more);
+ const inbox = createDiscussionInbox({ request, authenticated: session.authenticated });
+ const moderation = element("div", "ryd-discussion-moderation");
+ inbox.node.hidden = true;
+ moderation.hidden = true;
+ body.append(discussion, inbox.node, moderation);
+ root.append(header, intro, independent, body);
+ let disposed = false;
+ let accountGeneration = 0;
+ let sequence = 0;
+ let items = [];
+ let cursor = null;
+ let activeTab = "discussion";
+ const drafts = new Map();
+ const postingIds = new Map();
+ const rootReplyHandlers = new Map();
+ let moderationSequence = 0;
+ let reportCursor = null;
+ let reportStatus = "open";
+ let reports = [];
+ let highlightedId = null;
+ let focusSequence = 0;
+
+ function current() {
+ return !disposed;
+ }
+ function setError(error) {
+ if (current()) status.textContent = discussionError(error);
+ }
+
+ function showTab(tab, reload = true) {
+ if (tab === "moderation" && !session.isModerator) return;
+ if (tab === "inbox" && !session.authenticated) return;
+ const previousTab = activeTab;
+ activeTab = tab;
+ discussion.hidden = tab !== "discussion";
+ inbox.node.hidden = tab !== "inbox";
+ moderation.hidden = tab !== "moderation";
+ discussionButton.setAttribute("aria-pressed", String(tab === "discussion"));
+ inboxButton.setAttribute("aria-pressed", String(tab === "inbox"));
+ moderationButton.setAttribute("aria-pressed", String(tab === "moderation"));
+ if (tab === "inbox") inbox.load();
+ if (tab === "moderation") loadReports();
+ if (reload && tab === "discussion" && previousTab === "moderation") load();
+ }
+
+ function renderSession() {
+ inboxButton.hidden = !session.authenticated;
+ moderationButton.hidden = !session.isModerator;
+ discussionButton.hidden = !session.authenticated;
+ if ((activeTab === "inbox" && !session.authenticated) || (activeTab === "moderation" && !session.isModerator))
+ showTab("discussion");
+ composer.replaceChildren();
+ if (!session.canPost || session.profile?.suspended) {
+ composer.append(element("p", "ryd-discussion-gate", t("gate")));
+ const join = element("a", "ryd-discussion-link", t("join"));
+ join.href = "https://www.patreon.com/returnyoutubedislike/membership";
+ join.target = "_blank";
+ join.rel = "noopener noreferrer";
+ composer.append(join);
+ if (!session.authenticated) composer.append(element("p", "ryd-discussion-meta", t("signInRequired")));
+ } else if (!session.profile?.nickname) {
+ composer.append(nicknameForm());
+ } else {
+ const account = element("div", "ryd-discussion-account");
+ account.append(
+ element("span", "ryd-discussion-meta", t("as", [session.profile.nickname])),
+ button(t("nicknameChange"), () => {
+ composer.replaceChildren(nicknameForm(true));
+ }),
+ );
+ composer.append(account, commentForm());
+ }
+ renderComments();
+ }
+
+ function nicknameForm(allowCancel = false) {
+ const form = element("form", "ryd-discussion-form");
+ const label = element("label", "ryd-discussion-label", t("nicknameLabel"));
+ const input = element("input", "ryd-discussion-input");
+ input.name = "nickname";
+ input.value = session.profile?.nickname || "";
+ input.required = true;
+ input.minLength = capabilities.minNicknameLength || 3;
+ input.maxLength = capabilities.maxNicknameLength || 32;
+ input.autocomplete = "nickname";
+ label.append(input);
+ const message = statusNode();
+ const actions = element("div", "ryd-discussion-form-actions");
+ const submit = button(t("nicknameSave"), null, "ryd-discussion-button ryd-discussion-primary");
+ submit.type = "submit";
+ actions.append(submit);
+ if (allowCancel) actions.append(button(t("cancel"), renderSession));
+ form.append(
+ element("p", "ryd-discussion-description", t("nicknameRequired")),
+ label,
+ element("p", "ryd-discussion-meta", t("nicknameHint")),
+ message,
+ actions,
+ );
+ form.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ if (submit.disabled) return;
+ const nickname = input.value.trim().normalize("NFKC");
+ if (
+ nickname.length < input.minLength ||
+ nickname.length > input.maxLength ||
+ !/^[\p{L}\p{N} ._-]+$/u.test(nickname) ||
+ !/[\p{L}\p{N}]/u.test(nickname)
+ ) {
+ message.textContent = t("nicknameInvalid");
+ return;
+ }
+ submit.disabled = true;
+ message.textContent = t("saving");
+ const accountSnapshot = accountGeneration;
+ try {
+ const result = await request("profile", { nickname });
+ if (!current() || accountSnapshot !== accountGeneration) return;
+ session = result;
+ renderSession();
+ } catch (error) {
+ if (current()) message.textContent = discussionError(error);
+ } finally {
+ submit.disabled = false;
+ }
+ });
+ return form;
+ }
+
+ function commentForm({ parentId = null, replyToId = null, comment = null, onCancel, onSaved } = {}) {
+ const key = comment ? `edit:${comment.id}` : `reply:${replyToId || "root"}`;
+ const form = element("form", "ryd-discussion-form");
+ const label = element("label", "ryd-discussion-label");
+ label.append(element("span", "ryd-discussion-sr-only", t(comment ? "edit" : replyToId ? "reply" : "comment")));
+ const input = element("textarea", "ryd-discussion-textarea");
+ input.name = "comment";
+ input.placeholder = t("placeholder");
+ input.rows = 3;
+ input.maxLength = maxLength;
+ input.required = true;
+ input.value = drafts.get(key) ?? comment?.text ?? "";
+ label.append(input);
+ const footer = element("div", "ryd-discussion-form-actions");
+ const counter = element("span", "ryd-discussion-counter");
+ const submit = button(
+ t(comment ? "save" : replyToId ? "postReply" : "post"),
+ null,
+ "ryd-discussion-button ryd-discussion-primary",
+ );
+ submit.type = "submit";
+ submit.dataset.action = comment ? "save" : "post";
+ const update = () => {
+ drafts.set(key, input.value);
+ counter.textContent = t("characters", [input.value.length, maxLength]);
+ submit.disabled = !input.value.trim() || input.value.length > maxLength;
+ postingIds.delete(key);
+ };
+ input.addEventListener("input", update);
+ input.addEventListener("keydown", (event) => {
+ if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
+ event.preventDefault();
+ form.requestSubmit();
+ }
+ });
+ counter.textContent = t("characters", [input.value.length, maxLength]);
+ submit.disabled = !input.value.trim();
+ footer.append(counter);
+ if (onCancel) footer.append(button(t("cancel"), onCancel));
+ footer.append(submit);
+ const message = statusNode();
+ form.append(label, message, footer);
+ form.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ if (submit.disabled) return;
+ const text = input.value.trim();
+ if (!text || text.length > maxLength) return;
+ submit.disabled = true;
+ input.disabled = true;
+ message.textContent = t("saving");
+ const requestId = postingIds.get(key) || globalThis.crypto.randomUUID();
+ postingIds.set(key, requestId);
+ try {
+ const payload = comment
+ ? { id: comment.id, text }
+ : { videoId, text, requestId, ...(parentId ? { parentId, replyToId } : {}) };
+ const result = await request(comment ? "editComment" : "createComment", payload);
+ if (!current()) return;
+ drafts.delete(key);
+ postingIds.delete(key);
+ if (onSaved) onSaved(result);
+ else {
+ input.value = "";
+ update();
+ message.textContent = "";
+ await load();
+ }
+ } catch (error) {
+ if (current()) message.textContent = discussionError(error);
+ } finally {
+ if (current()) {
+ input.disabled = false;
+ submit.disabled = !input.value.trim();
+ }
+ }
+ });
+ return form;
+ }
+
+ function commentRow(comment, rootId = comment.parentId || comment.id) {
+ const row = element("article", `ryd-discussion-comment${comment.parentId ? " ryd-discussion-reply" : ""}`);
+ row.dataset.commentId = comment.id;
+ if (comment.id === highlightedId) row.classList.add("is-highlighted");
+ const heading = element("div", "ryd-discussion-comment-heading");
+ const initial = (comment.author?.nickname || "RYD").slice(0, 1).toUpperCase();
+ heading.append(
+ element("span", "ryd-discussion-avatar", initial),
+ element("strong", "ryd-discussion-author", comment.author?.nickname || t("deleted")),
+ );
+ const date = element("time", "ryd-discussion-meta", shortDate(comment.createdAt, discussionLanguage()));
+ date.dateTime = comment.createdAt;
+ heading.append(date);
+ if (comment.editedAt) heading.append(element("span", "ryd-discussion-meta", t("edited")));
+ const text = element(
+ "p",
+ "ryd-discussion-comment-text",
+ comment.deleted || comment.hidden ? t("deleted") : comment.text,
+ );
+ const actions = element("div", "ryd-discussion-comment-actions");
+ const controls = element("div", "ryd-discussion-inline-editor");
+ const replies = element("div", "ryd-discussion-replies");
+ row.append(heading, text, actions, controls, replies);
+ const alive = () => current() && row.isConnected;
+ if (!comment.deleted && !comment.hidden) {
+ for (const value of [1, -1]) {
+ const reaction = button(
+ "",
+ async () => {
+ if (reaction.disabled) return;
+ const buttons = [...actions.querySelectorAll("[data-reaction]")];
+ buttons.forEach((item) => {
+ item.disabled = true;
+ });
+ try {
+ const result = await request("react", {
+ id: comment.id,
+ value: comment.myReaction === value ? 0 : value,
+ });
+ if (!alive()) return;
+ Object.assign(comment, result);
+ updateReactions();
+ } catch (error) {
+ setError(error);
+ } finally {
+ buttons.forEach((item) => {
+ item.disabled = false;
+ });
+ }
+ },
+ "ryd-discussion-button ryd-discussion-reaction",
+ );
+ reaction.dataset.reaction = String(value);
+ actions.append(reaction);
+ }
+ function updateReactions() {
+ for (const reaction of actions.querySelectorAll("[data-reaction]")) {
+ const liked = reaction.dataset.reaction === "1";
+ const count = liked ? comment.likes || 0 : comment.dislikes || 0;
+ reaction.replaceChildren(thumbIcon(!liked), element("span", "", count.toLocaleString(discussionLanguage())));
+ reaction.setAttribute("aria-label", `${t(liked ? "like" : "dislike")} (${count})`);
+ reaction.setAttribute("aria-pressed", String(comment.myReaction === (liked ? 1 : -1)));
+ }
+ }
+ updateReactions();
+ const reply = button(t("reply"), () => {
+ controls.replaceChildren();
+ if (!session.canPost) {
+ controls.append(element("p", "ryd-discussion-gate", t("tierRequired")));
+ return;
+ }
+ if (!session.profile?.nickname) {
+ controls.append(nicknameForm());
+ return;
+ }
+ controls.append(
+ commentForm({
+ parentId: rootId,
+ replyToId: comment.id,
+ onCancel: () => controls.replaceChildren(),
+ onSaved: (created) => {
+ controls.replaceChildren();
+ rootReplyHandlers.get(rootId)?.(created);
+ },
+ }),
+ );
+ controls.querySelector("textarea")?.focus();
+ });
+ reply.dataset.action = "reply";
+ actions.append(reply);
+ if (session.authenticated && comment.author?.id === session.profile?.id) {
+ if (comment.canEdit) {
+ const edit = button(t("edit"), () => {
+ controls.replaceChildren(
+ commentForm({
+ comment,
+ onCancel: () => controls.replaceChildren(),
+ onSaved: (updated) => {
+ Object.assign(comment, updated);
+ row.replaceWith(commentRow(comment, rootId));
+ },
+ }),
+ );
+ controls.querySelector("textarea")?.focus();
+ });
+ edit.dataset.action = "edit";
+ actions.append(edit);
+ }
+ if (comment.canDelete) {
+ const remove = button(t("delete"), () => {
+ const prompt = element("div", "ryd-discussion-confirm");
+ prompt.append(
+ element("span", "", t("deleteConfirm")),
+ button(t("cancel"), () => controls.replaceChildren()),
+ );
+ const confirm = button(
+ t("delete"),
+ async () => {
+ confirm.disabled = true;
+ try {
+ const updated = await request("deleteComment", { id: comment.id });
+ if (alive()) {
+ Object.assign(comment, updated);
+ row.replaceWith(commentRow(comment, rootId));
+ }
+ } catch (error) {
+ setError(error);
+ confirm.disabled = false;
+ }
+ },
+ "ryd-discussion-button ryd-discussion-danger",
+ );
+ confirm.dataset.action = "confirm-delete";
+ prompt.append(confirm);
+ controls.replaceChildren(prompt);
+ });
+ remove.dataset.action = "delete";
+ actions.append(remove);
+ }
+ }
+ if (session.authenticated) {
+ const report = button(t("report"), () => showReport(comment, controls));
+ report.dataset.action = "report";
+ actions.append(report);
+ }
+ }
+ let repliesCursor = null;
+ let replyItems = [];
+ let replySequence = 0;
+ let replyLoading = false;
+ let repliesLoaded = false;
+ const moreReplies = button(t("more"), () => loadReplies(true));
+ const showReplies = button(t("loadReplies", [comment.replyCount || 0]), () => loadReplies());
+ showReplies.dataset.action = "load-replies";
+ showReplies.hidden = Boolean(comment.parentId) || !comment.replyCount;
+ actions.append(showReplies);
+ function renderReplies() {
+ const timestamp = (value) =>
+ String(value).replace(/(?:\.(\d+))?Z$/, (_, fraction = "") => `.${fraction.padEnd(9, "0")}Z`);
+ replyItems = [...new Map(replyItems.map((item) => [item.id, item])).values()].sort(
+ (a, b) => timestamp(a.createdAt).localeCompare(timestamp(b.createdAt)) || a.id.localeCompare(b.id),
+ );
+ comment.replyCount = Math.max(comment.replyCount || 0, replyItems.length);
+ replies.replaceChildren(...replyItems.map((item) => commentRow(item, rootId)));
+ if (repliesCursor) replies.append(moreReplies);
+ }
+ function addReply(created) {
+ if (!alive()) return;
+ if (!replyItems.some((item) => item.id === created.id)) comment.replyCount = (comment.replyCount || 0) + 1;
+ replyItems.push(created);
+ renderReplies();
+ showReplies.textContent = t("loadReplies", [comment.replyCount]);
+ showReplies.hidden = repliesLoaded;
+ if (!repliesLoaded) loadReplies();
+ }
+ async function loadReplies(append = false) {
+ if (replyLoading) return;
+ replyLoading = true;
+ showReplies.disabled = true;
+ const currentReply = ++replySequence;
+ try {
+ const result = await request("replies", {
+ rootId,
+ ...(append && repliesCursor ? { cursor: repliesCursor } : {}),
+ });
+ if (!alive() || currentReply !== replySequence) return;
+ replyItems = [...replyItems, ...(result.items || [])];
+ repliesCursor = result.nextCursor;
+ repliesLoaded = true;
+ renderReplies();
+ showReplies.hidden = true;
+ } catch (error) {
+ setError(error);
+ } finally {
+ showReplies.disabled = false;
+ replyLoading = false;
+ }
+ }
+ if (!comment.parentId) rootReplyHandlers.set(comment.id, addReply);
+ return row;
+ }
+
+ function showReport(comment, host) {
+ const form = element("form", "ryd-discussion-form");
+ const label = element("label", "ryd-discussion-label", t("reportReason"));
+ const reason = element("select", "ryd-discussion-select");
+ for (const [value, key] of [
+ ["spam", "reportSpam"],
+ ["abuse", "reportAbuse"],
+ ["other", "reportOther"],
+ ]) {
+ const option = element("option", "", t(key));
+ option.value = value;
+ reason.append(option);
+ }
+ label.append(reason);
+ const detailsLabel = element("label", "ryd-discussion-label", t("reportDetails"));
+ const details = element("textarea", "ryd-discussion-textarea");
+ details.maxLength = 400;
+ details.rows = 2;
+ detailsLabel.append(details);
+ const message = statusNode();
+ const submit = button(t("reportSend"), null, "ryd-discussion-button ryd-discussion-primary");
+ submit.type = "submit";
+ submit.dataset.action = "send-report";
+ const actions = element("div", "ryd-discussion-form-actions");
+ actions.append(
+ button(t("cancel"), () => host.replaceChildren()),
+ submit,
+ );
+ form.append(element("h4", "ryd-discussion-subtitle", t("reportTitle")), label, detailsLabel, message, actions);
+ form.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ if (submit.disabled) return;
+ submit.disabled = true;
+ try {
+ await request("report", {
+ id: comment.id,
+ reason: `${reason.value}${details.value.trim() ? `: ${details.value.trim()}` : ""}`,
+ });
+ if (current()) host.replaceChildren(element("p", "ryd-discussion-status", t("reportDone")));
+ } catch (error) {
+ message.textContent = discussionError(error);
+ submit.disabled = false;
+ }
+ });
+ host.replaceChildren(form);
+ }
+
+ function renderComments() {
+ rootReplyHandlers.clear();
+ list.replaceChildren(...items.map((item) => commentRow(item)));
+ }
+
+ async function load(append = false) {
+ if (!current()) return;
+ const currentSequence = ++sequence;
+ status.textContent = t("loading");
+ refresh.disabled = true;
+ more.disabled = true;
+ try {
+ const result = await request("comments", { videoId, sort: sort.value, ...(append && cursor ? { cursor } : {}) });
+ if (!current() || currentSequence !== sequence) return;
+ items = append ? [...items, ...(result.items || [])] : result.items || [];
+ items = [...new Map(items.map((item) => [item.id, item])).values()];
+ cursor = result.nextCursor;
+ renderComments();
+ status.textContent = items.length ? "" : t("empty");
+ more.hidden = !cursor;
+ } catch (error) {
+ if (current() && currentSequence === sequence) setError(error);
+ } finally {
+ if (current() && currentSequence === sequence) {
+ refresh.disabled = false;
+ more.disabled = false;
+ }
+ }
+ }
+
+ async function loadReports(append = false) {
+ if (!current() || !session.isModerator) return;
+ const currentSequence = ++moderationSequence;
+ moderation.replaceChildren(element("p", "ryd-discussion-status", t("loading")));
+ try {
+ const result = await request("reports", {
+ status: reportStatus,
+ ...(append && reportCursor ? { cursor: reportCursor } : {}),
+ });
+ if (!current() || currentSequence !== moderationSequence || !session.isModerator) return;
+ reports = append ? [...reports, ...(result.items || [])] : result.items || [];
+ reportCursor = result.nextCursor;
+ const reportToolbar = element("div", "ryd-discussion-toolbar");
+ const reportFilter = element("select", "ryd-discussion-select");
+ reportFilter.dataset.action = "report-status";
+ reportFilter.setAttribute("aria-label", t("reportStatus"));
+ for (const [value, label] of [
+ ["open", "openReports"],
+ ["resolved", "resolvedReports"],
+ ]) {
+ const option = element("option", "", t(label));
+ option.value = value;
+ reportFilter.append(option);
+ }
+ reportFilter.value = reportStatus;
+ reportFilter.addEventListener("change", () => {
+ reportStatus = reportFilter.value;
+ reportCursor = null;
+ loadReports();
+ });
+ reportToolbar.append(element("h3", "ryd-discussion-subtitle", t("moderation")), reportFilter);
+ moderation.replaceChildren(reportToolbar);
+ if (!reports.length) moderation.append(element("p", "ryd-discussion-status", t("queueEmpty")));
+ for (const report of reports) {
+ const row = element("article", "ryd-discussion-report");
+ row.dataset.reportId = report.id;
+ row.append(
+ element("strong", "ryd-discussion-author", report.comment?.author?.nickname || t("deleted")),
+ element("p", "ryd-discussion-comment-text", report.comment?.text || t("deleted")),
+ element("p", "ryd-discussion-report-reason", report.reason),
+ );
+ const reasonLabel = element("label", "ryd-discussion-label", t("moderationReason"));
+ const reason = element("input", "ryd-discussion-input");
+ reason.maxLength = 300;
+ reasonLabel.append(reason);
+ row.append(reasonLabel);
+ const actions = element("div", "ryd-discussion-comment-actions");
+ const feedback = statusNode();
+ const addAction = (key, operation, payload) => {
+ const action = button(t(key), async () => {
+ const buttons = [...actions.querySelectorAll("button")];
+ buttons.forEach((item) => {
+ item.disabled = true;
+ });
+ try {
+ await request(operation, { ...payload, ...(reason.value.trim() ? { reason: reason.value.trim() } : {}) });
+ if (current()) await loadReports();
+ } catch (error) {
+ feedback.textContent = discussionError(error);
+ buttons.forEach((item) => {
+ item.disabled = false;
+ });
+ }
+ });
+ action.dataset.action = key;
+ actions.append(action);
+ };
+ if (reportStatus === "open") addAction("dismiss", "dismissReport", { id: report.id });
+ addAction(report.comment?.hidden ? "restore" : "hide", "moderateComment", {
+ id: report.comment.id,
+ hidden: !report.comment.hidden,
+ });
+ if (report.comment.author)
+ addAction(report.authorSuspended ? "unban" : "ban", "suspendProfile", {
+ id: report.comment.author.id,
+ suspended: !report.authorSuspended,
+ });
+ row.append(actions, feedback);
+ moderation.append(row);
+ }
+ if (reportCursor) moderation.append(button(t("more"), () => loadReports(true)));
+ } catch (error) {
+ if (current() && currentSequence === moderationSequence)
+ moderation.replaceChildren(
+ element("p", "ryd-discussion-status", discussionError(error)),
+ button(t("retry"), () => loadReports()),
+ );
+ }
+ }
+
+ async function focusComment(id) {
+ if (!isDiscussionCommentId(id)) return;
+ const focus = ++focusSequence;
+ const account = accountGeneration;
+ const stillCurrent = () => current() && focus === focusSequence && account === accountGeneration;
+ try {
+ const target = await request("comment", { id });
+ if (!stillCurrent() || target.videoId !== videoId) return;
+ highlightedId = id;
+ const threadRoot = target.parentId ? await request("comment", { id: target.parentId }) : target;
+ if (!stillCurrent() || threadRoot.videoId !== videoId) return;
+ showTab("discussion", false);
+ if (!items.some((item) => item.id === threadRoot.id)) items.unshift(threadRoot);
+ renderComments();
+ const row = [...list.querySelectorAll("[data-comment-id]")].find(
+ (item) => item.dataset.commentId === threadRoot.id,
+ );
+ if (target.parentId) row.querySelector(".ryd-discussion-replies").append(commentRow(target, threadRoot.id));
+ const focused = [...list.querySelectorAll("[data-comment-id]")].find((item) => item.dataset.commentId === id);
+ focused?.scrollIntoView?.({ block: "center", behavior: "smooth" });
+ if (focused) {
+ focused.tabIndex = -1;
+ focused.focus({ preventScroll: true });
+ }
+ } catch (error) {
+ if (stillCurrent()) setError(error);
+ }
+ }
+
+ renderSession();
+ showTab("discussion");
+ return {
+ root,
+ load,
+ focusComment,
+ setSession(next) {
+ const previousIdentity = session.authenticated ? session.profile?.id || "authenticated" : "anonymous";
+ const nextIdentity = next?.authenticated ? next.profile?.id || "authenticated" : "anonymous";
+ accountGeneration++;
+ moderationSequence++;
+ session = next || SIGNED_OUT;
+ inbox.setSession(session);
+ renderSession();
+ if (previousIdentity !== nextIdentity) load();
+ },
+ setInbox(value) {
+ inbox.update(value, { live: true });
+ inboxButton.textContent = `${t("notifications")}${value?.unreadCount ? ` (${value.unreadCount})` : ""}`;
+ },
+ destroy() {
+ disposed = true;
+ sequence++;
+ moderationSequence++;
+ drafts.clear();
+ postingIds.clear();
+ inbox.destroy();
+ root.remove();
+ },
+ };
+}
diff --git a/Extensions/combined/src/discussion/panel.spec.js b/Extensions/combined/src/discussion/panel.spec.js
new file mode 100644
index 0000000..05ce6a0
--- /dev/null
+++ b/Extensions/combined/src/discussion/panel.spec.js
@@ -0,0 +1,433 @@
+/** @jest-environment jsdom */
+
+import { createDiscussionPanel } from "./panel";
+import { discussionText } from "./localize";
+
+const VIDEO = "abcdefghijk";
+const ROOT = "11111111-1111-1111-1111-111111111111";
+const REPLY = "22222222-2222-2222-2222-222222222222";
+const PROFILE = "33333333-3333-3333-3333-333333333333";
+const session = {
+ authenticated: true,
+ canPost: true,
+ isModerator: false,
+ profile: { id: PROFILE, nickname: "Writer" },
+};
+const flush = async () => {
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+};
+let panel;
+let request;
+
+function comment(overrides = {}) {
+ return {
+ id: ROOT,
+ videoId: VIDEO,
+ parentId: null,
+ replyToId: null,
+ author: { id: PROFILE, nickname: "Writer" },
+ text: "First comment",
+ createdAt: "2026-09-01T12:00:00Z",
+ editedAt: null,
+ deleted: false,
+ hidden: false,
+ likes: 2,
+ dislikes: 1,
+ myReaction: 0,
+ replyCount: 0,
+ canEdit: true,
+ canDelete: true,
+ ...overrides,
+ };
+}
+
+async function mount(options = {}, comments = [comment()]) {
+ request = jest.fn(async (operation) =>
+ operation === "comments" ? { items: comments, nextCursor: null } : { items: [], nextCursor: null },
+ );
+ panel = createDiscussionPanel({ videoId: VIDEO, request, ...options });
+ document.body.append(panel.root);
+ await panel.load();
+ return panel.root;
+}
+
+function submit(form, text) {
+ const input = form.querySelector("textarea");
+ input.value = text;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+}
+
+beforeEach(() => {
+ document.body.replaceChildren();
+ document.documentElement.lang = "en";
+ Object.defineProperty(globalThis.crypto, "randomUUID", {
+ configurable: true,
+ value: () => "44444444-4444-4444-4444-444444444444",
+ });
+});
+afterEach(() => panel?.destroy());
+
+test("an older comment deep-link response cannot replace the user's newer focused target", async () => {
+ const root = await mount({ session });
+ let resolveReply;
+ request.mockImplementation((operation, payload) => {
+ if (operation === "comment" && payload.id === REPLY)
+ return new Promise((resolve) => {
+ resolveReply = resolve;
+ });
+ if (operation === "comment") return Promise.resolve(comment());
+ return Promise.resolve({ items: [], nextCursor: null });
+ });
+ const oldFocus = panel.focusComment(REPLY);
+ await panel.focusComment(ROOT);
+ resolveReply(comment({ id: REPLY, parentId: ROOT, text: "Older linked reply" }));
+ await oldFocus;
+ expect(document.activeElement.dataset.commentId).toBe(ROOT);
+ expect(root.querySelector(`[data-comment-id="${REPLY}"]`)).toBeNull();
+});
+
+test("signed-out users can react without seeing author or moderator controls", async () => {
+ const root = await mount();
+ expect(root.querySelector("textarea")).toBeNull();
+ expect(root.querySelector("[data-action='edit']")).toBeNull();
+ expect(root.querySelector("[data-action='moderation']").hidden).toBe(true);
+ request.mockResolvedValueOnce({ commentId: ROOT, likes: 3, dislikes: 1, myReaction: 1 });
+ root.querySelector("[data-reaction='1']").click();
+ await flush();
+ expect(request).toHaveBeenLastCalledWith("react", { id: ROOT, value: 1 });
+ expect(root.querySelector("[data-reaction='1']").getAttribute("aria-pressed")).toBe("true");
+ expect(root.querySelector("[data-reaction='1']").textContent).toBe("3");
+ request.mockResolvedValueOnce({ commentId: ROOT, likes: 2, dislikes: 1, myReaction: 0 });
+ root.querySelector("[data-reaction='1']").click();
+ await flush();
+ expect(request).toHaveBeenLastCalledWith("react", { id: ROOT, value: 0 });
+});
+
+test("renders comment bodies and nicknames as literal text", async () => {
+ const root = await mount({}, [
+ comment({
+ text: '
\nSecond line',
+ author: { id: PROFILE, nickname: "" },
+ }),
+ ]);
+ expect(root.querySelector(".ryd-discussion-comment-text").textContent).toContain("
{
+ const root = await mount({ session });
+ const form = root.querySelector(".ryd-discussion-composer form");
+ request.mockRejectedValueOnce({ code: "unavailable" });
+ submit(form, "A new comment");
+ await flush();
+ const first = request.mock.calls.find(([operation]) => operation === "createComment")[1];
+ expect(form.querySelector("textarea").value).toBe("A new comment");
+ request.mockResolvedValueOnce(comment({ text: "A new comment" }));
+ form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ await flush();
+ const creates = request.mock.calls.filter(([operation]) => operation === "createComment");
+ expect(creates).toHaveLength(2);
+ expect(creates[1][1].requestId).toBe(first.requestId);
+ expect(first).toMatchObject({ videoId: VIDEO, text: "A new comment" });
+});
+
+test("guards blank posts and duplicate clicks while saving", async () => {
+ const root = await mount({ session });
+ const form = root.querySelector(".ryd-discussion-composer form");
+ submit(form, " ");
+ expect(request.mock.calls.filter(([operation]) => operation === "createComment")).toHaveLength(0);
+ let resolve;
+ request.mockImplementationOnce(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ submit(form, "Hello");
+ form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ expect(request.mock.calls.filter(([operation]) => operation === "createComment")).toHaveLength(1);
+ resolve(comment());
+ await flush();
+});
+
+test("nickname onboarding persists a chosen nickname without deriving one from account details", async () => {
+ const root = await mount({
+ session: { ...session, profile: null, fullName: "Private Name", email: "private@example.test" },
+ });
+ const input = root.querySelector("input[name='nickname']");
+ expect(input.value).toBe("");
+ expect(root.textContent).not.toContain("private@example.test");
+ input.value = "New Writer";
+ request.mockResolvedValueOnce({ ...session, profile: { id: PROFILE, nickname: "New Writer" } });
+ input.closest("form").dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ await flush();
+ expect(request).toHaveBeenLastCalledWith("profile", { nickname: "New Writer" });
+ expect(root.querySelector("textarea[name='comment']")).not.toBeNull();
+});
+
+test("inactive members retain their own edit/delete controls while another account does not", async () => {
+ const root = await mount({ session: { ...session, canPost: false } });
+ expect(root.querySelector("[data-action='edit']")).not.toBeNull();
+ expect(root.querySelector("[data-action='delete']")).not.toBeNull();
+ panel.setSession({ ...session, profile: { id: REPLY, nickname: "Other" } });
+ expect(root.querySelector("[data-action='edit']")).toBeNull();
+ panel.setSession({ authenticated: false, profile: null });
+ expect(root.querySelector("[data-action='report']")).toBeNull();
+});
+
+test("a nickname request finishing after logout cannot restore the previous account composer", async () => {
+ const root = await mount({ session: { ...session, profile: null } });
+ let resolve;
+ request.mockImplementationOnce(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ const input = root.querySelector("input[name='nickname']");
+ input.value = "Writer";
+ input.closest("form").dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ panel.setSession({ authenticated: false, canPost: false, profile: null });
+ resolve(session);
+ await flush();
+ expect(root.querySelector("textarea[name='comment']")).toBeNull();
+ expect(root.querySelector("[data-action='inbox']").hidden).toBe(true);
+});
+
+test("signing in refreshes ownership flags previously fetched while signed out", async () => {
+ const root = await mount({}, [comment({ canEdit: false, canDelete: false })]);
+ request.mockResolvedValueOnce({ items: [comment()], nextCursor: null });
+ panel.setSession(session);
+ await flush();
+ expect(request).toHaveBeenLastCalledWith("comments", { videoId: VIDEO, sort: "top" });
+ expect(root.querySelector("[data-action='edit']")).not.toBeNull();
+});
+
+test("deletion waits for explicit inline confirmation and shows a tombstone", async () => {
+ const root = await mount({ session });
+ root.querySelector("[data-action='delete']").click();
+ expect(request.mock.calls.some(([operation]) => operation === "deleteComment")).toBe(false);
+ request.mockResolvedValueOnce(comment({ deleted: true, text: "", author: null }));
+ root.querySelector("[data-action='confirm-delete']").click();
+ await flush();
+ expect(request).toHaveBeenLastCalledWith("deleteComment", { id: ROOT });
+ expect(root.querySelector(".ryd-discussion-comment-text").textContent).toBe(discussionText("deleted"));
+});
+
+test("replies preserve the root and immediate target IDs", async () => {
+ const root = await mount({ session }, [comment({ replyCount: 1 })]);
+ request.mockResolvedValueOnce({
+ items: [comment({ id: REPLY, parentId: ROOT, replyToId: ROOT, text: "First reply" })],
+ nextCursor: null,
+ });
+ root.querySelector("[data-action='load-replies']").click();
+ await flush();
+ const reply = root.querySelector(`[data-comment-id='${REPLY}']`);
+ reply.querySelector("[data-action='reply']").click();
+ request.mockResolvedValueOnce(
+ comment({ id: "55555555-5555-5555-5555-555555555555", parentId: ROOT, replyToId: REPLY }),
+ );
+ submit(reply.querySelector("form"), "Nested response");
+ await flush();
+ expect(request.mock.calls.find(([operation]) => operation === "createComment")[1]).toMatchObject({
+ parentId: ROOT,
+ replyToId: REPLY,
+ text: "Nested response",
+ });
+ expect(root.querySelectorAll(".ryd-discussion-reply")).toHaveLength(2);
+});
+
+test("posting a reply preserves loaded pages and merges the next page without duplicating the new reply", async () => {
+ const previous = Array.from({ length: 21 }, (_, index) =>
+ comment({
+ id: `${String(index + 10).padStart(8, "0")}-1111-4111-8111-111111111111`,
+ parentId: ROOT,
+ replyToId: ROOT,
+ text: `Reply ${index + 1}`,
+ createdAt: `2026-09-01T12:00:${String(index).padStart(2, "0")}Z`,
+ }),
+ );
+ const created = comment({
+ id: REPLY,
+ parentId: ROOT,
+ replyToId: ROOT,
+ text: "Newest reply",
+ createdAt: "2026-09-01T12:01:00Z",
+ });
+ const root = await mount({ session }, [comment({ replyCount: 21 })]);
+ request.mockImplementation(async (operation, payload) => {
+ if (operation === "createComment") return created;
+ if (operation === "replies")
+ return payload.cursor
+ ? { items: [previous[20], created], nextCursor: null }
+ : { items: previous.slice(0, 20), nextCursor: "remaining-replies" };
+ return { items: [], nextCursor: null };
+ });
+ root.querySelector("[data-action='load-replies']").click();
+ await flush();
+ root.querySelector("[data-action='reply']").click();
+ submit(root.querySelector(".ryd-discussion-inline-editor form"), created.text);
+ await flush();
+ expect(root.querySelectorAll(".ryd-discussion-reply")).toHaveLength(21);
+ expect(root.querySelector(`[data-comment-id='${REPLY}']`).textContent).toContain(created.text);
+ expect(request.mock.calls.filter(([operation]) => operation === "replies")).toHaveLength(1);
+ root.querySelector(".ryd-discussion-replies > button").click();
+ await flush();
+ expect(request).toHaveBeenLastCalledWith("replies", { rootId: ROOT, cursor: "remaining-replies" });
+ expect([...root.querySelectorAll(".ryd-discussion-reply")].map((row) => row.dataset.commentId)).toEqual([
+ ...previous.map((item) => item.id),
+ REPLY,
+ ]);
+});
+
+test("posting while the first reply page is loading keeps the new reply when that page arrives", async () => {
+ const thread = comment({ replyCount: 1 });
+ const root = await mount({ session }, [thread]);
+ const previous = comment({ id: REPLY, parentId: ROOT, replyToId: ROOT, text: "Previous reply" });
+ const created = comment({
+ id: "55555555-5555-4555-8555-555555555555",
+ parentId: ROOT,
+ replyToId: ROOT,
+ text: "New reply",
+ createdAt: "2026-09-01T12:00:00.001Z",
+ });
+ let resolveReplies;
+ request.mockImplementation((operation) => {
+ if (operation === "replies")
+ return new Promise((resolve) => {
+ resolveReplies = resolve;
+ });
+ return Promise.resolve(created);
+ });
+ root.querySelector("[data-action='load-replies']").click();
+ root.querySelector("[data-action='reply']").click();
+ submit(root.querySelector(".ryd-discussion-inline-editor form"), created.text);
+ await flush();
+ expect(root.querySelector(`[data-comment-id='${created.id}']`)).not.toBeNull();
+ resolveReplies({ items: [previous], nextCursor: null });
+ await flush();
+ expect([...root.querySelectorAll(".ryd-discussion-reply")].map((row) => row.dataset.commentId)).toEqual([
+ REPLY,
+ created.id,
+ ]);
+ expect(thread.replyCount).toBe(2);
+});
+
+test("a reply to an unopened thread remains visible and lets a failed history fetch be retried", async () => {
+ const root = await mount({ session }, [comment({ replyCount: 0 })]);
+ const previous = comment({ id: REPLY, parentId: ROOT, replyToId: ROOT, text: "Earlier reply" });
+ const created = comment({
+ id: "55555555-5555-4555-8555-555555555555",
+ parentId: ROOT,
+ replyToId: ROOT,
+ text: "New reply",
+ createdAt: "2026-09-01T12:01:00Z",
+ });
+ request.mockResolvedValueOnce(created).mockRejectedValueOnce({ code: "unavailable" });
+ root.querySelector("[data-action='reply']").click();
+ submit(root.querySelector(".ryd-discussion-inline-editor form"), created.text);
+ await flush();
+ expect(root.querySelector(`[data-comment-id='${created.id}']`)).not.toBeNull();
+ const showReplies = root.querySelector("[data-action='load-replies']");
+ expect(showReplies.hidden).toBe(false);
+ expect(showReplies.disabled).toBe(false);
+ request.mockResolvedValueOnce({ items: [previous, created], nextCursor: null });
+ showReplies.click();
+ await flush();
+ expect([...root.querySelectorAll(".ryd-discussion-reply")].map((row) => row.dataset.commentId)).toEqual([
+ REPLY,
+ created.id,
+ ]);
+ expect(showReplies.hidden).toBe(true);
+});
+
+test("posting fetches unopened history even when the root count predates another user's reply", async () => {
+ const thread = comment();
+ const root = await mount({ session }, [thread]);
+ const previous = comment({
+ id: "55555555-5555-4555-8555-555555555555",
+ parentId: ROOT,
+ replyToId: ROOT,
+ text: "Another user's reply",
+ createdAt: "2026-09-01T12:00:00Z",
+ });
+ const created = comment({ id: REPLY, parentId: ROOT, replyToId: ROOT, text: "First reply" });
+ request.mockResolvedValueOnce(created).mockResolvedValueOnce({ items: [previous, created], nextCursor: null });
+ root.querySelector("[data-action='reply']").click();
+ submit(root.querySelector(".ryd-discussion-inline-editor form"), created.text);
+ await flush();
+ expect(root.querySelector(`[data-comment-id='${REPLY}']`)).not.toBeNull();
+ expect(root.querySelector(`[data-comment-id='${previous.id}']`)).not.toBeNull();
+ expect(request).toHaveBeenLastCalledWith("replies", { rootId: ROOT });
+ expect(thread.replyCount).toBe(2);
+});
+
+test("top/new sorting discards the prior pagination cursor", async () => {
+ const root = await mount();
+ const sort = root.querySelector("select");
+ sort.value = "new";
+ sort.dispatchEvent(new Event("change"));
+ await flush();
+ expect(request).toHaveBeenLastCalledWith("comments", { videoId: VIDEO, sort: "new" });
+});
+
+test("an obsolete response cannot render after panel destruction", async () => {
+ const root = await mount();
+ let resolve;
+ request.mockImplementationOnce(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ const loading = panel.load();
+ panel.destroy();
+ resolve({ items: [comment({ text: "Late response" })] });
+ await loading;
+ expect(root.textContent).not.toContain("Late response");
+ expect(document.getElementById("ryd-discussion-panel")).toBeNull();
+});
+
+test("moderators can review reports and issue explicit scoped actions", async () => {
+ const root = await mount({ session: { ...session, isModerator: true } });
+ request.mockResolvedValueOnce({
+ items: [{ id: REPLY, comment: comment(), reason: "spam", authorSuspended: false }],
+ nextCursor: null,
+ });
+ root.querySelector("[data-action='moderation']").click();
+ await flush();
+ request.mockResolvedValueOnce(comment({ hidden: true }));
+ root.querySelector("[data-action='hide']").click();
+ await flush();
+ expect(request.mock.calls).toContainEqual(["moderateComment", { id: ROOT, hidden: true }]);
+});
+
+test("Russian UI follows the YouTube document language", async () => {
+ document.documentElement.lang = "ru-RU";
+ const root = await mount({ session });
+ expect(root.querySelector("h2").textContent).toBe("Комментарии RYD");
+ expect(root.querySelector("[data-action='post']").textContent).toBe("Отправить");
+ expect(root.querySelector("[data-reaction='1']").getAttribute("aria-label")).toContain("Нравится");
+});
+
+test("moderators can restore comments and authoring from dismissed report history", async () => {
+ const root = await mount({ session: { ...session, isModerator: true } });
+ request.mockImplementation(async (operation, payload) => {
+ if (operation === "reports" && payload.status === "resolved")
+ return { items: [{ id: REPLY, comment: comment({ hidden: true }), reason: "spam", authorSuspended: true }] };
+ return { items: [] };
+ });
+ root.querySelector("[data-action='moderation']").click();
+ await flush();
+ const filter = root.querySelector("[data-action='report-status']");
+ filter.value = "resolved";
+ filter.dispatchEvent(new Event("change"));
+ await flush();
+ expect(root.querySelector("[data-action='dismiss']")).toBeNull();
+ expect(root.querySelector("[data-action='unban']")).not.toBeNull();
+ root.querySelector("[data-action='restore']").click();
+ await flush();
+ expect(request.mock.calls).toContainEqual(["moderateComment", { id: ROOT, hidden: false }]);
+ expect(root.querySelector("[data-action='report-status']").value).toBe("resolved");
+});
diff --git a/Extensions/combined/src/discussion/popup.js b/Extensions/combined/src/discussion/popup.js
new file mode 100644
index 0000000..4c3cf51
--- /dev/null
+++ b/Extensions/combined/src/discussion/popup.js
@@ -0,0 +1,54 @@
+import { requestDiscussion, subscribeDiscussionEvents } from "./client";
+import { createDiscussionInbox } from "./inbox";
+import { discussionText as t } from "./localize";
+
+export function initDiscussionPopup() {
+ const root = document.getElementById("ryd-discussion-popup");
+ if (!root) return;
+ if (!document.documentElement.lang && chrome.i18n?.getUILanguage)
+ document.documentElement.lang = chrome.i18n.getUILanguage();
+ const toggle = document.getElementById("ryd-discussion-inbox-toggle");
+ const inbox = createDiscussionInbox({ authenticated: false });
+ root.append(inbox.node);
+ inbox.node.hidden = true;
+ toggle.textContent = t("notifications");
+ toggle.addEventListener("click", () => {
+ inbox.node.hidden = !inbox.node.hidden;
+ toggle.setAttribute("aria-expanded", String(!inbox.node.hidden));
+ if (!inbox.node.hidden) inbox.load();
+ });
+ let generation = 0;
+ const unsubscribe = subscribeDiscussionEvents((event) => {
+ if (event.type === "session") {
+ generation++;
+ inbox.setSession(event.data);
+ }
+ if (event.type === "inbox") {
+ inbox.update(event.data, { live: true });
+ toggle.textContent = `${t("notifications")}${event.data?.unreadCount ? ` (${event.data.unreadCount})` : ""}`;
+ }
+ });
+ const initial = generation;
+ requestDiscussion("session")
+ .then((session) => {
+ if (initial !== generation) return;
+ inbox.setSession(session);
+ if (session.authenticated)
+ requestDiscussion("notifications")
+ .then((value) => {
+ if (initial !== generation) return;
+ inbox.update(value);
+ toggle.textContent = `${t("notifications")}${value?.unreadCount ? ` (${value.unreadCount})` : ""}`;
+ })
+ .catch(() => {});
+ })
+ .catch(() => {});
+ window.addEventListener(
+ "unload",
+ () => {
+ inbox.destroy();
+ unsubscribe();
+ },
+ { once: true },
+ );
+}
diff --git a/Extensions/common/vote-client.js b/Extensions/common/vote-client.js
index fce7e2e..5a00811 100644
--- a/Extensions/common/vote-client.js
+++ b/Extensions/common/vote-client.js
@@ -311,7 +311,12 @@ function createVoteClient({
return current;
}
- return { ensureRegistered, submitVote };
+ async function getRegisteredUser() {
+ const credential = await credentialStore.load();
+ return isConfirmedCredential(credential) ? { userId: credential.userId } : null;
+ }
+
+ return { ensureRegistered, getRegisteredUser, submitVote };
}
export { DEFAULT_API_BASE_URL, VoteClientError, countLeadingZeroes, generateUserId, solvePuzzle, createVoteClient };
diff --git a/Extensions/common/vote-client.spec.js b/Extensions/common/vote-client.spec.js
index 94a1e0a..b34a63b 100644
--- a/Extensions/common/vote-client.spec.js
+++ b/Extensions/common/vote-client.spec.js
@@ -8,6 +8,28 @@ const CHALLENGE = Buffer.alloc(16, 7).toString("base64");
const ZERO_SOLUTION = "AAAAAA==";
const GENERATED_USER_ID = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
+test.each([null, { userId: GENERATED_USER_ID, registrationConfirmed: false }])(
+ "reading an existing voter does not create or register an identity: %s",
+ async (credentials) => {
+ const store = createCredentialStore(credentials);
+ const fetchImpl = jest.fn();
+ const cryptoImpl = createCryptoImpl();
+ const client = createClient({ credentialStore: store, fetchImpl, cryptoImpl });
+ expect(await client.getRegisteredUser()).toBe(null);
+ expect(fetchImpl).not.toHaveBeenCalled();
+ expect(store.save).not.toHaveBeenCalled();
+ expect(cryptoImpl.getRandomValues).not.toHaveBeenCalled();
+ },
+);
+
+test("reads a confirmed existing voter without registration", async () => {
+ const store = createCredentialStore({ userId: GENERATED_USER_ID, registrationConfirmed: true });
+ const fetchImpl = jest.fn();
+ const client = createClient({ credentialStore: store, fetchImpl });
+ expect(await client.getRegisteredUser()).toEqual({ userId: GENERATED_USER_ID });
+ expect(fetchImpl).not.toHaveBeenCalled();
+});
+
function jsonResponse(body, status = 200) {
return {
ok: status >= 200 && status < 300,
diff --git a/Extensions/e2e/discussion/README.md b/Extensions/e2e/discussion/README.md
new file mode 100644
index 0000000..1e11b5a
--- /dev/null
+++ b/Extensions/e2e/discussion/README.md
@@ -0,0 +1,51 @@
+# Discussion browser validation
+
+`hermetic-discussion-smoke.js` loads the generated MV3 extension through the shared
+`HermeticExtensionArtifactAdapter` in a new, disposable headless Chromium profile.
+It serves fixture documents at YouTube URLs and uses the loopback discussion host
+at `http://127.0.0.1:5099`. It never attaches to an existing browser profile.
+
+Start the repository's local discussion fixture host, then build a dedicated artifact:
+
+```powershell
+node node_modules/webpack-cli/bin/cli.js --mode=production --env liveTest=true --env discussionTestOrigin=http://127.0.0.1:5099 --output-path test-results/discussion/generated
+node Extensions/e2e/discussion/hermetic-discussion-smoke.js
+```
+
+Use Node 22.17.0. `RYD_DISCUSSION_ARTIFACT` can select another generated local-test
+Chrome artifact; `RYD_DISCUSSION_OUTPUT` can select a fresh evidence directory.
+The default output creates a new timestamped directory under `test-results/discussion`.
+Set `RYD_DISCUSSION_NOTIFICATIONS_ONLY=true` for a focused notification/reconnect
+run with only the required rendered-panel, nickname, and comment prerequisites.
+Between reply and like delivery, that run closes the actual worker WebSocket,
+asserts its native close event, and requires a new open connection with a different
+server ticket. Only the ticket-change boolean is recorded; ticket values are never
+returned from the worker or saved in evidence.
+
+The smoke asserts English and Russian watch and Shorts rendering, nickname updates,
+comment and reply creation, public reactions through like/dislike/removal, author
+controls after account switches, and premium/supporter/inactive posting gates.
+It checks light and dark fallback styles without native YouTube color variables,
+a 390-pixel viewport, Shorts Escape/Enter handling, enabled native comments exclusion,
+explicit current-video kids exclusion, and continued rendering when only unrelated
+recommendations or advertisements have kids flags.
+
+It also exercises report submission, moderator hide/ban/dismiss, resolved report
+history, and restoration/unbanning. Incoming replies and likes must produce a real
+worker WebSocket `NotificationsChanged` frame and a corresponding runtime inbox
+event before the runner explicitly reads the inbox. A test-only WebSocket observer
+forwards the native constructor unchanged and records only connection sequence,
+event target, and revision. Inbox UI mark-read is verified against the local API.
+
+All account changes use seeded fixture identities in dedicated discussion test keys.
+Created comments and replies are deleted after the attempt. The fixture host keeps
+its local audit history and chosen fixture nicknames. The existing adapter redirects
+legacy extension API traffic to its fake server, blocks external name resolution
+before browser startup, and cleans up its temporary artifact and profile. The
+compiled discussion runtime continues to use its explicitly configured local API.
+
+Each attempt records a sanitized JSON result, operation ledger, and 36 screenshots.
+The report labels this evidence as hermetic. It does not prove behavior against live
+YouTube, live OAuth accounts, actual YouTube language switching, cross-tab toast
+deduplication, Firefox, or Safari. Use the separate `live-discussion-contract.js`
+only for an authorized live session and its specified local mutation boundary.
diff --git a/Extensions/e2e/discussion/hermetic-discussion-smoke.js b/Extensions/e2e/discussion/hermetic-discussion-smoke.js
new file mode 100644
index 0000000..c3bbf1c
--- /dev/null
+++ b/Extensions/e2e/discussion/hermetic-discussion-smoke.js
@@ -0,0 +1,712 @@
+const assert = require("node:assert/strict");
+const crypto = require("node:crypto");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { expect } = require("@playwright/test");
+const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke");
+const { bounded, localClient, poll, solve, TIMEOUT_MS } = require("./live-discussion-contract");
+
+const REPOSITORY = path.resolve(__dirname, "../../..");
+const ORIGIN = "http://127.0.0.1:5099";
+const LANGUAGES = ["en", "ru"];
+const SURFACES = ["watch", "shorts"];
+
+function fixture({ language, surface, videoId, disabled = true, kids = false, dark = true }) {
+ assert.ok(LANGUAGES.includes(language) && SURFACES.includes(surface));
+ assert.match(videoId, /^[A-Za-z0-9_-]{11}$/);
+ const options = JSON.stringify({ language, surface, videoId, disabled, kids });
+ return `
+
+ Discussion artifact fixture`;
+}
+
+async function runHermeticDiscussionSmoke({
+ artifactDirectory = process.env.RYD_DISCUSSION_ARTIFACT ||
+ path.join(REPOSITORY, "test-results/discussion/generated/chrome"),
+ outputDirectory = process.env.RYD_DISCUSSION_OUTPUT ||
+ path.join(REPOSITORY, `test-results/discussion/hermetic-smoke-${Date.now()}`),
+ notificationFocus = process.env.RYD_DISCUSSION_NOTIFICATIONS_ONLY === "true",
+} = {}) {
+ const healthResponse = await fetch(`${ORIGIN}/api/discussion/local/health`, {
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ assert.equal(healthResponse.status, 200);
+ assert.equal((await healthResponse.json()).localTestMode, true, "Local discussion host is required");
+ const marker = JSON.parse(await fs.readFile(path.join(artifactDirectory, "live-build.json"), "utf8"));
+ assert.match(marker.buildId, /^[a-f0-9]{32}$/);
+ const background = await fs.readFile(path.join(artifactDirectory, "ryd.background.js"), "utf8");
+ assert.ok(background.includes(`const testOrigin = true ? "${ORIGIN}"`), "Requires an explicit local-test build");
+ await fs.mkdir(outputDirectory, { recursive: true });
+ const seedResponse = await fetch(`${ORIGIN}/api/discussion/local/seed`, {
+ method: "POST",
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ assert.equal(seedResponse.status, 200);
+ const { accounts } = await seedResponse.json();
+ const ledger = [];
+ const request = localClient(ORIGIN, accounts, (value) => ledger.push(value));
+ const results = [];
+ const screenshots = [];
+ const errors = [];
+ const created = [];
+ const profilesToRestore = new Set();
+ const commentsToRestore = new Set();
+ const record = (name, detail = {}) => {
+ results.push({ name, passed: true, ...detail });
+ process.stdout.write(`PASS ${name}\n`);
+ };
+ const apiServer = await startHermeticApiServer();
+ const adapter = new HermeticExtensionArtifactAdapter({
+ apiServer,
+ artifactDirectory,
+ headless: true,
+ contextOptions: { viewport: { width: 1200, height: 900 } },
+ });
+ let currentFixture;
+ let popup;
+ let failure;
+ try {
+ await adapter.start();
+ const { context, page, worker } = adapter;
+ context.setDefaultTimeout(TIMEOUT_MS);
+ context.setDefaultNavigationTimeout(TIMEOUT_MS);
+ page.on("pageerror", (error) => errors.push({ name: error.name, message: error.message.slice(0, 200) }));
+ await worker.evaluate(() => {
+ const NativeWebSocket = globalThis.WebSocket;
+ const probe = {
+ sequence: 0,
+ connection: 0,
+ ticketChanges: 0,
+ events: [],
+ closed: [],
+ ready: new Set(),
+ sockets: new Map(),
+ };
+ let previousTicket = null;
+ globalThis.__rydDiscussionSocketProbe = probe;
+ globalThis.WebSocket = class extends NativeWebSocket {
+ constructor(...args) {
+ super(...args);
+ const url = new URL(args[0]);
+ if (url.origin !== "ws://127.0.0.1:5099" || url.pathname !== "/api/discussion/notifications/hub") return;
+ const connection = ++probe.connection;
+ const ticket = url.searchParams.get("access_token");
+ if (previousTicket !== null && ticket && ticket !== previousTicket) probe.ticketChanges++;
+ previousTicket = ticket;
+ probe.sockets.set(connection, this);
+ this.addEventListener("message", (event) => {
+ for (const packet of String(event.data).split("\u001e")) {
+ let message;
+ try {
+ message = JSON.parse(packet);
+ } catch {
+ continue;
+ }
+ if (message && Object.keys(message).length === 0) probe.ready.add(connection);
+ if (message.target !== "NotificationsChanged" || !/^\d+$/.test(message.arguments?.[0]?.revision))
+ continue;
+ probe.events.push({
+ sequence: ++probe.sequence,
+ connection,
+ target: message.target,
+ revision: message.arguments[0].revision,
+ });
+ if (probe.events.length > 100) probe.events.shift();
+ }
+ });
+ this.addEventListener("close", (event) => {
+ probe.closed.push({ connection, code: event.code });
+ probe.ready.delete(connection);
+ probe.sockets.delete(connection);
+ });
+ }
+ };
+ });
+ await context.route(`${ORIGIN}/**`, (route) => route.continue());
+ await context.route("https://www.youtube.com/**", (route) => {
+ const url = new URL(route.request().url());
+ if (
+ route.request().resourceType() === "document" &&
+ ["/watch", `/shorts/${currentFixture?.videoId}`].includes(url.pathname)
+ )
+ return route.fulfill({ contentType: "text/html", body: fixture(currentFixture) });
+ return route.fulfill({ status: 404, body: "Fixture resource absent" });
+ });
+ popup = await context.newPage();
+ await popup.goto(`chrome-extension://${adapter.extensionId}/popup.html`, { waitUntil: "domcontentloaded" });
+ await popup.evaluate(() => {
+ const probe = { sequence: 0, events: [] };
+ globalThis.__rydDiscussionInboxProbe = probe;
+ chrome.runtime.onMessage.addListener((message, sender) => {
+ if (sender.id !== chrome.runtime.id || message.message !== "ryd_discussion_event" || message.type !== "inbox")
+ return;
+ probe.events.push({
+ sequence: ++probe.sequence,
+ revision: message.data.revision,
+ items: (message.data.items || []).map((item) => ({
+ id: item.id,
+ commentId: item.commentId,
+ type: item.type,
+ read: item.read,
+ revision: item.revision,
+ })),
+ });
+ if (probe.events.length > 100) probe.events.shift();
+ });
+ });
+ const operation = (name, payload = {}) =>
+ bounded(
+ () =>
+ popup.evaluate(
+ ({ name, payload }) => chrome.runtime.sendMessage({ message: "ryd_discussion", operation: name, payload }),
+ { name, payload },
+ ),
+ `Worker ${name}`,
+ );
+ async function setAccount(name) {
+ let expectedProfile = null;
+ if (name) {
+ const session = await request("/me", { account: name });
+ expectedProfile = session.profile?.id || null;
+ }
+ await bounded(
+ () =>
+ worker.evaluate(
+ ({ account, voter }) =>
+ chrome.storage.local.set({
+ rydDiscussionTestAuth: account ? { sessionToken: account.sessionToken } : null,
+ rydDiscussionTestCredentials: { userId: voter, registrationConfirmed: true },
+ }),
+ { account: name ? accounts[name] : null, voter: accounts.commenter.voterId },
+ ),
+ "Set fixture account",
+ );
+ const result = await poll(
+ () => operation("session"),
+ (value) =>
+ value.ok &&
+ (name
+ ? value.data.authenticated &&
+ (value.data.profile?.id || null) === expectedProfile &&
+ value.data.membershipTier ===
+ {
+ commenter: "commenter",
+ other: "commenter",
+ premium: "premium",
+ moderator: "premium",
+ supporter: "supporter",
+ inactive: "none",
+ }[name]
+ : !value.data.authenticated),
+ "Fixture session did not converge",
+ );
+ const refresh = page.locator(".ryd-discussion-thread > .ryd-discussion-toolbar > button");
+ if (await refresh.count()) await expect(refresh).toBeEnabled();
+ return result;
+ }
+ const socketSnapshot = () =>
+ bounded(
+ () =>
+ worker.evaluate(() => {
+ const probe = globalThis.__rydDiscussionSocketProbe;
+ return {
+ connection: probe.connection,
+ ticketChanges: probe.ticketChanges,
+ closed: probe.closed,
+ ready: [...probe.ready],
+ events: probe.events,
+ open: [...probe.sockets].filter(([, socket]) => socket.readyState === 1).map(([id]) => id),
+ };
+ }),
+ "Observe native SignalR frames",
+ );
+ const inboxSnapshot = () =>
+ bounded(() => popup.evaluate(() => globalThis.__rydDiscussionInboxProbe), "Observe runtime inbox events");
+ const root = () => page.locator("#ryd-discussion-panel, #ryd-discussion-drawer").filter({ visible: true });
+ const row = (id) => root().locator(`[data-comment-id="${id}"]`);
+ const composer = () => root().locator(".ryd-discussion-composer");
+ async function open(options) {
+ currentFixture = options;
+ await page.goto(
+ `https://www.youtube.com/${options.surface === "watch" ? `watch?v=${options.videoId}` : `shorts/${options.videoId}`}`,
+ { waitUntil: "domcontentloaded" },
+ );
+ await expect(page.locator("html")).toHaveAttribute("data-ryd-extension-build", marker.buildId);
+ if (options.kids || options.disabled === false) return;
+ if (options.surface === "shorts") await page.locator("#ryd-discussion-launcher").click();
+ await expect(root()).toHaveCount(1);
+ await expect(root()).toHaveAttribute("data-video-id", options.videoId);
+ }
+ async function screenshot(name) {
+ const file = `${name}.png`;
+ await page.screenshot({ path: path.join(outputDirectory, file), timeout: TIMEOUT_MS });
+ screenshots.push(file);
+ }
+ async function narrowScreenshot(name) {
+ const viewport = page.viewportSize();
+ try {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const box = await root().boundingBox();
+ assert.ok(box && box.x >= -1 && box.x + box.width <= 391, "Narrow activity panel overflows viewport");
+ await screenshot(`${name}-narrow`);
+ } finally {
+ await page.setViewportSize(viewport);
+ }
+ }
+ async function ownNickname(language, surface) {
+ if (await composer().locator(".ryd-discussion-account button").count())
+ await composer().locator(".ryd-discussion-account button").click();
+ const nickname = `Artifact ${language} ${surface} ${crypto.randomBytes(3).toString("hex")}`;
+ await composer().locator('input[name="nickname"]').fill(nickname);
+ await composer().locator('button[type="submit"]').click();
+ await expect(composer().locator(".ryd-discussion-account")).toContainText(nickname);
+ assert.equal((await request("/me", { account: "commenter" })).profile.nickname, nickname);
+ return nickname;
+ }
+ async function createRoot(videoId, text) {
+ await composer().locator("textarea").fill(text);
+ await composer().locator('[data-action="post"]').click();
+ const value = await poll(
+ () => request(`/videos/${videoId}/comments?sort=new`),
+ (list) => list.items.some((item) => item.text === text),
+ "Created comment absent from local API",
+ );
+ const comment = value.items.find((item) => item.text === text);
+ created.push({ id: comment.id, account: "commenter" });
+ await expect(row(comment.id)).toBeVisible();
+ return comment;
+ }
+ async function assertFallback(dark) {
+ await page.evaluate((value) => {
+ document.documentElement.toggleAttribute("dark", value);
+ document.body.style.backgroundColor = value ? "#0f0f0f" : "#fff";
+ document.body.style.color = value ? "#f1f1f1" : "#0f0f0f";
+ }, dark);
+ const colors = await root().evaluate((node) => {
+ const input = node.querySelector("textarea");
+ return {
+ text: getComputedStyle(node).color,
+ input: getComputedStyle(input).backgroundColor,
+ inputText: getComputedStyle(input).color,
+ nativeVariable: getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-primary"),
+ };
+ });
+ assert.equal(colors.nativeVariable, "");
+ assert.equal(colors.text, dark ? "rgb(241, 241, 241)" : "rgb(15, 15, 15)");
+ assert.notEqual(colors.input, colors.inputText);
+ return colors;
+ }
+
+ async function ensureProfile(account) {
+ const session = await request("/me", { account });
+ if (!session.profile?.nickname)
+ await request("/me/profile", {
+ account,
+ method: "PUT",
+ body: { nickname: `Artifact ${account} ${crypto.randomBytes(3).toString("hex")}` },
+ });
+ }
+
+ async function moderation(comment, label) {
+ await ensureProfile("other");
+ await ensureProfile("moderator");
+ await setAccount("other");
+ await row(comment.id).locator(':scope > .ryd-discussion-comment-actions [data-action="report"]').click();
+ await row(comment.id)
+ .locator(":scope > .ryd-discussion-inline-editor textarea")
+ .fill(`Artifact moderation ${label}`);
+ await row(comment.id).locator(':scope > .ryd-discussion-inline-editor [data-action="send-report"]').click();
+ const reports = await poll(
+ () => request("/moderation/reports", { account: "moderator" }),
+ (value) => value.items.some((item) => item.comment.id === comment.id),
+ "Report absent from moderator queue",
+ );
+ const reportId = reports.items.find((item) => item.comment.id === comment.id).id;
+ await setAccount("moderator");
+ await root().locator('[data-action="moderation"]').click();
+ const report = () => root().locator(`[data-report-id="${reportId}"]`);
+ await expect(report()).toBeVisible();
+ commentsToRestore.add(comment.id);
+ await report().locator('[data-action="hide"]').click();
+ await expect(report().locator('[data-action="restore"]')).toBeVisible();
+ assert.equal((await request(`/comments/${comment.id}`)).hidden, true);
+ profilesToRestore.add(comment.author.id);
+ await report().locator('[data-action="ban"]').click();
+ await expect(report().locator('[data-action="unban"]')).toBeVisible();
+ assert.equal((await request("/me", { account: "commenter" })).profile.suspended, true);
+ await report().locator('[data-action="dismiss"]').click();
+ await expect(report()).toHaveCount(0);
+ await root().locator('[data-action="report-status"]').selectOption("resolved");
+ await expect(report()).toBeVisible();
+ await expect(report().locator('[data-action="dismiss"]')).toHaveCount(0);
+ await screenshot(`${label}-moderation-history`);
+ await narrowScreenshot(`${label}-moderation-history`);
+ await report().locator('[data-action="restore"]').click();
+ await expect(report().locator('[data-action="hide"]')).toBeVisible();
+ await report().locator('[data-action="unban"]').click();
+ await expect(report().locator('[data-action="ban"]')).toBeVisible();
+ assert.equal((await request(`/comments/${comment.id}`)).hidden, false);
+ assert.equal((await request("/me", { account: "commenter" })).profile.suspended, false);
+ commentsToRestore.delete(comment.id);
+ profilesToRestore.delete(comment.author.id);
+ record(`${label}-report-hide-ban-dismiss-history-restore-unban`, { reportId });
+ }
+
+ async function notifications(comment, label, catalog) {
+ const previousConnection = (await socketSnapshot()).connection;
+ await setAccount("commenter");
+ await root().locator('[data-action="discussion"]').click();
+ await poll(
+ socketSnapshot,
+ (value) => value.open.some((id) => id > previousConnection),
+ "Current fixture SignalR socket did not open",
+ );
+ assert.equal((await operation("readNotifications", { all: true })).ok, true);
+ await page.bringToFront();
+ for (const type of ["reply", "likes"]) {
+ const wireBaseline = await socketSnapshot();
+ const inboxBaseline = await inboxSnapshot();
+ let targetId = comment.id;
+ if (type === "reply") {
+ const reply = await request(`/videos/${comment.videoId}/comments`, {
+ account: "other",
+ method: "POST",
+ body: {
+ text: `Incoming artifact reply ${label} ${crypto.randomUUID()}`,
+ parentId: comment.id,
+ replyToId: comment.id,
+ requestId: crypto.randomUUID(),
+ },
+ });
+ targetId = reply.id;
+ created.push({ id: reply.id, account: "other" });
+ } else {
+ const userId = accounts.other.voterId;
+ const challenge = await request(`/comments/${comment.id}/reaction/challenge`, {
+ method: "POST",
+ body: { userId, value: 1 },
+ });
+ const confirmed = await request(`/comments/${comment.id}/reaction/confirm`, {
+ account: "other",
+ method: "POST",
+ body: {
+ userId,
+ challengeId: challenge.challengeId,
+ solution: solve(challenge.challenge, challenge.difficulty),
+ },
+ });
+ assert.equal(confirmed.myReaction, 1);
+ }
+ const wireSequence = wireBaseline.events.at(-1)?.sequence || 0;
+ await poll(
+ socketSnapshot,
+ (value) =>
+ value.events.some((event) => event.sequence > wireSequence && wireBaseline.open.includes(event.connection)),
+ "No incoming worker SignalR frame for fixture activity",
+ );
+ const observed = await poll(
+ inboxSnapshot,
+ (value) =>
+ value.events.some(
+ (event) =>
+ event.sequence > inboxBaseline.sequence &&
+ event.items.some((item) => item.commentId === targetId && item.type === type && !item.read),
+ ),
+ "No runtime inbox event for the new activity",
+ );
+ const envelope = observed.events.find(
+ (event) =>
+ event.sequence > inboxBaseline.sequence &&
+ event.items.some((item) => item.commentId === targetId && item.type === type && !item.read),
+ );
+ const notification = envelope.items.find(
+ (item) => item.commentId === targetId && item.type === type && !item.read,
+ );
+ const wire = (await socketSnapshot()).events.find(
+ (event) =>
+ event.sequence > wireSequence &&
+ wireBaseline.open.includes(event.connection) &&
+ BigInt(event.revision) >= BigInt(notification.revision),
+ );
+ assert.ok(wire, "Runtime inbox revision has no matching observed SignalR frame");
+ record(`${label}-${type}-native-signalr-and-runtime-inbox`, {
+ wire,
+ notificationId: notification.id,
+ revision: notification.revision,
+ });
+ await root().locator('[data-action="inbox"]').click();
+ const notificationRow = root().locator(`[data-notification-id="${notification.id}"]`);
+ await expect(notificationRow).toBeVisible();
+ if (type === "likes") await expect(notificationRow).toContainText(catalog.discussion_notificationLike.message);
+ await screenshot(`${label}-${type}-inbox`);
+ await narrowScreenshot(`${label}-${type}-inbox`);
+ await notificationRow.locator("button").click();
+ await expect(notificationRow).not.toHaveClass(/is-unread/);
+ await poll(
+ () => request("/notifications", { account: "commenter" }),
+ (value) => value.items.find((item) => item.id === notification.id)?.read === true,
+ "Inbox mark-read did not persist",
+ );
+ record(`${label}-${type}-inbox-ui-and-mark-read`);
+ await root().locator('[data-action="discussion"]').click();
+ if (notificationFocus && type === "reply") {
+ const before = await socketSnapshot();
+ await bounded(
+ () =>
+ worker.evaluate(() => {
+ for (const socket of globalThis.__rydDiscussionSocketProbe.sockets.values()) {
+ if (socket.readyState === 1) socket.close(4000, "Fixture reconnect");
+ }
+ }),
+ "Close native fixture socket",
+ );
+ const reconnected = await poll(
+ socketSnapshot,
+ (value) =>
+ value.closed.some((entry) => before.open.includes(entry.connection)) &&
+ value.open.some((id) => id > before.connection && value.ready.includes(id)) &&
+ value.ticketChanges > before.ticketChanges,
+ "Worker did not reconnect with a fresh ticket after native socket closure",
+ );
+ record(`${label}-native-socket-close-reconnect-fresh-ticket`, {
+ closedConnections: reconnected.closed.filter((entry) => before.open.includes(entry.connection)),
+ newConnections: reconnected.open.filter((id) => id > before.connection),
+ freshTicket: true,
+ });
+ }
+ }
+ }
+
+ await setAccount(null);
+ for (const language of LANGUAGES) {
+ const catalog = JSON.parse(
+ await fs.readFile(path.join(artifactDirectory, `_locales/${language}/messages.json`), "utf8"),
+ );
+ for (const surface of SURFACES) {
+ const videoId = `H${crypto.randomBytes(8).toString("base64url").slice(0, 10)}`;
+ const label = `${language}-${surface}`;
+ await setAccount(null);
+ await open({ language, surface, videoId, disabled: true, kids: false, dark: true });
+ await expect(root().locator("h2")).toHaveText(catalog.discussion_title.message);
+ await expect(composer().locator("textarea")).toHaveCount(0);
+ await expect(composer()).toContainText(catalog.discussion_gate.message);
+ record(`${label}-anonymous-unknown-kids-unrelated-kids-render`);
+
+ await setAccount("commenter");
+ const nickname = await ownNickname(language, surface);
+ const comment = await createRoot(videoId, `Artifact comment ${label} ${crypto.randomUUID()}`);
+ assert.equal(comment.author.nickname, nickname);
+ record(`${label}-nickname-and-create`);
+
+ if (notificationFocus) {
+ await ensureProfile("other");
+ await setAccount("other");
+ await notifications(comment, label, catalog);
+ continue;
+ }
+
+ await row(comment.id).locator(':scope > .ryd-discussion-comment-actions [data-action="reply"]').click();
+ const replyText = `Artifact reply ${label} ${crypto.randomUUID()}`;
+ await row(comment.id).locator(":scope > .ryd-discussion-inline-editor textarea").fill(replyText);
+ await row(comment.id).locator(':scope > .ryd-discussion-inline-editor [data-action="post"]').click();
+ const replies = await poll(
+ () => request(`/comments/${comment.id}/replies`),
+ (value) => value.items.some((item) => item.text === replyText),
+ "Reply absent from local API",
+ );
+ const reply = replies.items.find((item) => item.text === replyText);
+ created.push({ id: reply.id, account: "commenter" });
+ assert.equal(reply.parentId, comment.id);
+ await expect(row(reply.id)).toBeVisible();
+ record(`${label}-reply`);
+
+ const darkColors = await assertFallback(true);
+ await screenshot(`${label}-dark`);
+ const lightColors = await assertFallback(false);
+ await screenshot(`${label}-light`);
+ record(`${label}-theme-fallback`, { darkColors, lightColors });
+ await assertFallback(true);
+
+ for (const account of ["premium", "supporter", "inactive"]) {
+ await setAccount(account);
+ if (account === "premium")
+ await expect(composer().locator("textarea, input[name='nickname']")).toHaveCount(1);
+ else await expect(composer().locator("textarea, input[name='nickname']")).toHaveCount(0);
+ record(`${label}-${account}-gate`);
+ }
+ await setAccount(null);
+ await expect(composer().locator("textarea")).toHaveCount(0);
+ await expect(
+ row(comment.id).locator(':scope > .ryd-discussion-comment-actions [data-action="edit"]'),
+ ).toHaveCount(0);
+ for (const [value, expectedLike, expectedDislike] of [
+ [1, 1, 0],
+ [-1, 0, 1],
+ [-1, 0, 0],
+ ]) {
+ const reaction = row(comment.id).locator(
+ `:scope > .ryd-discussion-comment-actions [data-reaction="${value}"]`,
+ );
+ await expect(reaction).toBeEnabled();
+ await reaction.click();
+ await poll(
+ () => request(`/comments/${comment.id}`),
+ (item) => item.likes === expectedLike && item.dislikes === expectedDislike,
+ "Anonymous reaction count did not converge",
+ );
+ await expect(reaction).toBeEnabled();
+ await expect(reaction).toHaveAttribute(
+ "aria-pressed",
+ String((value === 1 ? expectedLike : expectedDislike) === 1),
+ );
+ }
+ record(`${label}-anonymous-like-dislike-remove`);
+
+ await setAccount("commenter");
+ await expect(composer().locator("textarea")).toHaveCount(1);
+ await expect(
+ row(comment.id).locator(':scope > .ryd-discussion-comment-actions [data-action="edit"]'),
+ ).toHaveCount(1);
+ await page.setViewportSize({ width: 390, height: 844 });
+ const box = await root().boundingBox();
+ assert.ok(box && box.x >= -1 && box.x + box.width <= 391, "Panel overflows narrow viewport");
+ await screenshot(`${label}-narrow`);
+ await page.setViewportSize({ width: 1200, height: 900 });
+ if (surface === "shorts") {
+ await root().locator("textarea").first().focus();
+ await page.keyboard.press("Escape");
+ await expect(page.locator("#ryd-discussion-drawer")).toBeHidden();
+ await expect(page.locator("#ryd-discussion-launcher")).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(root()).toBeVisible();
+ }
+ record(`${label}-responsive-and-owner-restoration`);
+
+ await moderation(comment, label);
+ await notifications(comment, label, catalog);
+
+ await page.evaluate(() => window.__discussionFixture.setKids(true));
+ await expect(
+ page.locator("#ryd-discussion-panel, #ryd-discussion-launcher, #ryd-discussion-drawer"),
+ ).toHaveCount(0);
+ await page.evaluate(() => window.__discussionFixture.setKids(false));
+ if (surface === "shorts")
+ await expect(page.locator("#ryd-discussion-launcher")).toHaveAttribute("aria-expanded", "true");
+ await expect(root()).toHaveCount(1);
+ record(`${label}-positive-kids-excluded-unknown-restored`);
+
+ await open({ language, surface, videoId, disabled: false, kids: false, dark: true });
+ await page.waitForTimeout(300);
+ await expect(
+ page.locator("#ryd-discussion-panel, #ryd-discussion-launcher, #ryd-discussion-drawer"),
+ ).toHaveCount(0);
+ record(`${label}-native-enabled-excluded`);
+ }
+ }
+ assert.equal(errors.length, 0, "Fixture page had uncaught errors");
+ record("no-uncaught-fixture-page-errors");
+ } catch (error) {
+ failure = {
+ name: error.name,
+ message: String(error.message)
+ .replace(/(access_token|sessionToken|token)=[^&\s]+/gi, "$1=[redacted]")
+ .slice(0, 2500),
+ };
+ if (adapter.page)
+ await adapter.page
+ .screenshot({ path: path.join(outputDirectory, "failure.png"), timeout: TIMEOUT_MS })
+ .catch(() => {});
+ } finally {
+ for (const id of commentsToRestore) {
+ await request(`/moderation/comments/${id}`, {
+ account: "moderator",
+ method: "POST",
+ body: { hidden: false },
+ }).catch(() => errors.push({ name: "RestoreCommentError", commentId: id }));
+ }
+ for (const id of profilesToRestore) {
+ await request(`/moderation/profiles/${id}/suspension`, {
+ account: "moderator",
+ method: "POST",
+ body: { suspended: false },
+ }).catch(() => errors.push({ name: "RestoreProfileError", profileId: id }));
+ }
+ for (const { id, account } of created.reverse()) {
+ try {
+ await request(`/comments/${id}`, { account, method: "DELETE" });
+ } catch {
+ errors.push({ name: "CleanupError", commentId: id });
+ }
+ }
+ await bounded(() => adapter.close(), "Close disposable profile", 10000).catch(() =>
+ errors.push({ name: "BrowserCleanupError" }),
+ );
+ await bounded(() => apiServer.close(), "Close fixture server", TIMEOUT_MS).catch(() =>
+ errors.push({ name: "ServerCleanupError" }),
+ );
+ await fs.writeFile(
+ path.join(outputDirectory, "result.json"),
+ JSON.stringify(
+ {
+ kind: "hermetic-generated-artifact",
+ scope: notificationFocus ? "notifications-and-reconnect" : "discussion-smoke",
+ buildId: marker.buildId,
+ artifactDirectory,
+ results,
+ screenshots,
+ ledger,
+ errors,
+ failure: failure || null,
+ limitations: [
+ "Fixture YouTube pages, not live YouTube",
+ "Headless disposable Chromium profile",
+ "No live account OAuth",
+ "No live YouTube toasts or cross-tab notification deduplication scenario",
+ "Native Firefox and Safari not exercised",
+ ],
+ },
+ null,
+ 2,
+ ),
+ );
+ }
+ assert.equal(failure, undefined, failure?.message);
+ assert.equal(errors.length, 0, "Smoke cleanup or diagnostics failed");
+ return {
+ kind: "hermetic-generated-artifact",
+ scope: notificationFocus ? "notifications-and-reconnect" : "discussion-smoke",
+ buildId: marker.buildId,
+ assertions: results.length,
+ outputDirectory,
+ };
+}
+
+if (require.main === module)
+ runHermeticDiscussionSmoke()
+ .then((result) => process.stdout.write(`${JSON.stringify(result)}\n`))
+ .catch((error) => {
+ process.stderr.write(`${error.message}\n`);
+ process.exitCode = 1;
+ });
+
+module.exports = { fixture, runHermeticDiscussionSmoke };
diff --git a/Extensions/e2e/discussion/live-discussion-contract.js b/Extensions/e2e/discussion/live-discussion-contract.js
new file mode 100644
index 0000000..d1b1061
--- /dev/null
+++ b/Extensions/e2e/discussion/live-discussion-contract.js
@@ -0,0 +1,1047 @@
+const assert = require("node:assert/strict");
+const crypto = require("node:crypto");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const TIMEOUT_MS = 5000;
+const DEFAULT_STAGES = [
+ "render",
+ "authoring",
+ "access",
+ "reactions",
+ "sorting",
+ "moderation",
+ "notifications",
+ "responsive",
+ "keyboard",
+ "cleanup",
+ "navigation",
+];
+
+function safeDiagnostic(value) {
+ return String(value)
+ .replace(/(access_token|sessionToken|token)=([^&\s]+)/gi, "$1=[redacted]")
+ .slice(0, 800);
+}
+
+async function bounded(work, label, timeout = TIMEOUT_MS) {
+ let timer;
+ try {
+ return await Promise.race([
+ Promise.resolve().then(work),
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(`${label} exceeded ${timeout}ms`)), timeout);
+ }),
+ ]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+const DEFAULT_VIDEOS = {
+ watch: "6Rt2HtMsP6M",
+ shorts: "5QS-aIVIH9o",
+ enabled: "sYi2Sf_Bysg",
+};
+
+function localClient(origin, accounts, onResponse = () => {}) {
+ const url = new URL(origin);
+ assert.ok(["127.0.0.1", "localhost", "[::1]"].includes(url.hostname), "Mutation tests require loopback API");
+ assert.ok(
+ ["http:", "https:"].includes(url.protocol) && !url.username && !url.password,
+ "Invalid loopback API origin",
+ );
+ return async (route, { account, method = "GET", body, expected = 200 } = {}) => {
+ const token = account ? accounts[account]?.sessionToken || accounts[account]?.token : null;
+ if (account) assert.ok(token, `Missing local account: ${account}`);
+ const response = await fetch(`${url.origin}/api/discussion${route}`, {
+ method,
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ const data = await response.json().catch(() => null);
+ onResponse({
+ source: "loopback-client",
+ method,
+ path: `/api/discussion${route.split("?")[0]}`,
+ status: response.status,
+ });
+ assert.equal(
+ response.status,
+ expected,
+ `${method} ${route.split("?")[0]} failed: ${data?.code || response.status}`,
+ );
+ return data;
+ };
+}
+
+function assertLocalRuntime(origin, runtime, health) {
+ localClient(origin, {});
+ assert.equal(health?.localTestMode, true, "Local discussion host marker is required");
+ assert.match(runtime?.buildId || "", /^[a-f0-9]{32}$/, "Generated live build marker is required");
+ assert.equal(runtime?.localOriginMatched, true, "Loaded extension must use the requested local discussion origin");
+ assert.equal(runtime?.localHostPermission, true, "Loaded extension is missing its local test host permission");
+ return { buildId: runtime.buildId, extensionId: runtime.extensionId, origin: new URL(origin).origin };
+}
+
+function createFixtureCleanup(request) {
+ const comments = new Map();
+ const pending = [];
+ const hidden = new Set();
+ const suspended = new Set();
+ const reports = new Set();
+ const remember = (comment, account) => {
+ if (comment?.id) {
+ comments.set(comment.id, { ...comment, account });
+ for (const fixture of pending)
+ if (
+ fixture.account === account &&
+ fixture.text === comment.text &&
+ (fixture.parentId || null) === (comment.parentId || null)
+ )
+ fixture.resolvedId = comment.id;
+ }
+ return comment;
+ };
+ return {
+ remember,
+ pending,
+ hidden,
+ suspended,
+ reports,
+ async run() {
+ const results = [];
+ async function attempt(name, work) {
+ try {
+ await bounded(work, name);
+ results.push({ name, passed: true });
+ } catch (error) {
+ results.push({ name, passed: false, message: safeDiagnostic(error.message) });
+ }
+ }
+ for (const id of hidden)
+ await attempt(`restore-comment:${id}`, () =>
+ request(`/moderation/comments/${id}`, { account: "moderator", method: "POST", body: { hidden: false } }),
+ );
+ for (const id of suspended)
+ await attempt(`restore-profile:${id}`, () =>
+ request(`/moderation/profiles/${id}/suspension`, {
+ account: "moderator",
+ method: "POST",
+ body: { suspended: false },
+ }),
+ );
+ for (const fixture of pending.filter((item) => !item.resolvedId))
+ await attempt(`discover-created:${fixture.label}`, async () => {
+ const route = fixture.parentId
+ ? `/comments/${fixture.parentId}/replies`
+ : `/videos/${fixture.videoId}/comments?sort=new`;
+ let cursor = null;
+ const deadline = Date.now() + TIMEOUT_MS;
+ do {
+ const data = await request(
+ `${route}${cursor ? `${route.includes("?") ? "&" : "?"}cursor=${encodeURIComponent(cursor)}` : ""}`,
+ { account: fixture.account },
+ );
+ const found = (data.items || []).filter((comment) => comment.text === fixture.text);
+ for (const comment of found) remember(comment, fixture.account);
+ if (found.length) break;
+ cursor = data.nextCursor;
+ if (cursor && Date.now() >= deadline) throw new Error("Fixture discovery exceeded its deadline");
+ } while (cursor);
+ });
+ for (const comment of [...comments.values()].sort((a, b) => Number(!!b.parentId) - Number(!!a.parentId)))
+ await attempt(`delete-created:${comment.id}`, async () => {
+ const current = await request(`/comments/${comment.id}`, { account: comment.account });
+ if (!current.deleted)
+ await request(`/comments/${comment.id}`, { account: comment.account, method: "DELETE" });
+ const verified = await request(`/comments/${comment.id}`, { account: comment.account });
+ assert.equal(verified.deleted, true, "Fixture deletion did not persist");
+ assert.equal(verified.text, "", "Fixture body remains public");
+ });
+ for (const id of reports)
+ await attempt(`dismiss-created:${id}`, () =>
+ request(`/moderation/reports/${id}/dismiss`, { account: "moderator", method: "POST", body: {} }),
+ );
+ return results;
+ },
+ };
+}
+
+function solve(challenge, difficulty) {
+ assert.ok(Number.isInteger(difficulty) && difficulty >= 0 && difficulty <= 24, "Invalid local puzzle difficulty");
+ const buffer = Buffer.alloc(20);
+ const decoded = Buffer.from(challenge, "base64");
+ assert.equal(decoded.length, 16, "Invalid local puzzle challenge");
+ decoded.copy(buffer, 4);
+ const deadline = Date.now() + TIMEOUT_MS;
+ for (let value = 0; value < 2 ** difficulty * 20; value++) {
+ if (value % 1024 === 0 && Date.now() >= deadline) throw new Error("Local puzzle exceeded 5000ms");
+ buffer.writeUInt32LE(value, 0);
+ const digest = crypto.createHash("sha512").update(buffer).digest();
+ let zeros = 0;
+ for (const byte of digest) {
+ if (byte === 0) zeros += 8;
+ else {
+ zeros += Math.clz32(byte) - 24;
+ break;
+ }
+ }
+ if (zeros >= difficulty) return buffer.subarray(0, 4).toString("base64");
+ }
+ throw new Error("Local reaction challenge exhausted");
+}
+
+async function poll(probe, accept, message, timeout = TIMEOUT_MS) {
+ const deadline = Date.now() + timeout;
+ let value;
+ while (Date.now() < deadline) {
+ value = await bounded(probe, message, Math.max(1, deadline - Date.now()));
+ if (accept(value)) return value;
+ await new Promise((resolve) => setTimeout(resolve, Math.min(150, Math.max(0, deadline - Date.now()))));
+ }
+ throw new Error(message);
+}
+
+/**
+ * Runs selected stages against the generated extension on native YouTube.
+ * Example: { surfaces: ["watch"], stages: ["authoring", "reactions"] }.
+ * A render-only stage does not create comments. Other stages seed missing prerequisites through the loopback API.
+ * The caller owns one persistent browser connection and switches YouTube UI language before each final run.
+ */
+async function runLiveDiscussionMatrix({
+ page,
+ popup,
+ setAccount,
+ accounts,
+ origin,
+ language,
+ outputDirectory,
+ videos = DEFAULT_VIDEOS,
+ surfaces = ["watch", "shorts"],
+ stages = DEFAULT_STAGES,
+ checkpoint = () => {},
+ notificationObserver,
+ captureScreenshot,
+}) {
+ assert.ok(["en", "ru"].includes(language));
+ assert.ok(surfaces.length && surfaces.every((value) => ["watch", "shorts"].includes(value)), "Invalid surfaces");
+ assert.ok(
+ stages.every((value) => DEFAULT_STAGES.includes(value)),
+ "Invalid stages",
+ );
+ const selected = new Set(stages.length ? stages : ["render"]);
+ page.setDefaultTimeout(TIMEOUT_MS);
+ page.setDefaultNavigationTimeout(TIMEOUT_MS);
+ popup.setDefaultTimeout(TIMEOUT_MS);
+ await fs.mkdir(outputDirectory, { recursive: true });
+ const results = [];
+ const diagnostics = [];
+ const ledger = [];
+ const request = localClient(origin, accounts, (entry) => ledger.push(entry));
+ const fixtures = createFixtureCleanup(request);
+ const cleanupResults = [];
+ const stageResults = [];
+ const omissions = [
+ ...DEFAULT_STAGES.filter((stage) => !selected.has(stage)).map((stage) => ({ stage, reason: "Not selected" })),
+ ...[
+ "native-language-menu",
+ "native-theme-switch",
+ "spa-and-recycled-shorts",
+ "explicit-native-kids",
+ "cross-tab-toasts",
+ "offline-and-worker-restart",
+ "cursor-pagination",
+ ].map((stage) => ({ stage, reason: "Caller-owned supplementary acceptance" })),
+ ...(!notificationObserver
+ ? [{ stage: "native-signalr-frames", reason: "No native socket observer supplied; runtime inbox delivery only" }]
+ : []),
+ ];
+ let runtime = null;
+ const responseListener = (response) => {
+ const url = new URL(response.url());
+ if (url.origin !== new URL(origin).origin || !url.pathname.startsWith("/api/discussion/")) return;
+ ledger.push({
+ source: "browser",
+ method: response.request().method(),
+ path: url.pathname,
+ status: response.status(),
+ });
+ };
+ const errorListener = (error) => diagnostics.push({ kind: "page", message: safeDiagnostic(error.message) });
+ page.context().on("response", responseListener);
+ page.on("pageerror", errorListener);
+ const record = (name, details = {}) => {
+ results.push({ name, passed: true, ...details });
+ checkpoint(name);
+ };
+ const message = (operation, payload = {}) =>
+ bounded(
+ () =>
+ popup.evaluate(
+ ({ operation, payload }) => chrome.runtime.sendMessage({ message: "ryd_discussion", operation, payload }),
+ { operation, payload },
+ ),
+ `Worker ${operation}`,
+ );
+ const root = () => page.locator("#ryd-discussion-panel, #ryd-discussion-drawer").filter({ visible: true });
+ const row = (id) => root().locator(`[data-comment-id="${id}"]`);
+ const actions = (id) => row(id).locator(":scope > .ryd-discussion-comment-actions");
+ const editor = (id) => row(id).locator(":scope > .ryd-discussion-inline-editor");
+ const profiles = new Map();
+ async function ensureProfile(name) {
+ let value = await request("/me", { account: name });
+ if (!value.profile?.nickname)
+ value = await request("/me/profile", {
+ account: name,
+ method: "PUT",
+ body: { nickname: `Test ${name} ${crypto.randomBytes(3).toString("hex")}` },
+ });
+ profiles.set(name, value.profile?.id);
+ }
+ const checkLocale = async () => assert.ok((await page.locator("html").getAttribute("lang")).startsWith(language));
+ async function account(name) {
+ if (name && !profiles.has(name)) await ensureProfile(name);
+ await bounded(() => setAccount(name), "Set local extension account");
+ await poll(
+ () => message("session"),
+ (response) =>
+ response.ok &&
+ (name == null
+ ? !response.data.authenticated
+ : response.data.authenticated && response.data.profile?.id === profiles.get(name)),
+ "Extension session did not converge",
+ );
+ }
+ async function open(surface) {
+ const deadline = Date.now() + 25000;
+ const remaining = () => Math.max(1, deadline - Date.now());
+ await bounded(
+ async () => {
+ await page.goto(
+ `https://www.youtube.com/${surface === "watch" ? `watch?v=${videos.watch}` : `shorts/${videos.shorts}`}`,
+ { waitUntil: "domcontentloaded", timeout: Math.min(TIMEOUT_MS, remaining()) },
+ );
+ await checkLocale();
+ const native = await poll(
+ () =>
+ bounded(
+ () =>
+ page.evaluate(
+ ({ surface, videoId }) => {
+ const data = (node) => node?.polymerController?.data || node?.data;
+ if (surface === "shorts") {
+ const player = document.querySelector("#shorts-player");
+ let currentId;
+ try {
+ currentId = player?.getVideoData?.()?.video_id;
+ } catch {
+ return null;
+ }
+ const reel = player?.closest("ytd-reel-video-renderer");
+ if (currentId !== videoId || !reel) return null;
+ const buttons = [
+ ...(data(reel.querySelector("reel-action-bar-view-model"))?.buttonViewModels || []),
+ ...(data(reel)?.overlay?.reelPlayerOverlayRenderer?.buttonBar?.reelActionBarViewModel
+ ?.buttonViewModels || []),
+ ];
+ return buttons.some(
+ ({ buttonViewModel: button }) =>
+ button?.iconName === "SHORTS_COMMENT" && button.state === "BUTTON_VIEW_MODEL_STATE_DISABLED",
+ )
+ ? { videoId, evidence: "current-shorts-disabled-action" }
+ : null;
+ }
+ const watch = [...document.querySelectorAll("ytd-watch-flexy, ytd-watch-grid")].find(
+ (node) => node.getAttribute("video-id") === videoId,
+ );
+ if (!watch) return null;
+ const disabledLink = (value) => {
+ try {
+ const url = new URL(value, location.origin);
+ return url.hostname === "support.google.com" && url.pathname === "/youtube/answer/9706180";
+ } catch {
+ return false;
+ }
+ };
+ const hasNotice = (value, depth = 0) =>
+ value &&
+ typeof value === "object" &&
+ depth <= 14 &&
+ (value.messageRenderer?.text?.runs?.some((run) =>
+ disabledLink(run.navigationEndpoint?.urlEndpoint?.url),
+ ) ||
+ Object.values(value).some((child) => hasNotice(child, depth + 1)));
+ for (const comments of watch.querySelectorAll("ytd-comments")) {
+ comments.scrollIntoView({ block: "center" });
+ if (
+ comments.getBoundingClientRect().height > 0 &&
+ (hasNotice(data(comments)) ||
+ [...comments.querySelectorAll("ytd-message-renderer a[href]")].some((anchor) =>
+ disabledLink(anchor.href),
+ ))
+ )
+ return { videoId, evidence: "native-disabled-comments-notice" };
+ }
+ return null;
+ },
+ { surface, videoId: videos[surface] },
+ ),
+ "Inspect native YouTube anchor",
+ Math.min(TIMEOUT_MS, remaining()),
+ ),
+ Boolean,
+ "Native YouTube disabled-comments anchor did not hydrate",
+ Math.min(20000, remaining()),
+ );
+ record(`${surface}:native-anchor-ready`, native);
+ checkpoint(`${surface}:ryd-panel-wait`);
+ await bounded(
+ async () => {
+ await poll(
+ () => page.locator("html").getAttribute("data-ryd-extension-build"),
+ (buildId) => buildId === runtime.buildId,
+ "Content runtime does not match the guarded live build",
+ Math.min(TIMEOUT_MS, remaining()),
+ );
+ if (surface === "shorts") {
+ await page
+ .locator("#ryd-discussion-launcher")
+ .waitFor({ state: "visible", timeout: Math.min(TIMEOUT_MS, remaining()) });
+ await page.locator("#ryd-discussion-launcher").click({ timeout: Math.min(TIMEOUT_MS, remaining()) });
+ }
+ await root().waitFor({ state: "visible", timeout: Math.min(TIMEOUT_MS, remaining()) });
+ assert.equal(await root().count(), 1);
+ assert.equal(await root().getAttribute("data-video-id"), videos[surface]);
+ },
+ "RYD panel readiness",
+ Math.min(TIMEOUT_MS, remaining()),
+ );
+ record(`${surface}:ryd-panel-ready`);
+ },
+ "Open native video and discussion",
+ 25000,
+ );
+ }
+ async function capture(name) {
+ await poll(
+ () =>
+ root()
+ .evaluate((node) => {
+ node.scrollIntoView({ block: "center" });
+ return node.isConnected;
+ })
+ .catch((error) => {
+ if (/not attached|Execution context was destroyed/.test(error.message)) return false;
+ throw error;
+ }),
+ Boolean,
+ "The discussion container changed during screenshot preparation",
+ );
+ await bounded(
+ () =>
+ captureScreenshot
+ ? captureScreenshot(page, path.join(outputDirectory, `${name}.png`))
+ : page.screenshot({
+ path: path.join(outputDirectory, `${name}.png`),
+ animations: "disabled",
+ mask: [page.locator("#avatar-btn")],
+ timeout: TIMEOUT_MS,
+ }),
+ `Screenshot ${name}`,
+ );
+ }
+ async function newest(id) {
+ await root().locator(".ryd-discussion-thread > .ryd-discussion-toolbar select").selectOption("new");
+ await row(id).waitFor({ state: "visible", timeout: TIMEOUT_MS });
+ }
+ const inboxEvents = () =>
+ bounded(() => popup.evaluate(() => globalThis.__rydDiscussionLiveInboxEvents || []), "Read incoming inbox events");
+ let failure = null;
+ try {
+ const health = await request("/local/health");
+ const metadata = await bounded(
+ () =>
+ popup.evaluate(async (origin) => {
+ const manifest = chrome.runtime.getManifest();
+ const read = (name) => fetch(chrome.runtime.getURL(name), { signal: AbortSignal.timeout(5000) });
+ const [markerResponse, backgroundResponse] = await Promise.all([
+ read("live-build.json"),
+ read("ryd.background.js"),
+ ]);
+ const marker = markerResponse.ok ? await markerResponse.json() : null;
+ const background = backgroundResponse.ok ? await backgroundResponse.text() : "";
+ return {
+ extensionId: chrome.runtime.id,
+ buildId: marker?.buildId,
+ localOriginMatched: background.includes(`const testOrigin = true ? ${JSON.stringify(origin)}`),
+ localHostPermission:
+ manifest.host_permissions?.some(
+ (pattern) =>
+ pattern === `${origin}/*` || pattern === `${new URL(origin).protocol}//${new URL(origin).hostname}/*`,
+ ) === true,
+ };
+ }, new URL(origin).origin),
+ "Verify local extension runtime",
+ );
+ runtime = assertLocalRuntime(origin, metadata, health);
+ record("local-runtime-and-host-guard", runtime);
+ await bounded(
+ () =>
+ popup.evaluate(() => {
+ if (globalThis.__rydDiscussionLiveInboxListener)
+ chrome.runtime.onMessage.removeListener(globalThis.__rydDiscussionLiveInboxListener);
+ globalThis.__rydDiscussionLiveInboxEvents = [];
+ let sequence = 0;
+ globalThis.__rydDiscussionLiveInboxListener = (event) => {
+ if (event?.message !== "ryd_discussion_event" || event.type !== "inbox") return;
+ globalThis.__rydDiscussionLiveInboxEvents.push({
+ sequence: ++sequence,
+ data: {
+ unreadCount: event.data?.unreadCount,
+ revision: event.data?.revision,
+ items: (event.data?.items || []).map(({ id, commentId, type, read, revision }) => ({
+ id,
+ commentId,
+ type,
+ read,
+ revision,
+ })),
+ },
+ });
+ if (globalThis.__rydDiscussionLiveInboxEvents.length > 100)
+ globalThis.__rydDiscussionLiveInboxEvents.shift();
+ };
+ chrome.runtime.onMessage.addListener(globalThis.__rydDiscussionLiveInboxListener);
+ }),
+ "Install incoming inbox observer",
+ );
+ for (const surface of surfaces) {
+ const videoId = videos[surface];
+ await account("commenter");
+ const session = await message("session");
+ if (!session.data.profile?.nickname) {
+ const result = await message("profile", {
+ nickname: `Test Commenter ${crypto.randomBytes(3).toString("hex")}`,
+ });
+ assert.equal(result.ok, true);
+ }
+ await open(surface);
+ const marker = `${language} ${surface} ${crypto.randomUUID()}`;
+ const text = `Local discussion check ${marker} plain text`;
+ const composer = root().locator(".ryd-discussion-composer textarea[name=comment]");
+ let comment;
+ if (selected.has("authoring")) {
+ const accountControls = root().locator(".ryd-discussion-composer .ryd-discussion-account button");
+ if (await accountControls.count()) await accountControls.click();
+ const nickname = `Live ${language} ${surface} ${crypto.randomBytes(3).toString("hex")}`;
+ await root().locator('.ryd-discussion-composer input[name="nickname"]').fill(nickname);
+ await root().locator('.ryd-discussion-composer button[type="submit"]').click();
+ await poll(
+ () => request("/me", { account: "commenter" }),
+ (data) => data.profile?.nickname === nickname,
+ "Nickname change did not persist",
+ );
+ await composer.waitFor({ state: "visible" });
+ record(`${surface}:nickname-ui-change`);
+ fixtures.pending.push({ label: `${surface}-root`, videoId, text, account: "commenter" });
+ await composer.fill(text);
+ await root().locator(".ryd-discussion-composer [data-action=post]").click();
+ const created = await poll(
+ () => request(`/videos/${videoId}/comments?sort=new`, { account: "commenter" }),
+ (result) => result.items.some((item) => item.text === text),
+ "Posted comment missing from authoritative list",
+ );
+ comment = fixtures.remember(
+ created.items.find((item) => item.text === text),
+ "commenter",
+ );
+ await newest(comment.id);
+ assert.equal(await row(comment.id).locator(".ryd-discussion-comment-text").first().textContent(), text);
+ assert.equal(await row(comment.id).locator(".ryd-discussion-comment-text b").count(), 0);
+ record(`${surface}:create-plain-text`);
+
+ await actions(comment.id).locator("[data-action=edit]").click();
+ await editor(comment.id).locator("textarea[name=comment]").fill(`Edited ${text}`);
+ await editor(comment.id).locator("[data-action=save]").click();
+ await poll(
+ () => request(`/comments/${comment.id}`, { account: "commenter" }),
+ (data) => data.text === `Edited ${text}`,
+ "Edit was not persisted",
+ );
+ record(`${surface}:edit`);
+
+ await actions(comment.id).locator("[data-action=reply]").click();
+ const replyText = `Reply ${marker}`;
+ fixtures.pending.push({
+ label: `${surface}-reply`,
+ videoId,
+ parentId: comment.id,
+ text: replyText,
+ account: "commenter",
+ });
+ await editor(comment.id).locator("textarea[name=comment]").fill(replyText);
+ await editor(comment.id).locator("[data-action=post]").click();
+ const replies = await poll(
+ () => request(`/comments/${comment.id}/replies`, { account: "commenter" }),
+ (data) => data.items.some((item) => item.text === replyText),
+ "Reply was not persisted",
+ );
+ fixtures.remember(
+ replies.items.find((item) => item.text === replyText),
+ "commenter",
+ );
+ record(`${surface}:reply`);
+ await capture(`${surface}-authoring`);
+ }
+ if (
+ !comment &&
+ ["access", "reactions", "sorting", "moderation", "notifications", "cleanup"].some((stage) =>
+ selected.has(stage),
+ )
+ ) {
+ fixtures.pending.push({ label: `${surface}-root`, videoId, text, account: "commenter" });
+ comment = await request(`/videos/${videoId}/comments`, {
+ account: "commenter",
+ method: "POST",
+ body: { text, requestId: crypto.randomUUID() },
+ });
+ fixtures.remember(comment, "commenter");
+ await newest(comment.id);
+ record(`${surface}:local-fixture`, { commentId: comment.id });
+ }
+ if (!stages.length || selected.has("render")) {
+ await capture(`${surface}-render`);
+ record(`${surface}:render`);
+ }
+ if (selected.has("access")) {
+ await account("premium");
+ await composer.waitFor({ state: "visible" });
+ assert.equal((await message("session")).data.canPost, true);
+ record(`${surface}:premium-posting-gate`);
+ for (const name of ["supporter", "inactive"]) {
+ await account(name);
+ await poll(
+ () => composer.count(),
+ (count) => count === 0,
+ `${name} account still has composer`,
+ );
+ assert.equal((await message("session")).data.canPost, false);
+ record(`${surface}:${name}-posting-gate`);
+ }
+ await account(null);
+ await newest(comment.id);
+ assert.equal(await composer.count(), 0);
+ await poll(
+ () => actions(comment.id).locator("[data-action=edit]").count(),
+ (count) => count === 0,
+ "Signed-out author actions remain",
+ );
+ record(`${surface}:signed-out-author-controls`);
+ await account("commenter");
+ await newest(comment.id);
+ await actions(comment.id).locator("[data-action=edit]").waitFor({ state: "visible" });
+ record(`${surface}:author-controls-restored`);
+ }
+ if (selected.has("reactions")) {
+ await account(null);
+ await newest(comment.id);
+ for (const value of [1, -1, -1]) {
+ const button = actions(comment.id).locator(`[data-reaction="${value}"]`);
+ await poll(() => button.isEnabled(), Boolean, "Reaction button not ready");
+ const expected = (await button.getAttribute("aria-pressed")) === "true" ? 0 : value;
+ const beforeValue =
+ (await actions(comment.id).locator('[data-reaction="1"]').getAttribute("aria-pressed")) === "true"
+ ? 1
+ : (await actions(comment.id).locator('[data-reaction="-1"]').getAttribute("aria-pressed")) === "true"
+ ? -1
+ : 0;
+ const before = await request(`/comments/${comment.id}`);
+ await button.click();
+ await poll(
+ async () => ({
+ enabled: await button.isEnabled(),
+ like: await actions(comment.id).locator('[data-reaction="1"]').getAttribute("aria-pressed"),
+ dislike: await actions(comment.id).locator('[data-reaction="-1"]').getAttribute("aria-pressed"),
+ }),
+ (state) =>
+ state.enabled && state.like === String(expected === 1) && state.dislike === String(expected === -1),
+ "Reaction did not match the requested transition",
+ );
+ const after = await request(`/comments/${comment.id}`);
+ assert.equal(after.likes, before.likes - Number(beforeValue === 1) + Number(expected === 1));
+ assert.equal(after.dislikes, before.dislikes - Number(beforeValue === -1) + Number(expected === -1));
+ }
+ assert.equal(await actions(comment.id).locator('[data-reaction="-1"]').getAttribute("aria-pressed"), "false");
+ record(`${surface}:anonymous-like-dislike-remove`);
+ }
+ if (selected.has("sorting")) {
+ for (const sort of ["new", "top"]) {
+ const expected = await request(`/videos/${videoId}/comments?sort=${sort}`);
+ await root().locator(".ryd-discussion-thread > .ryd-discussion-toolbar select").selectOption(sort);
+ await poll(
+ () =>
+ root()
+ .locator(".ryd-discussion-comments > [data-comment-id]")
+ .evaluateAll((nodes) => nodes.map((node) => node.dataset.commentId)),
+ (ids) => JSON.stringify(ids) === JSON.stringify(expected.items.map((item) => item.id)),
+ `Rendered ${sort} order differs from API`,
+ );
+ }
+ await newest(comment.id);
+ record(`${surface}:sorting`);
+ }
+ if (selected.has("moderation")) {
+ await ensureProfile("other");
+ await ensureProfile("moderator");
+ await account("other");
+ await newest(comment.id);
+ await actions(comment.id).locator("[data-action=report]").click();
+ await editor(comment.id).locator("[data-action=send-report]").click();
+ const submitted = await poll(
+ () => request("/moderation/reports", { account: "moderator" }),
+ (data) => data.items.some((item) => item.comment.id === comment.id),
+ "Report not persisted",
+ );
+ const reportId = submitted.items.find((item) => item.comment.id === comment.id).id;
+ fixtures.reports.add(reportId);
+ await account("moderator");
+ await root().locator("[data-action=moderation]").click();
+ const report = root().locator(`[data-report-id="${reportId}"]`);
+ await report.waitFor({ state: "visible" });
+ fixtures.hidden.add(comment.id);
+ await report.locator("[data-action=hide]").click();
+ await report.locator("[data-action=restore]").waitFor({ state: "visible" });
+ assert.equal((await request(`/comments/${comment.id}`)).hidden, true);
+ fixtures.suspended.add(comment.author.id);
+ await report.locator("[data-action=ban]").click();
+ await report.locator("[data-action=unban]").waitFor({ state: "visible" });
+ assert.equal((await request("/me", { account: "commenter" })).profile.suspended, true);
+ await report.locator("[data-action=dismiss]").click();
+ await report.waitFor({ state: "detached", timeout: TIMEOUT_MS });
+ fixtures.reports.delete(reportId);
+ await root().locator("[data-action=report-status]").selectOption("resolved");
+ await report.waitFor({ state: "visible" });
+ assert.ok(
+ (await request("/moderation/reports?status=resolved", { account: "moderator" })).items.some(
+ (item) => item.id === reportId,
+ ),
+ );
+ await capture(`${surface}-moderation-history`);
+ await report.locator("[data-action=restore]").click();
+ await report.locator("[data-action=hide]").waitFor({ state: "visible" });
+ assert.equal((await request(`/comments/${comment.id}`)).hidden, false);
+ fixtures.hidden.delete(comment.id);
+ await report.locator("[data-action=unban]").click();
+ await report.locator("[data-action=ban]").waitFor({ state: "visible" });
+ assert.equal((await request("/me", { account: "commenter" })).profile.suspended, false);
+ fixtures.suspended.delete(comment.author.id);
+ record(`${surface}:report-resolved-history-restore`);
+ }
+ if (selected.has("notifications")) {
+ await ensureProfile("other");
+ await account("commenter");
+ await root().locator("[data-action=discussion]").click();
+ const cleared = await message("readNotifications", { all: true });
+ assert.equal(cleared.ok, true);
+ for (const type of ["reply", "likes"]) {
+ const nativeBaseline = notificationObserver
+ ? await bounded(
+ () => notificationObserver.beforeActivity({ surface, type, commentId: comment.id }),
+ "Await native notification transport",
+ )
+ : null;
+ const baseline = (await inboxEvents()).at(-1)?.sequence || 0;
+ let activityId = comment.id;
+ if (type === "reply") {
+ const replyText = `Notification reply ${marker}`;
+ fixtures.pending.push({
+ label: `${surface}-notification-reply`,
+ videoId,
+ parentId: comment.id,
+ text: replyText,
+ account: "other",
+ });
+ const incomingReply = fixtures.remember(
+ await request(`/videos/${videoId}/comments`, {
+ account: "other",
+ method: "POST",
+ body: { text: replyText, parentId: comment.id, replyToId: comment.id, requestId: crypto.randomUUID() },
+ }),
+ "other",
+ );
+ activityId = incomingReply.id;
+ } else {
+ const userId = accounts.other.voterId;
+ assert.ok(userId, "Missing separate local reaction identity");
+ const challenge = await request(`/comments/${comment.id}/reaction/challenge`, {
+ method: "POST",
+ body: { userId, value: 1 },
+ });
+ await request(`/comments/${comment.id}/reaction/confirm`, {
+ method: "POST",
+ body: {
+ userId,
+ challengeId: challenge.challengeId,
+ solution: solve(challenge.challenge, challenge.difficulty),
+ },
+ });
+ }
+ if (notificationObserver) {
+ const proof = await bounded(
+ () =>
+ notificationObserver.afterActivity({ surface, type, commentId: activityId, baseline: nativeBaseline }),
+ "Await native incoming notification frame",
+ );
+ assert.equal(proof?.observed, true, "Native notification observer must attest a received frame");
+ record(`${surface}:${type}-native-signalr-frame`, {
+ revision: proof.revision == null ? null : String(proof.revision),
+ connection: Number.isInteger(proof.connection) ? proof.connection : null,
+ });
+ }
+ // Observe incoming delivery before explicitly reading the inbox or opening its UI.
+ const matches = (event) =>
+ event.sequence > baseline &&
+ event.data.items?.some((item) => item.commentId === activityId && item.type === type && !item.read);
+ const incomingEvents = await poll(
+ inboxEvents,
+ (events) => events.some(matches),
+ `Incoming runtime inbox event for ${type} did not arrive`,
+ );
+ const incoming = incomingEvents.find(matches);
+ const notification = incoming.data.items.find(
+ (item) => item.commentId === activityId && item.type === type && !item.read,
+ );
+ assert.ok(incoming.data.unreadCount > 0);
+ record(`${surface}:${type}-notification-runtime-event`, {
+ notificationId: notification.id,
+ revision: incoming.data.revision,
+ });
+ await root().locator("[data-action=inbox]").click();
+ const inboxRow = root().locator(`[data-notification-id="${notification.id}"]`);
+ await inboxRow.waitFor({ state: "visible" });
+ await capture(`${surface}-${type}-notifications`);
+ await inboxRow.locator("button").click();
+ await poll(
+ () => request("/notifications", { account: "commenter" }),
+ (data) => data.items.find((item) => item.id === notification.id)?.read === true,
+ "UI mark read was not persisted",
+ );
+ record(`${surface}:${type}-inbox-ui-mark-read`);
+ await root().locator("[data-action=discussion]").click();
+ }
+ }
+ if (selected.has("responsive")) {
+ const viewport = await bounded(
+ () => page.evaluate(() => ({ width: innerWidth, height: innerHeight })),
+ "Read original viewport",
+ );
+ try {
+ await bounded(() => page.setViewportSize({ width: 390, height: 844 }), "Set narrow viewport");
+ await poll(
+ () => root().boundingBox(),
+ (box) => box && box.x >= -1 && box.x + box.width <= 392,
+ "Discussion exceeds the narrow viewport",
+ );
+ await capture(`${surface}-390px`);
+ record(`${surface}:responsive-390px`);
+ } finally {
+ await bounded(() => page.setViewportSize(viewport), "Restore viewport");
+ }
+ let previousBox = null;
+ let stableSince = Date.now();
+ await poll(
+ async () => {
+ const box = JSON.stringify(await root().boundingBox());
+ if (box === "null" || box !== previousBox) stableSince = Date.now();
+ previousBox = box;
+ return Date.now() - stableSince >= 500;
+ },
+ Boolean,
+ "Discussion layout did not settle after restoring the viewport",
+ );
+ }
+ if (selected.has("keyboard")) {
+ await account("commenter");
+ await root().locator("[data-action=discussion]").click();
+ await composer.focus();
+ await bounded(() => page.keyboard.press("Tab"), "Keyboard Tab");
+ assert.ok(
+ await bounded(
+ () => root().evaluate((node) => node.contains(document.activeElement)),
+ "Inspect keyboard focus",
+ ),
+ "Keyboard Tab left the discussion panel",
+ );
+ if (surface === "shorts") {
+ const controls = root().locator(
+ "button:visible:not(:disabled), input:visible:not(:disabled), textarea:visible:not(:disabled), select:visible:not(:disabled), a[href]:visible",
+ );
+ await controls.last().focus();
+ await bounded(() => page.keyboard.press("Tab"), "Drawer focus wrap");
+ assert.ok(
+ await bounded(
+ () => controls.first().evaluate((node) => node === document.activeElement),
+ "Inspect wrapped focus",
+ ),
+ "Drawer keyboard focus did not wrap to its first control",
+ );
+ await bounded(() => page.keyboard.press("Escape"), "Close drawer with Escape");
+ await page.locator("#ryd-discussion-drawer").waitFor({ state: "hidden", timeout: TIMEOUT_MS });
+ assert.ok(
+ await bounded(
+ () => page.locator("#ryd-discussion-launcher").evaluate((node) => node === document.activeElement),
+ "Inspect launcher focus",
+ ),
+ "Closing the drawer did not return focus to its launcher",
+ );
+ await page.locator("#ryd-discussion-launcher").press("Enter");
+ await root().waitFor({ state: "visible", timeout: TIMEOUT_MS });
+ }
+ const keyboardText = `Keyboard submission ${marker}`;
+ fixtures.pending.push({ label: `${surface}-keyboard`, videoId, text: keyboardText, account: "commenter" });
+ await composer.fill(keyboardText);
+ await composer.press("Control+Enter");
+ const created = await poll(
+ () => request(`/videos/${videoId}/comments?sort=new`, { account: "commenter" }),
+ (data) => data.items.some((item) => item.text === keyboardText),
+ "Keyboard submission was not persisted",
+ );
+ const keyboardComment = fixtures.remember(
+ created.items.find((item) => item.text === keyboardText),
+ "commenter",
+ );
+ await capture(`${surface}-keyboard`);
+ await request(`/comments/${keyboardComment.id}`, { account: "commenter", method: "DELETE" });
+ record(`${surface}:keyboard-controls-and-submit`);
+ }
+ if (selected.has("cleanup") && comment) {
+ await account("commenter");
+ await root().locator("[data-action=discussion]").click();
+ await newest(comment.id);
+ const before = await request(`/comments/${comment.id}`);
+ await actions(comment.id).locator("[data-action=delete]").click();
+ await editor(comment.id).locator("[data-action=confirm-delete]").click();
+ const deleted = await poll(
+ () => request(`/comments/${comment.id}`),
+ (data) => data.deleted === true,
+ "Deleted comment did not become a tombstone",
+ );
+ assert.equal(deleted.text, "");
+ assert.equal(deleted.replyCount, before.replyCount);
+ record(`${surface}:delete-tombstone`);
+ }
+ }
+ if (selected.has("navigation")) {
+ await page.goto(`https://www.youtube.com/watch?v=${videos.enabled}`, { waitUntil: "domcontentloaded" });
+ await page.locator("ytd-comments").first().scrollIntoViewIfNeeded({ timeout: TIMEOUT_MS });
+ await page.locator("ytd-comments-header-renderer").first().waitFor({ state: "attached", timeout: TIMEOUT_MS });
+ assert.equal(await page.locator("#ryd-discussion-panel,#ryd-discussion-launcher").count(), 0);
+ record("enabled-video-excluded");
+ await page.goBack({ waitUntil: "domcontentloaded" });
+ const previous = surfaces.at(-1);
+ await page
+ .locator(previous === "shorts" ? "#ryd-discussion-launcher" : "#ryd-discussion-panel")
+ .waitFor({ state: "visible", timeout: TIMEOUT_MS });
+ record(`history-returns-to-${previous}`);
+ }
+ return {
+ language,
+ surfaces,
+ stages,
+ runtime,
+ results,
+ diagnostics,
+ ledger,
+ cleanupResults,
+ stageResults,
+ omissions,
+ };
+ } catch (error) {
+ failure = { message: safeDiagnostic(error.message) };
+ throw error;
+ } finally {
+ const completed = new Set(results.map((result) => result.name));
+ const lastCheck = {
+ render: "render",
+ authoring: "reply",
+ access: "author-controls-restored",
+ reactions: "anonymous-like-dislike-remove",
+ sorting: "sorting",
+ moderation: "report-resolved-history-restore",
+ notifications: "likes-inbox-ui-mark-read",
+ responsive: "responsive-390px",
+ keyboard: "keyboard-controls-and-submit",
+ cleanup: "delete-tombstone",
+ };
+ for (const surface of surfaces)
+ for (const [stage, check] of Object.entries(lastCheck))
+ stageResults.push({
+ surface,
+ stage,
+ status: !selected.has(stage)
+ ? "not-selected"
+ : completed.has(`${surface}:${check}`)
+ ? "passed"
+ : "not-completed",
+ });
+ stageResults.push({
+ stage: "navigation",
+ status: !selected.has("navigation")
+ ? "not-selected"
+ : completed.has(`history-returns-to-${surfaces.at(-1)}`)
+ ? "passed"
+ : "not-completed",
+ });
+ cleanupResults.push(...(await fixtures.run()));
+ const cleanupFailed = cleanupResults.some((result) => !result.passed);
+ if (cleanupFailed && !failure) failure = { message: "Local fixture cleanup failed; inspect cleanupResults" };
+ page.context().off("response", responseListener);
+ page.off("pageerror", errorListener);
+ await bounded(
+ () =>
+ popup.evaluate(() => {
+ if (globalThis.__rydDiscussionLiveInboxListener)
+ chrome.runtime.onMessage.removeListener(globalThis.__rydDiscussionLiveInboxListener);
+ delete globalThis.__rydDiscussionLiveInboxListener;
+ delete globalThis.__rydDiscussionLiveInboxEvents;
+ }),
+ "Remove incoming inbox observer",
+ ).catch(() => {});
+ await fs.writeFile(
+ path.join(outputDirectory, "results.json"),
+ JSON.stringify(
+ {
+ language,
+ surfaces,
+ stages,
+ runtime,
+ results,
+ diagnostics,
+ ledger,
+ cleanupResults,
+ stageResults,
+ omissions,
+ failure,
+ },
+ null,
+ 2,
+ ),
+ );
+ if (cleanupFailed && failure?.message === "Local fixture cleanup failed; inspect cleanupResults")
+ throw new Error(failure.message);
+ }
+}
+
+module.exports = {
+ DEFAULT_VIDEOS,
+ DEFAULT_STAGES,
+ TIMEOUT_MS,
+ bounded,
+ assertLocalRuntime,
+ createFixtureCleanup,
+ localClient,
+ poll,
+ solve,
+ runLiveDiscussionMatrix,
+};
diff --git a/Extensions/e2e/discussion/live-discussion-contract.spec.js b/Extensions/e2e/discussion/live-discussion-contract.spec.js
new file mode 100644
index 0000000..807d0ed
--- /dev/null
+++ b/Extensions/e2e/discussion/live-discussion-contract.spec.js
@@ -0,0 +1,210 @@
+const crypto = require("node:crypto");
+const fs = require("node:fs/promises");
+const {
+ bounded,
+ localClient,
+ poll,
+ solve,
+ runLiveDiscussionMatrix,
+ assertLocalRuntime,
+ createFixtureCleanup,
+} = require("./live-discussion-contract");
+
+test("the mutation client rejects non-loopback origins before doing any work", () => {
+ expect(() => localClient("https://returnyoutubedislikeapi.com", {})).toThrow(/loopback/);
+ expect(() => localClient("http://name:password@127.0.0.1:5099", {})).toThrow(/Invalid loopback/);
+});
+
+test("a hanging probe cannot exceed the finite poll budget", async () => {
+ const started = Date.now();
+ await expect(poll(() => new Promise(() => {}), Boolean, "Probe stuck", 30)).rejects.toThrow(/exceeded/);
+ expect(Date.now() - started).toBeLessThan(1000);
+});
+
+test("bounded propagates real failures and clears its timeout after success", async () => {
+ await expect(bounded(() => 7, "Immediate", 30)).resolves.toBe(7);
+ await expect(
+ bounded(
+ () => {
+ throw new Error("Actual failure");
+ },
+ "Fail",
+ 30,
+ ),
+ ).rejects.toThrow("Actual failure");
+});
+
+test("the local solver returns the four-byte SHA-512 proof format", () => {
+ const challenge = Buffer.alloc(16, 1).toString("base64");
+ const solution = Buffer.from(solve(challenge, 4), "base64");
+ expect(solution.length).toBe(4);
+ const digest = crypto
+ .createHash("sha512")
+ .update(Buffer.concat([solution, Buffer.from(challenge, "base64")]))
+ .digest();
+ expect(digest[0] >>> 4).toBe(0);
+ expect(() => solve(challenge, 99)).toThrow(/difficulty/);
+});
+
+test("staged runs reject invalid surfaces before touching a browser", async () => {
+ await expect(runLiveDiscussionMatrix({ language: "en", surfaces: ["mobile"], page: {}, popup: {} })).rejects.toThrow(
+ /Invalid surfaces/,
+ );
+ await expect(
+ runLiveDiscussionMatrix({ language: "en", surfaces: ["watch"], stages: ["unknown"], page: {}, popup: {} }),
+ ).rejects.toThrow(/Invalid stages/);
+});
+
+test("discussion create requests default to the implemented 200 response and ledger omits credentials", async () => {
+ const previousFetch = global.fetch;
+ const ledger = [];
+ global.fetch = jest.fn().mockResolvedValue({ status: 200, json: async () => ({ id: "created" }) });
+ try {
+ const request = localClient("http://127.0.0.1:5099", { writer: { sessionToken: "test-session" } }, (entry) =>
+ ledger.push(entry),
+ );
+ await expect(
+ request("/videos/abcdefghijk/comments", { account: "writer", method: "POST", body: { text: "Local text" } }),
+ ).resolves.toEqual({ id: "created" });
+ expect(global.fetch.mock.calls[0][0]).toBe("http://127.0.0.1:5099/api/discussion/videos/abcdefghijk/comments");
+ expect(JSON.stringify(ledger)).not.toContain("test-session");
+ expect(ledger).toEqual([
+ { source: "loopback-client", method: "POST", path: "/api/discussion/videos/abcdefghijk/comments", status: 200 },
+ ]);
+ } finally {
+ global.fetch = previousFetch;
+ }
+});
+
+test("the runtime guard requires both generated local artifact evidence and the local host marker", () => {
+ const runtime = {
+ buildId: "a".repeat(32),
+ extensionId: "fixture-extension",
+ localOriginMatched: true,
+ localHostPermission: true,
+ };
+ expect(assertLocalRuntime("http://127.0.0.1:5099", runtime, { localTestMode: true })).toEqual({
+ buildId: "a".repeat(32),
+ extensionId: "fixture-extension",
+ origin: "http://127.0.0.1:5099",
+ });
+ expect(() => assertLocalRuntime("https://example.com", runtime, { localTestMode: true })).toThrow(/loopback/);
+ expect(() => assertLocalRuntime("http://127.0.0.1:5099", runtime, {})).toThrow(/host marker/);
+ for (const override of [{ buildId: null }, { localOriginMatched: false }, { localHostPermission: false }])
+ expect(() =>
+ assertLocalRuntime("http://127.0.0.1:5099", { ...runtime, ...override }, { localTestMode: true }),
+ ).toThrow();
+});
+
+test("fixture cleanup restores moderation state and deletes replies before roots with their own authors", async () => {
+ const comments = new Map([
+ ["root", { id: "root", text: "Root", parentId: null, deleted: false }],
+ ["reply", { id: "reply", text: "Reply", parentId: "root", deleted: false }],
+ ]);
+ const request = jest.fn(async (route, options = {}) => {
+ const id = route.split("/").at(-1);
+ if (route.startsWith("/comments/")) {
+ if (options.method === "DELETE") comments.set(id, { ...comments.get(id), text: "", deleted: true });
+ return comments.get(id);
+ }
+ return {};
+ });
+ const cleanup = createFixtureCleanup(request);
+ cleanup.remember(comments.get("root"), "commenter");
+ cleanup.remember(comments.get("reply"), "other");
+ cleanup.hidden.add("root");
+ cleanup.suspended.add("author");
+ const results = await cleanup.run();
+ expect(results.every((value) => value.passed)).toBe(true);
+ expect(request.mock.calls.slice(0, 2).map(([route]) => route)).toEqual([
+ "/moderation/comments/root",
+ "/moderation/profiles/author/suspension",
+ ]);
+ expect(
+ request.mock.calls
+ .filter(([, options]) => options.method === "DELETE")
+ .map(([route, options]) => [route, options.account]),
+ ).toEqual([
+ ["/comments/reply", "other"],
+ ["/comments/root", "commenter"],
+ ]);
+});
+
+test("fixture cleanup discovers a committed UI post whose response was lost, without deleting unrelated rows", async () => {
+ const created = { id: "lost-response", text: "Unique attempt marker", deleted: false };
+ const request = jest.fn(async (route, options = {}) => {
+ if (route.startsWith("/videos/")) return { items: [created, { id: "unrelated", text: "Someone else" }] };
+ if (options.method === "DELETE") Object.assign(created, { deleted: true, text: "" });
+ return created;
+ });
+ const cleanup = createFixtureCleanup(request);
+ cleanup.pending.push({ label: "pending-root", videoId: "abcdefghijk", text: created.text, account: "commenter" });
+ expect((await cleanup.run()).every((value) => value.passed)).toBe(true);
+ expect(request.mock.calls.filter(([, options]) => options.method === "DELETE").map(([route]) => route)).toEqual([
+ "/comments/lost-response",
+ ]);
+});
+
+test("fixture cleanup reports a failed deletion and still attempts the remaining comments", async () => {
+ const deleted = new Set();
+ const request = jest.fn(async (route, options = {}) => {
+ const id = route.split("/").at(-1);
+ if (id === "bad") throw new Error("Local failure");
+ if (options.method === "DELETE") deleted.add(id);
+ return { id, deleted: deleted.has(id), text: deleted.has(id) ? "" : "Body" };
+ });
+ const cleanup = createFixtureCleanup(request);
+ cleanup.remember({ id: "bad" }, "commenter");
+ cleanup.remember({ id: "good" }, "commenter");
+ const results = await cleanup.run();
+ expect(results.find((item) => item.name === "delete-created:bad").passed).toBe(false);
+ expect(results.find((item) => item.name === "delete-created:good").passed).toBe(true);
+});
+
+test("a rejected runtime guard cannot switch accounts or make a mutation request", async () => {
+ const previousFetch = global.fetch;
+ const mkdir = jest.spyOn(fs, "mkdir").mockResolvedValue(undefined);
+ const writeFile = jest.spyOn(fs, "writeFile").mockResolvedValue(undefined);
+ global.fetch = jest.fn().mockResolvedValue({ status: 200, json: async () => ({ localTestMode: true }) });
+ const context = { on: jest.fn(), off: jest.fn() };
+ const page = {
+ setDefaultTimeout: jest.fn(),
+ setDefaultNavigationTimeout: jest.fn(),
+ context: () => context,
+ on: jest.fn(),
+ off: jest.fn(),
+ };
+ const popup = {
+ setDefaultTimeout: jest.fn(),
+ evaluate: jest
+ .fn()
+ .mockResolvedValue({ buildId: "a".repeat(32), localOriginMatched: false, localHostPermission: true }),
+ };
+ const setAccount = jest.fn();
+ try {
+ await expect(
+ runLiveDiscussionMatrix({
+ page,
+ popup,
+ setAccount,
+ accounts: {},
+ origin: "http://127.0.0.1:5099",
+ language: "en",
+ outputDirectory: "unused",
+ stages: ["authoring"],
+ }),
+ ).rejects.toThrow(/requested local discussion origin/);
+ expect(setAccount).not.toHaveBeenCalled();
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ expect(global.fetch.mock.calls[0][1].method).toBe("GET");
+ const receipt = JSON.parse(writeFile.mock.calls[0][1]);
+ expect(receipt.cleanupResults).toEqual([]);
+ expect(receipt.stageResults.find((value) => value.surface === "watch" && value.stage === "authoring").status).toBe(
+ "not-completed",
+ );
+ } finally {
+ global.fetch = previousFetch;
+ mkdir.mockRestore();
+ writeFile.mockRestore();
+ }
+});
diff --git a/Extensions/e2e/discussion/watch-width-smoke.js b/Extensions/e2e/discussion/watch-width-smoke.js
new file mode 100644
index 0000000..f32d52c
--- /dev/null
+++ b/Extensions/e2e/discussion/watch-width-smoke.js
@@ -0,0 +1,87 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { chromium } = require("@playwright/test");
+
+async function runWatchWidthSmoke() {
+ const repository = path.resolve(__dirname, "../../..");
+ const output = path.join(repository, "test-results/discussion", `watch-width-${Date.now()}`);
+ const css = await fs.readFile(path.join(repository, "Extensions/combined/content-style.css"), "utf8");
+ await fs.mkdir(output, { recursive: true });
+ const browser = await chromium.launch({ headless: true });
+ const results = [];
+ try {
+ const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
+ await context.route("**/*", (route) => route.abort());
+ const page = await context.newPage();
+ page.setDefaultTimeout(5000);
+ for (const language of ["en", "ru"]) {
+ const ru = language === "ru";
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.setContent(
+ `
+
+
+ ${ru ? "Обсуждение сообщества Return YouTube Dislike." : "A community discussion hosted by Return YouTube Dislike."}
+ ${ru ? "Эти комментарии не связаны с комментариями YouTube." : "These comments are separate from YouTube."}
+
+
+ `,
+ { timeout: 5000 },
+ );
+ const panel = page.locator("#ryd-discussion-panel");
+ const geometry = () =>
+ page.evaluate(() => {
+ const rect = document.querySelector("#ryd-discussion-panel").getBoundingClientRect();
+ return {
+ x: rect.x,
+ width: rect.width,
+ right: rect.right,
+ viewport: innerWidth,
+ nativeWidth: document.querySelector("#native-parent").getBoundingClientRect().width,
+ };
+ });
+ const oldSizing = await page.addStyleTag({ content: "#ryd-discussion-panel{max-inline-size:none}" });
+ await panel.scrollIntoViewIfNeeded();
+ const before = await geometry();
+ assert.ok(
+ before.x < 0 || before.right > before.viewport,
+ "The native wide parent must reproduce the original overflow",
+ );
+ await page.screenshot({ path: path.join(output, `${language}-before.png`), timeout: 5000 });
+ await oldSizing.evaluate((node) => node.remove());
+ await panel.scrollIntoViewIfNeeded();
+ const after = await geometry();
+ assert.equal(after.width, 342);
+ assert.ok(after.x >= 0 && after.right <= after.viewport, "The narrow watch panel must fit inside the viewport");
+ assert.equal(after.nativeWidth, before.nativeWidth, "Native container geometry must remain unchanged");
+ await page.screenshot({ path: path.join(output, `${language}-after.png`), timeout: 5000 });
+ await page.setViewportSize({ width: 1536, height: 639 });
+ const desktop = await geometry();
+ assert.equal(desktop.width, desktop.nativeWidth, "Desktop watch panel must still fill its parent");
+ results.push({ language, before, after, desktop, passed: true });
+ }
+ await fs.writeFile(
+ path.join(output, "result.json"),
+ JSON.stringify({ kind: "hermetic-css-layout", results }, null, 2),
+ );
+ return { output, results };
+ } finally {
+ await browser.close();
+ }
+}
+
+if (require.main === module)
+ runWatchWidthSmoke()
+ .then((result) => process.stdout.write(`${JSON.stringify(result)}\n`))
+ .catch((error) => {
+ process.stderr.write(`${error.message}\n`);
+ process.exitCode = 1;
+ });
+
+module.exports = { runWatchWidthSmoke };
diff --git a/Extensions/e2e/hermetic-api-contract.js b/Extensions/e2e/hermetic-api-contract.js
index ac4e0ee..04a004b 100644
--- a/Extensions/e2e/hermetic-api-contract.js
+++ b/Extensions/e2e/hermetic-api-contract.js
@@ -1,6 +1,7 @@
const assert = require("node:assert/strict");
const API_METHODS_BY_PATH = Object.freeze({
+ "/api/discussion/capabilities": Object.freeze(["GET"]),
"/configs/selectors": Object.freeze(["GET"]),
"/interact/confirmVote": Object.freeze(["POST"]),
"/interact/vote": Object.freeze(["POST"]),
diff --git a/Extensions/e2e/hermetic-artifact-smoke.js b/Extensions/e2e/hermetic-artifact-smoke.js
index 717ed5e..f7ca855 100644
--- a/Extensions/e2e/hermetic-artifact-smoke.js
+++ b/Extensions/e2e/hermetic-artifact-smoke.js
@@ -619,6 +619,8 @@ async function startHermeticApiServer({ dislikes = 25, likes = 100 } = {}) {
}
let defaultBody;
if (request.method === "GET" && url.pathname === "/configs/selectors") defaultBody = {};
+ else if (request.method === "GET" && url.pathname === "/api/discussion/capabilities")
+ defaultBody = { enabled: false };
else if (request.method === "GET" && url.pathname === "/votes") {
defaultBody = { dislikes, likes, rating: 4.5 };
} else if (request.method === "GET" && url.pathname === "/puzzle/registration") {
diff --git a/Extensions/e2e/hermetic-artifact-smoke.spec.js b/Extensions/e2e/hermetic-artifact-smoke.spec.js
index 2b1978c..abc45dc 100644
--- a/Extensions/e2e/hermetic-artifact-smoke.spec.js
+++ b/Extensions/e2e/hermetic-artifact-smoke.spec.js
@@ -277,6 +277,21 @@ test("the loopback backend records and rejects an OPTIONS request to an unknown
}
});
+test("legacy artifact fixtures explicitly disable discussion and still reject discussion writes", async () => {
+ const apiServer = await startHermeticApiServer();
+ try {
+ const capabilities = await fetch(`${apiServer.origin}/api/discussion/capabilities`);
+ expect(capabilities.status).toBe(200);
+ expect(await capabilities.json()).toEqual({ enabled: false });
+ expect(apiServer.unexpectedRequests).toHaveLength(0);
+ const mutation = await fetch(`${apiServer.origin}/api/discussion/videos/abcdefghijk/comments`, { method: "POST" });
+ expect(mutation.status).toBe(404);
+ expect(apiServer.unexpectedRequests).toHaveLength(1);
+ } finally {
+ await apiServer.close();
+ }
+});
+
function createPreflightRoute(pathname, requestedMethod) {
const request = {
headers: () => ({ "access-control-request-method": requestedMethod }),
diff --git a/package-lock.json b/package-lock.json
index efe2f73..5601771 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"npm": "10.8.2"
},
"dependencies": {
+ "@microsoft/signalr": "9.0.6",
"country-code-lookup": "^0.1.3",
"echarts": "^5.5.0",
"topojson-client": "^3.1.0",
@@ -2502,6 +2503,40 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@microsoft/signalr": {
+ "version": "9.0.6",
+ "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-9.0.6.tgz",
+ "integrity": "sha512-DrhgzFWI9JE4RPTsHYRxh4yr+OhnwKz8bnJe7eIi7mLLjqhJpEb62CiUy/YbFvLqLzcGzlzz1QWgVAW0zyipMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "eventsource": "^2.0.2",
+ "fetch-cookie": "^2.0.3",
+ "node-fetch": "^2.6.7",
+ "ws": "^7.5.10"
+ }
+ },
+ "node_modules/@microsoft/signalr/node_modules/ws": {
+ "version": "7.5.13",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
+ "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.3.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"dev": true,
@@ -2985,7 +3020,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
@@ -4296,7 +4330,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4317,6 +4350,15 @@
"node": ">=0.8.x"
}
},
+ "node_modules/eventsource": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
+ "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/execa": {
"version": "5.1.1",
"dev": true,
@@ -4427,6 +4469,40 @@
"bser": "2.1.1"
}
},
+ "node_modules/fetch-cookie": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
+ "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
+ "license": "Unlicense",
+ "dependencies": {
+ "set-cookie-parser": "^2.4.8",
+ "tough-cookie": "^4.0.0"
+ }
+ },
+ "node_modules/fetch-cookie/node_modules/tough-cookie": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
+ "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "psl": "^1.1.33",
+ "punycode": "^2.1.1",
+ "universalify": "^0.2.0",
+ "url-parse": "^1.5.3"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fetch-cookie/node_modules/universalify": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
+ "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
"node_modules/filemanager-webpack-plugin": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/filemanager-webpack-plugin/-/filemanager-webpack-plugin-9.0.1.tgz",
@@ -6437,6 +6513,48 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/node-fetch/node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
+ "node_modules/node-fetch/node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/node-fetch/node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
"node_modules/node-int64": {
"version": "0.4.0",
"dev": true,
@@ -6802,16 +6920,33 @@
"node": ">= 6"
}
},
+ "node_modules/psl": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
+ "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/lupomontero"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
+ "node_modules/querystringify": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+ "license": "MIT"
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"dev": true,
@@ -6977,6 +7112,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/requires-port": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+ "license": "MIT"
+ },
"node_modules/resolve": {
"version": "1.20.0",
"dev": true,
@@ -7214,6 +7355,12 @@
"randombytes": "^2.1.0"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/shallow-clone": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
@@ -7890,6 +8037,16 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/url-parse": {
+ "version": "1.5.10",
+ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+ "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "querystringify": "^2.1.1",
+ "requires-port": "^1.0.0"
+ }
+ },
"node_modules/us-atlas": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/us-atlas/-/us-atlas-3.0.1.tgz",
diff --git a/package.json b/package.json
index 5b0df50..89160e5 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
},
"homepage": "https://github.com/Anarios/return-youtube-dislike#readme",
"dependencies": {
+ "@microsoft/signalr": "9.0.6",
"country-code-lookup": "^0.1.3",
"echarts": "^5.5.0",
"topojson-client": "^3.1.0",
diff --git a/webpack.config.js b/webpack.config.js
index 6975a51..c7d5935 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -20,7 +20,7 @@ const ignorePatterns = [
...entries.map((entry) => `**/${entry}.js`),
];
-const manifestTransform = (content, filename) => {
+const manifestTransform = (content, filename, discussionTestOrigin = "") => {
const filteredContent = content
.toString()
.split("\n")
@@ -29,6 +29,12 @@ const manifestTransform = (content, filename) => {
const manifestData = JSON.parse(filteredContent);
manifestData.version = extensionVersion;
+ if (discussionTestOrigin) {
+ const testUrl = new URL(discussionTestOrigin);
+ const permission = `${testUrl.protocol}//${testUrl.hostname}/*`;
+ const permissionField = manifestData.manifest_version === 3 ? "host_permissions" : "permissions";
+ manifestData[permissionField] = [...new Set([...(manifestData[permissionField] || []), permission])];
+ }
return JSON.stringify(manifestData, null, 2);
};
@@ -103,6 +109,22 @@ class ExtensionBuildReceiptPlugin {
module.exports = (env = {}, argv = {}) => {
const liveTestBuild = env.liveTest === true || env.liveTest === "true";
+ let discussionTestOrigin = "";
+ if (env.discussionTestOrigin) {
+ if (!liveTestBuild) throw new Error("A local discussion origin requires the explicit live-test build.");
+ const origin = new URL(env.discussionTestOrigin);
+ if (
+ !["http:", "https:"].includes(origin.protocol) ||
+ !["127.0.0.1", "localhost", "[::1]"].includes(origin.hostname) ||
+ origin.username ||
+ origin.password ||
+ origin.pathname !== "/" ||
+ origin.search ||
+ origin.hash
+ )
+ throw new Error("The discussion test origin must be a loopback HTTP origin without credentials or a path.");
+ discussionTestOrigin = origin.origin;
+ }
const liveBuildId = createLiveBuildId(liveTestBuild);
const buildMode = argv.mode || "production";
@@ -127,6 +149,7 @@ module.exports = (env = {}, argv = {}) => {
new webpack.DefinePlugin({
__RYD_LIVE_BUILD_ID__: JSON.stringify(liveBuildId),
__RYD_LIVE_TEST_BUILD__: JSON.stringify(liveTestBuild),
+ __RYD_DISCUSSION_TEST_ORIGIN__: JSON.stringify(discussionTestOrigin),
}),
...(liveTestBuild
? [
@@ -151,7 +174,7 @@ module.exports = (env = {}, argv = {}) => {
{
from: "./Extensions/combined/manifest-chrome.json",
to: "./chrome/manifest.json",
- transform: manifestTransform,
+ transform: (content, filename) => manifestTransform(content, filename, discussionTestOrigin),
},
{
from: "./Extensions/combined",
@@ -164,7 +187,7 @@ module.exports = (env = {}, argv = {}) => {
{
from: "./Extensions/combined/manifest-firefox.json",
to: "./firefox/manifest.json",
- transform: manifestTransform,
+ transform: (content, filename) => manifestTransform(content, filename, discussionTestOrigin),
},
{
from: "./Extensions/combined",
@@ -177,7 +200,7 @@ module.exports = (env = {}, argv = {}) => {
{
from: "./Extensions/combined/manifest-safari.json",
to: "./safari/manifest.json",
- transform: manifestTransform,
+ transform: (content, filename) => manifestTransform(content, filename, discussionTestOrigin),
},
],
}),
diff --git a/webpack.discussion.spec.js b/webpack.discussion.spec.js
new file mode 100644
index 0000000..0ad2119
--- /dev/null
+++ b/webpack.discussion.spec.js
@@ -0,0 +1,32 @@
+const fs = require("node:fs");
+const createBuild = require("./webpack.config");
+
+function builtManifest(env) {
+ const build = createBuild(env, { mode: "production" });
+ const copy = build.plugins.find((plugin) => plugin.constructor.name === "CopyPlugin");
+ const transform = copy.patterns.find((pattern) => pattern.to === "./chrome/manifest.json").transform;
+ return JSON.parse(transform(fs.readFileSync("Extensions/combined/manifest-chrome.json"), "manifest-chrome.json"));
+}
+
+test("production packaging never adds a local discussion host", () => {
+ const manifest = builtManifest({});
+ expect(manifest.host_permissions).toEqual(["*://*.youtube.com/*", "*://returnyoutubedislikeapi.com/*"]);
+ expect(manifest.minimum_chrome_version).toBe("116");
+ expect(manifest.permissions).toContain("alarms");
+ expect(manifest.web_accessible_resources[0].resources).toContain("comments-bridge.js");
+});
+
+test("local discussion host is available only in an explicit isolated test build", () => {
+ expect(() => createBuild({ discussionTestOrigin: "http://127.0.0.1:5099" })).toThrow(/explicit live-test/);
+ for (const origin of [
+ "https://example.com",
+ "http://localhost/path",
+ "http://user@localhost",
+ "http://localhost?token=secret",
+ ]) {
+ expect(() => createBuild({ liveTest: true, discussionTestOrigin: origin })).toThrow(/loopback HTTP origin/);
+ }
+ expect(builtManifest({ liveTest: true, discussionTestOrigin: "http://127.0.0.1:5099" }).host_permissions).toContain(
+ "http://127.0.0.1/*",
+ );
+});