Add comments, reactions, and notification popup

This commit is contained in:
Anarios
2026-10-17 21:42:01 +02:00
parent e500088e65
commit 5d69d01d67
48 changed files with 7692 additions and 52 deletions
@@ -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 };
}
@@ -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"
}
@@ -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"
}
+138
View File
@@ -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);
})();
+472
View File
@@ -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;
+12 -35
View File
@@ -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"
}
+9 -3
View File
@@ -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"]
}
+2 -2
View File
@@ -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"]
}
+20
View File
@@ -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;
+11
View File
@@ -5,6 +5,7 @@
<meta content="text/html; charset=utf-8" />
<title title="__MSG_extensionName__">__MSG_extensionName__</title>
<link rel="stylesheet" href="popup.css" />
<link rel="stylesheet" href="content-style.css" />
</head>
<!-- END HEADER -->
<body>
@@ -85,9 +86,19 @@
<button class="m3-btn" id="link_github" title="__MSG_linkGithub__">__MSG_linkGithub__</button>
<button class="m3-btn" style="margin-top: 0.3em" id="link_help" title="__MSG_linkHelp__">__MSG_linkHelp__</button>
<br />
<button
type="button"
id="ryd-discussion-inbox-toggle"
class="m3-btn"
aria-expanded="false"
aria-controls="ryd-discussion-popup"
>
__MSG_discussion_notifications__
</button>
<button class="m3-btn" style="margin-top: 0.3em" id="link_changelog" title="__MSG_linkChangelog__">
__MSG_linkChangelog__
</button>
<section id="ryd-discussion-popup" class="ryd-discussion-popup"></section>
<br />
<br />
+3
View File
@@ -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"),
+19 -2
View File
@@ -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);
}
+11 -1
View File
@@ -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 }));
},
);
@@ -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.
@@ -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 };
@@ -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();
});
});
@@ -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",
});
@@ -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));
}
});
@@ -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();
});
@@ -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 = `<ytd-watch-flexy video-id="${VIDEO}"><ytd-comments></ytd-comments></ytd-watch-flexy>`;
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 = `<ytd-reel-video-renderer video-id="${VIDEO}"><reel-action-bar-view-model></reel-action-bar-view-model></ytd-reel-video-renderer>`;
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 = '<ytd-reel-video-renderer video-id="zyxwvutsrqp"></ytd-reel-video-renderer>';
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 =
"<ytd-reel-video-renderer id='retained'><reel-action-bar-view-model></reel-action-bar-view-model></ytd-reel-video-renderer><ytd-reel-video-renderer id='current'><div id='shorts-player'></div><reel-action-bar-view-model></reel-action-bar-view-model></ytd-reel-video-renderer>";
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 });
});
@@ -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);
}
@@ -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);
});
@@ -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;
}
@@ -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 = "<ytd-comments></ytd-comments>";
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 =
`<ytd-message-renderer><span>${text}</span><a href="https://support.google.com/youtube/answer/9706180?hl=ru">Подробнее</a></ytd-message-renderer>`;
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 = "<ytd-message-renderer>Loading comments</ytd-message-renderer>";
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 =
"<ytd-reel-video-renderer is-active><reel-action-bar-view-model></reel-action-bar-view-model></ytd-reel-video-renderer>";
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");
});
+72
View File
@@ -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;
}
@@ -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();
});
+174
View File
@@ -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);
}
@@ -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: "<img src=x>" }], 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("<img src=x>");
});
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);
});
+284
View File
@@ -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;
}
@@ -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 = `<ytd-watch-flexy video-id="${VIDEO}"><ytd-comments></ytd-comments></ytd-watch-flexy>`;
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 = `<ytd-reel-video-renderer video-id="${videoId}"><reel-action-bar-view-model></reel-action-bar-view-model></ytd-reel-video-renderer>`;
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);
});
@@ -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);
}
+700
View File
@@ -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();
},
};
}
@@ -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: '<img src=x onerror="bad()">\nSecond line',
author: { id: PROFILE, nickname: "<script>bad()</script>" },
}),
]);
expect(root.querySelector(".ryd-discussion-comment-text").textContent).toContain("<img");
expect(root.querySelector("img, script")).toBeNull();
});
test("keeps a failed submission draft and reuses its idempotency ID on retry", async () => {
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");
});
@@ -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 },
);
}
+6 -1
View File
@@ -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 };
+22
View File
@@ -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,
+51
View File
@@ -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.
@@ -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 `<!doctype html><html lang="${language}"${dark ? " dark" : ""}><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Discussion artifact fixture</title><style>
*{box-sizing:border-box} body{margin:0;padding:24px;font:14px Arial,sans-serif;background:${dark ? "#0f0f0f" : "#fff"};color:${dark ? "#f1f1f1" : "#0f0f0f"}}
[hidden]{display:none!important} ytd-watch-flexy,ytd-reel-video-renderer,ytd-comments,reel-action-bar-view-model{display:block}
ytd-watch-flexy{width:min(920px,100%);margin:auto} ytd-comments{min-height:42px;margin-top:20px}
#movie_player{height:120px;background:#222;margin-bottom:20px} #shorts-player{height:280px;background:#222;width:260px}
ytd-reel-video-renderer{width:320px;max-width:100%;height:500px;margin:auto} reel-action-bar-view-model{min-height:80px}
</style></head><body><main id="fixture-root"></main><script>
(()=>{const o=${options};const root=document.createElement(o.surface==='watch'?'ytd-watch-flexy':'ytd-reel-video-renderer');
const player=document.createElement('div');player.id=o.surface==='watch'?'movie_player':'shorts-player';
player.getVideoData=()=>({video_id:o.videoId});player.getPlayerResponse=()=>({videoDetails:{videoId:o.videoId,...(o.kids?{isMadeForKids:true}:{})},
endscreen:{videos:[{videoId:'OtherVideo1',isMadeForKids:true}]},adPlacements:[{videoDetails:{videoId:'OtherVideo2',madeForKids:true}}],
microformat:{playerMicroformatRenderer:{recommendations:[{isMadeForKids:true}]}}});
root.append(player);root.polymerController={data:o.kids?{isMadeForKids:true}:{}};
if(o.surface==='watch'){root.setAttribute('video-id',o.videoId);const comments=document.createElement('ytd-comments');
comments.textContent=o.disabled?(o.language==='ru'?'Комментарии отключены.':'Comments are turned off.'):'Native comments enabled';
comments.polymerController={data:{contents:o.disabled?[{messageRenderer:{text:{runs:[{navigationEndpoint:{urlEndpoint:{url:'https://support.google.com/youtube/answer/9706180?hl='+o.language}}}]}}}]:[]}};root.append(comments);}
else{const actions=document.createElement('reel-action-bar-view-model');actions.polymerController={data:{buttonViewModels:[{buttonViewModel:{iconName:'SHORTS_COMMENT',state:o.disabled?'BUTTON_VIEW_MODEL_STATE_DISABLED':'BUTTON_VIEW_MODEL_STATE_ENABLED'}}]}};root.append(actions);}
document.querySelector('#fixture-root').append(root);window.ytInitialPlayerResponse=player.getPlayerResponse();
window.__discussionFixture={setKids(value){root.polymerController.data=value?{isMadeForKids:true}:{};o.kids=value;window.ytInitialPlayerResponse=player.getPlayerResponse();document.dispatchEvent(new Event('yt-page-data-updated'));},
setLanguage(value){document.documentElement.lang=value;document.dispatchEvent(new Event('yt-page-data-updated'));}};
})();</script></body></html>`;
}
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 };
File diff suppressed because it is too large Load Diff
@@ -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();
}
});
@@ -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(
`<!doctype html><html lang="${language}" dark><head><meta charset="utf-8"><style>
body{margin:0;padding:24px;display:flex;justify-content:center;font:14px Arial,sans-serif;background:#0f0f0f;color:#f1f1f1}
#native-parent{width:426.666px;min-width:426.666px;flex-shrink:0}
#native-comments{height:52px} ${css}
</style></head><body><main id="native-parent"><div id="native-comments">${ru ? "Комментарии отключены." : "Comments are turned off."}</div>
<section id="ryd-discussion-panel" class="ryd-discussion">
<header class="ryd-discussion-header"><div class="ryd-discussion-brand"><span class="ryd-discussion-logo">RYD</span><h2 class="ryd-discussion-title">${ru ? "Комментарии RYD" : "RYD comments"}</h2></div>
<nav class="ryd-discussion-header-actions"><button class="ryd-discussion-button">${ru ? "Обсуждение" : "Discussion"}</button><button class="ryd-discussion-button">${ru ? "Уведомления" : "Notifications"}</button><button class="ryd-discussion-button">${ru ? "Модерация" : "Moderation"}</button></nav></header>
<p class="ryd-discussion-description">${ru ? "Обсуждение сообщества Return YouTube Dislike." : "A community discussion hosted by Return YouTube Dislike."}</p>
<p class="ryd-discussion-independent">${ru ? "Эти комментарии не связаны с комментариями YouTube." : "These comments are separate from YouTube."}</p>
<textarea class="ryd-discussion-textarea">${ru ? "Проверка ширины комментариев" : "Discussion width check"}</textarea>
<div class="ryd-discussion-form-actions"><button class="ryd-discussion-button ryd-discussion-primary">${ru ? "Отправить" : "Post"}</button></div>
</section></main></body></html>`,
{ 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 };
+1
View File
@@ -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"]),
@@ -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") {
@@ -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 }),
+160 -3
View File
@@ -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",
+1
View File
@@ -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",
+27 -4
View File
@@ -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),
},
],
}),
+32
View File
@@ -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/*",
);
});