mirror of
https://github.com/Anarios/return-youtube-dislike.git
synced 2026-10-27 23:31:06 +01:00
Add comments, reactions, and notification popup
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 }),
|
||||
|
||||
Generated
+160
-3
@@ -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",
|
||||
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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/*",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user