diff --git a/Extensions/UserScript/Return Youtube Dislike.user.js b/Extensions/UserScript/Return Youtube Dislike.user.js index 7a728d9..41f89c6 100644 --- a/Extensions/UserScript/Return Youtube Dislike.user.js +++ b/Extensions/UserScript/Return Youtube Dislike.user.js @@ -442,7 +442,12 @@ function createVoteClient({ return current; } - return { ensureRegistered, submitVote }; + async function getRegisteredUser() { + const credential = await credentialStore.load(); + return isConfirmedCredential(credential) ? { userId: credential.userId } : null; + } + + return { ensureRegistered, getRegisteredUser, submitVote }; } diff --git a/Extensions/combined/_locales/en/messages.json b/Extensions/combined/_locales/en/messages.json index 5001b5e..2a7460f 100644 --- a/Extensions/combined/_locales/en/messages.json +++ b/Extensions/combined/_locales/en/messages.json @@ -587,6 +587,104 @@ "hideClutterButtonsAfter": { "message": "After: only Like and Dislike stay visible" }, + "discussion_title": { "message": "RYD comments" }, + "discussion_subtitle": { "message": "A community discussion hosted by Return YouTube Dislike." }, + "discussion_independent": { "message": "These comments are separate from YouTube." }, + "discussion_open": { "message": "Open RYD comments" }, + "discussion_close": { "message": "Close" }, + "discussion_new": { "message": "Newest" }, + "discussion_top": { "message": "Top" }, + "discussion_sort": { "message": "Sort comments" }, + "discussion_refresh": { "message": "Refresh" }, + "discussion_loading": { "message": "Loading comments…" }, + "discussion_empty": { "message": "No comments yet. Start the conversation." }, + "discussion_replyEmpty": { "message": "No replies yet." }, + "discussion_comment": { "message": "Comment" }, + "discussion_post": { "message": "Post comment" }, + "discussion_reply": { "message": "Reply" }, + "discussion_postReply": { "message": "Post reply" }, + "discussion_cancel": { "message": "Cancel" }, + "discussion_save": { "message": "Save" }, + "discussion_edit": { "message": "Edit" }, + "discussion_delete": { "message": "Delete" }, + "discussion_deleteConfirm": { "message": "Delete this comment?" }, + "discussion_deleted": { "message": "Comment deleted" }, + "discussion_edited": { "message": "edited" }, + "discussion_replies": { "message": "Replies ($1)" }, + "discussion_more": { "message": "Load more" }, + "discussion_like": { "message": "Like" }, + "discussion_dislike": { "message": "Dislike" }, + "discussion_reacting": { "message": "Saving reaction…" }, + "discussion_signInRequired": { "message": "Sign in from the extension popup to continue." }, + "discussion_tierRequired": { "message": "Posting is available to active Commenter members and above." }, + "discussion_join": { "message": "Become a Commenter" }, + "discussion_gate": { "message": "Everyone can read and react. Active Commenter members and above can post." }, + "discussion_nicknameRequired": { "message": "Choose your public RYD nickname before posting." }, + "discussion_nicknameLabel": { "message": "Public RYD nickname" }, + "discussion_nicknameHint": { + "message": "3–32 letters, numbers, spaces, dots, dashes or underscores. This name will appear with your comments." + }, + "discussion_nicknameSave": { "message": "Save nickname" }, + "discussion_nicknameTaken": { "message": "This nickname is already taken. Choose another." }, + "discussion_nicknameInvalid": { "message": "Use 3–32 letters, numbers, spaces, dots, dashes or underscores." }, + "discussion_nicknameChange": { "message": "Change nickname" }, + "discussion_as": { "message": "Commenting as $1" }, + "discussion_placeholder": { "message": "Add to the discussion…" }, + "discussion_characters": { "message": "$1 / $2" }, + "discussion_report": { "message": "Report" }, + "discussion_reportTitle": { "message": "Report comment" }, + "discussion_reportReason": { "message": "Reason" }, + "discussion_reportSpam": { "message": "Spam" }, + "discussion_reportAbuse": { "message": "Harassment or hate" }, + "discussion_reportOther": { "message": "Other" }, + "discussion_reportDetails": { "message": "Additional details (optional)" }, + "discussion_reportSend": { "message": "Send report" }, + "discussion_reportDone": { "message": "Report received. Thank you." }, + "discussion_moderation": { "message": "Moderation" }, + "discussion_queueEmpty": { "message": "No reports to review." }, + "discussion_hide": { "message": "Hide comment" }, + "discussion_restore": { "message": "Restore comment" }, + "discussion_dismiss": { "message": "Dismiss reports" }, + "discussion_ban": { "message": "Ban author" }, + "discussion_unban": { "message": "Unban author" }, + "discussion_moderationReason": { "message": "Moderation reason" }, + "discussion_moderationSaved": { "message": "Moderation action saved." }, + "discussion_reported": { "message": "$1 reports" }, + "discussion_unavailable": { "message": "RYD comments are unavailable for this video." }, + "discussion_commentUnavailable": { "message": "This comment is no longer available." }, + "discussion_error": { "message": "Unable to connect. Please try again." }, + "discussion_rateLimited": { "message": "Please wait a moment before trying again." }, + "discussion_conflict": { "message": "This comment changed. Refresh and try again." }, + "discussion_forbidden": { "message": "This action is not available for your account." }, + "discussion_retry": { "message": "Try again" }, + "discussion_notifications": { "message": "Notifications" }, + "discussion_notificationEmpty": { "message": "You're all caught up." }, + "discussion_notificationReply": { "message": "$1 replied to your comment" }, + "discussion_notificationLike": { "message": "Someone liked your comment" }, + "discussion_notificationLikes": { "message": "Your comment received $1 likes" }, + "discussion_notificationDislike": { "message": "Someone disliked your comment" }, + "discussion_notificationReaction": { "message": "Someone reacted to your comment" }, + "discussion_someone": { "message": "Someone" }, + "discussion_markRead": { "message": "Mark read" }, + "discussion_markAllRead": { "message": "Mark all read" }, + "discussion_view": { "message": "View discussion" }, + "discussion_unread": { "message": "$1 unread notifications" }, + "discussion_signedOutInbox": { "message": "Sign in to see replies and reactions to your comments." }, + "discussion_notificationUnavailable": { "message": "This discussion is no longer available." }, + "discussion_discussionTab": { "message": "Discussion" }, + "discussion_nickname": { "message": "Nickname" }, + "discussion_reportCount": { "message": "Reports" }, + "discussion_reportStatus": { "message": "Report status" }, + "discussion_openReports": { "message": "Open reports" }, + "discussion_resolvedReports": { "message": "Dismissed reports" }, + "discussion_author": { "message": "Author" }, + "discussion_me": { "message": "You" }, + "discussion_saving": { "message": "Saving…" }, + "discussion_confirm": { "message": "Confirm" }, + "discussion_notificationUpdated": { "message": "New activity on your comments" }, + "discussion_showThread": { "message": "Show thread" }, + "discussion_loadReplies": { "message": "Show replies ($1)" }, + "patreonTierCommenter": { "message": "Commenter" }, "changelog_brand_logo_alt": { "message": "Return YouTube Dislike logo" } diff --git a/Extensions/combined/_locales/ru/messages.json b/Extensions/combined/_locales/ru/messages.json index 0e4ff5d..a38dfd6 100644 --- a/Extensions/combined/_locales/ru/messages.json +++ b/Extensions/combined/_locales/ru/messages.json @@ -566,6 +566,108 @@ "hideClutterButtonsAfter": { "message": "After: only Like and Dislike stay visible" }, + "discussion_title": { "message": "Комментарии RYD" }, + "discussion_subtitle": { "message": "Обсуждение сообщества Return YouTube Dislike." }, + "discussion_independent": { "message": "Эти комментарии не связаны с комментариями YouTube." }, + "discussion_open": { "message": "Открыть комментарии RYD" }, + "discussion_close": { "message": "Закрыть" }, + "discussion_new": { "message": "Сначала новые" }, + "discussion_top": { "message": "Популярные" }, + "discussion_sort": { "message": "Сортировка комментариев" }, + "discussion_refresh": { "message": "Обновить" }, + "discussion_loading": { "message": "Загрузка комментариев…" }, + "discussion_empty": { "message": "Комментариев пока нет. Начните обсуждение." }, + "discussion_replyEmpty": { "message": "Ответов пока нет." }, + "discussion_comment": { "message": "Комментарий" }, + "discussion_post": { "message": "Отправить" }, + "discussion_reply": { "message": "Ответить" }, + "discussion_postReply": { "message": "Отправить ответ" }, + "discussion_cancel": { "message": "Отмена" }, + "discussion_save": { "message": "Сохранить" }, + "discussion_edit": { "message": "Изменить" }, + "discussion_delete": { "message": "Удалить" }, + "discussion_deleteConfirm": { "message": "Удалить этот комментарий?" }, + "discussion_deleted": { "message": "Комментарий удалён" }, + "discussion_edited": { "message": "изменён" }, + "discussion_replies": { "message": "Ответы ($1)" }, + "discussion_more": { "message": "Показать ещё" }, + "discussion_like": { "message": "Нравится" }, + "discussion_dislike": { "message": "Не нравится" }, + "discussion_reacting": { "message": "Сохранение реакции…" }, + "discussion_signInRequired": { "message": "Чтобы продолжить, войдите в аккаунт через меню расширения." }, + "discussion_tierRequired": { "message": "Писать могут участники с активным уровнем Commenter и выше." }, + "discussion_join": { "message": "Стать участником Commenter" }, + "discussion_gate": { + "message": "Читать и оценивать могут все. Писать могут участники с активным уровнем Commenter и выше." + }, + "discussion_nicknameRequired": { "message": "Перед отправкой выберите публичный псевдоним RYD." }, + "discussion_nicknameLabel": { "message": "Публичный псевдоним RYD" }, + "discussion_nicknameHint": { + "message": "От 3 до 32 букв, цифр, пробелов, точек, дефисов или знаков подчёркивания. Это имя будет видно рядом с вашими комментариями." + }, + "discussion_nicknameSave": { "message": "Сохранить псевдоним" }, + "discussion_nicknameTaken": { "message": "Этот псевдоним уже занят. Выберите другой." }, + "discussion_nicknameInvalid": { + "message": "Используйте от 3 до 32 букв, цифр, пробелов, точек, дефисов или знаков подчёркивания." + }, + "discussion_nicknameChange": { "message": "Изменить псевдоним" }, + "discussion_as": { "message": "Вы пишете как $1" }, + "discussion_placeholder": { "message": "Добавьте комментарий…" }, + "discussion_characters": { "message": "$1 / $2" }, + "discussion_report": { "message": "Пожаловаться" }, + "discussion_reportTitle": { "message": "Жалоба на комментарий" }, + "discussion_reportReason": { "message": "Причина" }, + "discussion_reportSpam": { "message": "Спам" }, + "discussion_reportAbuse": { "message": "Оскорбления или ненависть" }, + "discussion_reportOther": { "message": "Другое" }, + "discussion_reportDetails": { "message": "Дополнительные сведения (необязательно)" }, + "discussion_reportSend": { "message": "Отправить жалобу" }, + "discussion_reportDone": { "message": "Жалоба отправлена. Спасибо." }, + "discussion_moderation": { "message": "Модерация" }, + "discussion_queueEmpty": { "message": "Новых жалоб нет." }, + "discussion_hide": { "message": "Скрыть комментарий" }, + "discussion_restore": { "message": "Восстановить комментарий" }, + "discussion_dismiss": { "message": "Отклонить жалобы" }, + "discussion_ban": { "message": "Заблокировать автора" }, + "discussion_unban": { "message": "Разблокировать автора" }, + "discussion_moderationReason": { "message": "Причина модерации" }, + "discussion_moderationSaved": { "message": "Действие модератора сохранено." }, + "discussion_reported": { "message": "Жалоб: $1" }, + "discussion_unavailable": { "message": "Комментарии RYD недоступны для этого видео." }, + "discussion_commentUnavailable": { "message": "Этот комментарий больше недоступен." }, + "discussion_error": { "message": "Не удалось подключиться. Попробуйте ещё раз." }, + "discussion_rateLimited": { "message": "Немного подождите и попробуйте снова." }, + "discussion_conflict": { "message": "Комментарий изменился. Обновите страницу и попробуйте снова." }, + "discussion_forbidden": { "message": "Это действие недоступно для вашего аккаунта." }, + "discussion_retry": { "message": "Повторить" }, + "discussion_notifications": { "message": "Уведомления" }, + "discussion_notificationEmpty": { "message": "Новых уведомлений нет." }, + "discussion_notificationReply": { "message": "$1 ответил(а) на ваш комментарий" }, + "discussion_notificationLike": { "message": "Кому-то понравился ваш комментарий" }, + "discussion_notificationLikes": { "message": "Новых лайков у вашего комментария: $1" }, + "discussion_notificationDislike": { "message": "Кому-то не понравился ваш комментарий" }, + "discussion_notificationReaction": { "message": "Кто-то оценил ваш комментарий" }, + "discussion_someone": { "message": "Кто-то" }, + "discussion_markRead": { "message": "Отметить прочитанным" }, + "discussion_markAllRead": { "message": "Прочитать все" }, + "discussion_view": { "message": "Открыть обсуждение" }, + "discussion_unread": { "message": "Непрочитанных уведомлений: $1" }, + "discussion_signedOutInbox": { "message": "Войдите, чтобы видеть ответы и реакции на ваши комментарии." }, + "discussion_notificationUnavailable": { "message": "Это обсуждение больше недоступно." }, + "discussion_discussionTab": { "message": "Обсуждение" }, + "discussion_nickname": { "message": "Псевдоним" }, + "discussion_reportCount": { "message": "Жалобы" }, + "discussion_reportStatus": { "message": "Статус жалоб" }, + "discussion_openReports": { "message": "Открытые жалобы" }, + "discussion_resolvedReports": { "message": "Закрытые жалобы" }, + "discussion_author": { "message": "Автор" }, + "discussion_me": { "message": "Вы" }, + "discussion_saving": { "message": "Сохранение…" }, + "discussion_confirm": { "message": "Подтвердить" }, + "discussion_notificationUpdated": { "message": "Новая активность в ваших комментариях" }, + "discussion_showThread": { "message": "Показать ветку" }, + "discussion_loadReplies": { "message": "Показать ответы ($1)" }, + "patreonTierCommenter": { "message": "Commenter" }, "changelog_brand_logo_alt": { "message": "Логотип Return YouTube Dislike" } diff --git a/Extensions/combined/comments-bridge.js b/Extensions/combined/comments-bridge.js new file mode 100644 index 0000000..ba15187 --- /dev/null +++ b/Extensions/combined/comments-bridge.js @@ -0,0 +1,138 @@ +(() => { + const REQUEST = "ryd-discussion-state-request"; + const ATTRIBUTE = "data-ryd-discussion-native-state"; + const INSTALL = Symbol.for("return-youtube-dislike.discussion-bridge"); + if (window[INSTALL]) return; + window[INSTALL] = true; + + function data(element) { + return element?.polymerController?.data || element?.data || null; + } + + function hasDisabledNotice(value, depth = 0) { + if (!value || typeof value !== "object" || depth > 14) return false; + if (value.messageRenderer) { + const runs = value.messageRenderer.text?.runs || []; + if ( + runs.some((run) => { + try { + const url = new URL(run.navigationEndpoint?.urlEndpoint?.url); + return url.hostname === "support.google.com" && url.pathname === "/youtube/answer/9706180"; + } catch { + return false; + } + }) + ) + return true; + } + return Object.values(value).some((child) => hasDisabledNotice(child, depth + 1)); + } + + function positiveKidsFlag(value) { + return ["isMadeForKids", "madeForKids", "is_made_for_kids"].some((key) => value?.[key] === true); + } + + function hasKidsMiniplayerNotice(response) { + const renderer = response?.playabilityStatus?.miniplayer?.miniplayerRenderer; + if (renderer?.playbackMode !== "PLAYBACK_MODE_PAUSED_ONLY") return false; + const warning = renderer.minimizedEndpoint?.openPopupAction?.popup?.notificationActionRenderer; + const command = warning?.actionButton?.buttonRenderer?.command; + return [command?.urlEndpoint?.url, command?.commandMetadata?.webCommandMetadata?.url].some((value) => { + if (typeof value !== "string") return false; + try { + const url = new URL(value, "https://www.youtube.com"); + return ( + url.protocol === "https:" && + url.hostname === "support.google.com" && + (url.pathname === "/youtube/answer/9632097" || + (url.pathname === "/youtube/bin/answer.py" && url.searchParams.get("answer") === "9632097")) + ); + } catch { + return false; + } + }); + } + + function isMadeForKids(root, videoId) { + if ( + ["is-made-for-kids", "made-for-kids"].some( + (name) => root.hasAttribute(name) && root.getAttribute(name) !== "false", + ) + ) + return true; + const controller = root.polymerController || root; + const rootData = data(root); + if ( + positiveKidsFlag(rootData) || + ((!rootData?.videoDetails?.videoId || rootData.videoDetails.videoId === videoId) && + positiveKidsFlag(rootData?.videoDetails)) + ) + return true; + const player = root.querySelector("#movie_player, #shorts-player") || document.querySelector("#shorts-player"); + const candidates = [controller.playerData, controller.playerResponse, data(root)?.playerResponse]; + try { + candidates.push(player?.getPlayerResponse?.()); + } catch { + /* Player may be between videos. */ + } + if (window.ytInitialPlayerResponse?.videoDetails?.videoId === videoId) + candidates.push(window.ytInitialPlayerResponse); + return candidates.some((value) => { + if (value?.videoDetails?.videoId !== videoId) return false; + const microformat = value.microformat?.playerMicroformatRenderer; + return ( + positiveKidsFlag(value) || + positiveKidsFlag(value.videoDetails) || + hasKidsMiniplayerNotice(value) || + ((!microformat?.videoId || microformat.videoId === videoId) && positiveKidsFlag(microformat)) + ); + }); + } + + function inspect(event) { + const root = event.target; + if (!(root instanceof Element) || !root.matches("ytd-watch-flexy, ytd-watch-grid, ytd-reel-video-renderer")) return; + const url = new URL(location.href); + const shorts = url.pathname.startsWith("/shorts/"); + const videoId = shorts ? url.pathname.split("/")[2] : url.searchParams.get("v"); + if (!/^[A-Za-z0-9_-]{11}$/.test(videoId || "")) return; + let disabled = false; + if (shorts) { + const player = document.querySelector("#shorts-player"); + let playerId; + try { + playerId = player?.getVideoData?.()?.video_id; + } catch { + /* Player has not hydrated yet. */ + } + const rootData = data(root); + const identity = root.getAttribute("video-id") || rootData?.videoId || rootData?.videoDetails?.videoId; + const linked = [...root.querySelectorAll("a[href*='/shorts/']")].some((anchor) => { + try { + return new URL(anchor.href).pathname === `/shorts/${videoId}`; + } catch { + return false; + } + }); + const ownsCurrentPlayer = playerId === videoId && root.contains(player); + if (identity ? identity !== videoId : !linked && !ownsCurrentPlayer) return; + const actionData = data(root.querySelector("reel-action-bar-view-model")); + disabled = (actionData?.buttonViewModels || []).some((item) => { + const button = item.buttonViewModel; + return button?.iconName === "SHORTS_COMMENT" && button.state === "BUTTON_VIEW_MODEL_STATE_DISABLED"; + }); + const overlayButtons = + rootData?.overlay?.reelPlayerOverlayRenderer?.buttonBar?.reelActionBarViewModel?.buttonViewModels || []; + disabled ||= overlayButtons.some( + (item) => + item.buttonViewModel?.iconName === "SHORTS_COMMENT" && + item.buttonViewModel.state === "BUTTON_VIEW_MODEL_STATE_DISABLED", + ); + } else { + if (url.pathname !== "/watch" || root.getAttribute("video-id") !== videoId) return; + disabled = [...root.querySelectorAll("ytd-comments")].some((comments) => hasDisabledNotice(data(comments))); + } + root.setAttribute(ATTRIBUTE, JSON.stringify({ videoId, disabled, madeForKids: isMadeForKids(root, videoId) })); + } + document.addEventListener(REQUEST, inspect, true); +})(); diff --git a/Extensions/combined/content-style.css b/Extensions/combined/content-style.css index 784275c..fb272a1 100644 --- a/Extensions/combined/content-style.css +++ b/Extensions/combined/content-style.css @@ -9,6 +9,478 @@ --yt-spec-brand-background-secondary: #000 !important; */ } +.ryd-discussion, +.ryd-discussion-popup, +.ryd-discussion-toast { + --ryd-discussion-bg: var(--yt-spec-base-background, #fff); + --ryd-discussion-fg: var(--yt-spec-text-primary, #0f0f0f); + --ryd-discussion-muted: var(--yt-spec-text-secondary, #606060); + --ryd-discussion-surface: var(--yt-spec-badge-chip-background, #f2f2f2); + --ryd-discussion-line: var(--yt-spec-10-percent-layer, #dedede); + --ryd-discussion-accent: var(--yt-spec-call-to-action, #065fd4); + --ryd-discussion-on-accent: var(--yt-spec-inverted-text-primary, #fff); + color-scheme: light; + box-sizing: border-box; + color: var(--ryd-discussion-fg); + font: + 14px/1.5 Roboto, + Arial, + sans-serif; + text-align: start; +} + +html[dark] .ryd-discussion, +html[dark] .ryd-discussion-popup, +html[dark] .ryd-discussion-toast { + --ryd-discussion-bg: var(--yt-spec-base-background, #0f0f0f); + --ryd-discussion-fg: var(--yt-spec-text-primary, #f1f1f1); + --ryd-discussion-muted: var(--yt-spec-text-secondary, #aaa); + --ryd-discussion-surface: var(--yt-spec-badge-chip-background, #272727); + --ryd-discussion-line: var(--yt-spec-10-percent-layer, #3f3f3f); + --ryd-discussion-accent: var(--yt-spec-call-to-action, #3ea6ff); + --ryd-discussion-on-accent: var(--yt-spec-inverted-text-primary, #0f0f0f); + color-scheme: dark; +} + +.ryd-discussion *, +.ryd-discussion-popup *, +.ryd-discussion-toast * { + box-sizing: border-box; +} +.ryd-discussion [hidden], +.ryd-discussion[hidden], +.ryd-discussion-popup [hidden], +.ryd-discussion-backdrop[hidden] { + display: none !important; +} +.ryd-discussion { + width: 100%; + margin: 20px 0 32px; + padding: 20px 0; + border-top: 1px solid var(--ryd-discussion-line); +} +.ryd-discussion-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} +.ryd-discussion-brand, +.ryd-discussion-header-actions, +.ryd-discussion-toolbar, +.ryd-discussion-form-actions, +.ryd-discussion-comment-actions, +.ryd-discussion-account, +.ryd-discussion-comment-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} +.ryd-discussion-header-actions { + gap: 2px; +} +.ryd-discussion-title { + margin: 0; + font-size: 20px; + font-weight: 600; + line-height: 1.3; +} +.ryd-discussion-logo { + padding: 4px 6px; + background: #d92323; + color: #fff; + font-size: 10px; + font-weight: 800; + border-radius: 6px; + letter-spacing: -0.2px; +} +.ryd-discussion-description { + margin: 10px 0 0; + color: var(--ryd-discussion-muted); + font-size: 13px; +} +.ryd-discussion-independent { + margin: 2px 0 20px; + color: var(--ryd-discussion-muted); + font-size: 12px; +} +.ryd-discussion-subtitle { + margin: 0; + font-size: 16px; + font-weight: 600; +} +.ryd-discussion-button, +.ryd-discussion-close { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + border: 0; + border-radius: 18px; + background: transparent; + color: inherit; + min-height: 34px; + padding: 6px 12px; + font: inherit; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + cursor: pointer; + text-decoration: none; + white-space: normal; +} +.ryd-discussion-button:hover, +.ryd-discussion-close:hover { + background: var(--ryd-discussion-surface); +} +.ryd-discussion-button[aria-pressed="true"] { + background: var(--ryd-discussion-surface); + color: var(--ryd-discussion-accent); +} +.ryd-discussion-button:disabled { + opacity: 0.5; + cursor: default; +} +.ryd-discussion-button:focus-visible, +.ryd-discussion-close:focus-visible, +.ryd-discussion-select:focus-visible, +.ryd-discussion-textarea:focus-visible, +.ryd-discussion-input:focus-visible, +.ryd-discussion-launcher:focus-visible, +.ryd-discussion-notification-link:focus-visible { + outline: 2px solid var(--ryd-discussion-accent); + outline-offset: 2px; +} +.ryd-discussion-primary { + background: var(--ryd-discussion-accent); + color: var(--ryd-discussion-on-accent); + padding-inline: 16px; +} +.ryd-discussion-primary:hover { + background: var(--ryd-discussion-accent); + filter: brightness(0.92); +} +.ryd-discussion-danger { + color: #dc3232; +} +.ryd-discussion-close { + font-size: 26px; + padding: 0; + height: 36px; + width: 36px; + min-width: 36px; +} +.ryd-discussion-composer { + margin-bottom: 20px; +} +.ryd-discussion-gate { + margin: 0 0 6px; + color: var(--ryd-discussion-muted); + font-size: 13px; +} +.ryd-discussion-link, +.ryd-discussion-notification-link { + color: var(--ryd-discussion-accent); + text-decoration: none; +} +.ryd-discussion-link:hover, +.ryd-discussion-notification-link:hover { + text-decoration: underline; +} +.ryd-discussion-form { + margin: 10px 0; + width: 100%; +} +.ryd-discussion-label { + display: block; + width: 100%; + color: var(--ryd-discussion-fg); + font-size: 13px; + margin: 6px 0; +} +.ryd-discussion-textarea, +.ryd-discussion-input { + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + margin-top: 6px; + border: 1px solid var(--ryd-discussion-line); + border-radius: 10px; + background: var(--ryd-discussion-bg); + color: var(--ryd-discussion-fg); + padding: 10px 12px; + font: inherit; + line-height: 1.5; +} +.ryd-discussion-textarea { + resize: vertical; + min-height: 78px; +} +.ryd-discussion-input { + max-width: 420px; +} +.ryd-discussion-textarea::placeholder { + color: var(--ryd-discussion-muted); +} +.ryd-discussion-form-actions { + justify-content: flex-end; + margin-top: 6px; +} +.ryd-discussion-counter { + margin-inline-end: auto; + color: var(--ryd-discussion-muted); + font-size: 11px; +} +.ryd-discussion-toolbar { + justify-content: space-between; + margin: 16px 0 12px; +} +.ryd-discussion-select { + border: 0; + border-radius: 8px; + padding: 8px 28px 8px 10px; + background: var(--ryd-discussion-surface); + color: inherit; + font: inherit; + font-size: 13px; +} +.ryd-discussion-status { + margin: 8px 0; + color: var(--ryd-discussion-muted); + font-size: 13px; +} +.ryd-discussion-status:empty { + display: none; +} +.ryd-discussion-comment { + padding: 16px 0; + border-bottom: 1px solid var(--ryd-discussion-line); + min-width: 0; +} +.ryd-discussion-comment:last-child { + border-bottom: 0; +} +.ryd-discussion-comment-heading { + gap: 8px; +} +.ryd-discussion-author { + font-size: 13px; + font-weight: 600; + overflow-wrap: anywhere; +} +.ryd-discussion-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + min-width: 28px; + border-radius: 50%; + color: var(--ryd-discussion-muted); + background: var(--ryd-discussion-surface); + font-size: 13px; + font-weight: 500; +} +.ryd-discussion-meta { + color: var(--ryd-discussion-muted); + font-size: 11px; +} +.ryd-discussion-comment-text { + margin: 8px 0 6px; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: normal; + font-size: 14px; + line-height: 1.55; +} +.ryd-discussion-comment-actions { + gap: 2px; +} +.ryd-discussion-comment-actions .ryd-discussion-button { + padding-inline: 9px; +} +.ryd-discussion-thumb { + width: 18px; + height: 18px; + fill: currentColor; + flex-shrink: 0; +} +.ryd-discussion-thumb.is-down { + transform: rotate(180deg); +} +.ryd-discussion-reaction { + min-width: 42px; + font-variant-numeric: tabular-nums; +} +.ryd-discussion-replies { + margin-inline-start: 24px; + padding-inline-start: 12px; + border-inline-start: 2px solid var(--ryd-discussion-line); +} +.ryd-discussion-replies:empty { + display: none; +} +.ryd-discussion-reply { + padding-block: 12px; +} +.ryd-discussion-inline-editor:empty { + display: none; +} +.ryd-discussion-confirm { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 0; + flex-wrap: wrap; +} +.ryd-discussion-more { + margin: 12px 0; + background: var(--ryd-discussion-surface); +} +.ryd-discussion-comment.is-highlighted { + border-radius: 10px; + padding-inline: 12px; + background: var(--ryd-discussion-surface); +} +.ryd-discussion-report { + border: 1px solid var(--ryd-discussion-line); + border-radius: 12px; + padding: 14px; + margin-top: 14px; +} +.ryd-discussion-report-reason { + color: var(--ryd-discussion-muted); + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.ryd-discussion-notification { + border-bottom: 1px solid var(--ryd-discussion-line); + padding: 12px 8px; + display: flex; + align-items: flex-start; + gap: 8px; + flex-wrap: wrap; +} +.ryd-discussion-notification.is-unread { + background: var(--ryd-discussion-surface); + border-radius: 8px; +} +.ryd-discussion-notification-link { + flex: 1 1 100%; + font-size: 13px; + overflow-wrap: anywhere; +} +.ryd-discussion-inbox-list { + display: grid; + gap: 4px; +} +.ryd-discussion-launcher { + display: block; + margin: 8px auto; + width: 48px; + height: 48px; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 50%; + background: #d92323; + color: #fff; + font: + 700 12px/1 Roboto, + Arial, + sans-serif; + cursor: pointer; + box-shadow: 0 2px 8px #0003; +} +.ryd-discussion-backdrop { + position: fixed; + inset: 0; + background: #0008; + z-index: 2147483000; +} +.ryd-discussion-shorts { + position: fixed; + inset: 0 0 0 auto; + width: min(440px, 100vw); + height: 100dvh; + margin: 0; + padding: 20px; + background: var(--ryd-discussion-bg); + z-index: 2147483001; + border: 0; + box-shadow: -8px 0 32px #0003; + overflow-y: auto; + overscroll-behavior: contain; +} +.ryd-discussion-shorts .ryd-discussion-header { + align-items: flex-start; +} +.ryd-discussion-shorts .ryd-discussion-header-actions { + width: 100%; +} +.ryd-discussion-shorts .ryd-discussion-close { + margin-inline-start: auto; +} +.ryd-discussion-toast { + position: fixed; + inset-inline-end: 24px; + bottom: 24px; + z-index: 2147483002; + width: min(360px, calc(100vw - 32px)); + display: flex; + gap: 12px; + align-items: flex-start; + padding: 16px; + border-radius: 14px; + background: var(--ryd-discussion-bg); + border: 1px solid var(--ryd-discussion-line); + box-shadow: 0 6px 30px #0003; +} +.ryd-discussion-toast p { + margin: 5px 0; + font-size: 13px; +} +.ryd-discussion-toast .ryd-discussion-close { + margin-inline-start: auto; +} +.ryd-discussion-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +@media (max-width: 600px) { + #ryd-discussion-panel { + max-inline-size: calc(100vw - 48px); + margin-inline: auto; + } + .ryd-discussion-header-actions { + width: 100%; + } + .ryd-discussion-title { + font-size: 18px; + } + .ryd-discussion-replies { + margin-inline-start: 10px; + padding-inline-start: 8px; + } + .ryd-discussion-shorts { + width: 100vw; + padding: 16px; + } + .ryd-discussion-toast { + inset-inline-end: 16px; + bottom: 16px; + } +} + /* html:not(.style-scope)[dark], :not(.style-scope)[dark] { --yt-spec-general-background-a: #000 !important; --yt-spec-general-background-b: #000 !important; diff --git a/Extensions/combined/manifest-chrome.json b/Extensions/combined/manifest-chrome.json index fcf4a7a..8f7688b 100644 --- a/Extensions/combined/manifest-chrome.json +++ b/Extensions/combined/manifest-chrome.json @@ -11,55 +11,32 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "host_permissions": [ - "*://*.youtube.com/*", - "*://returnyoutubedislikeapi.com/*" - ], - "permissions": [ - "storage" - ], - "optional_permissions": [ - "identity" - ], + "host_permissions": ["*://*.youtube.com/*", "*://returnyoutubedislikeapi.com/*"], + "permissions": ["storage", "alarms"], + "optional_permissions": ["identity"], "action": { "default_popup": "popup.html" }, "content_scripts": [ { - "matches": [ - "*://youtube.com/*", - "*://www.youtube.com/*", - "*://m.youtube.com/*" - ], - "exclude_matches": [ - "*://*.music.youtube.com/*" - ], - "js": [ - "ryd.content-script.js" - ], - "css": [ - "content-style.css" - ] + "matches": ["*://youtube.com/*", "*://www.youtube.com/*", "*://m.youtube.com/*"], + "exclude_matches": ["*://*.music.youtube.com/*"], + "js": ["ryd.content-script.js"], + "css": ["content-style.css"] } ], "externally_connectable": { - "matches": [ - "*://*.youtube.com/*" - ] + "matches": ["*://*.youtube.com/*"] }, "web_accessible_resources": [ { - "resources": [ - "ryd.script.js", - "menu-fixer.js" - ], - "matches": [ - "*://*.youtube.com/*" - ] + "resources": ["ryd.script.js", "menu-fixer.js", "comments-bridge.js"], + "matches": ["*://*.youtube.com/*"] } ], "options_ui": { "page": "popup.html", "open_in_tab": false - } + }, + "minimum_chrome_version": "116" } diff --git a/Extensions/combined/manifest-firefox.json b/Extensions/combined/manifest-firefox.json index dc33aee..503c179 100644 --- a/Extensions/combined/manifest-firefox.json +++ b/Extensions/combined/manifest-firefox.json @@ -12,7 +12,14 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "permissions": ["activeTab", "*://*.youtube.com/*", "storage", "*://returnyoutubedislikeapi.com/*", "identity"], + "permissions": [ + "activeTab", + "*://*.youtube.com/*", + "storage", + "*://returnyoutubedislikeapi.com/*", + "identity", + "alarms" + ], "browser_specific_settings": { "gecko": { "id": "{762f9885-5a13-4abd-9c77-433dcd38b8fd}", @@ -23,7 +30,6 @@ } } }, - "browser_action": { "default_popup": "popup.html" }, @@ -40,5 +46,5 @@ "page": "popup.html", "open_in_tab": false }, - "web_accessible_resources": ["menu-fixer.js"] + "web_accessible_resources": ["menu-fixer.js", "comments-bridge.js"] } diff --git a/Extensions/combined/manifest-safari.json b/Extensions/combined/manifest-safari.json index f6ca020..06f066c 100644 --- a/Extensions/combined/manifest-safari.json +++ b/Extensions/combined/manifest-safari.json @@ -12,7 +12,7 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "permissions": ["activeTab", "*://*.youtube.com/*", "storage", "*://returnyoutubedislikeapi.com/*"], + "permissions": ["activeTab", "*://*.youtube.com/*", "storage", "*://returnyoutubedislikeapi.com/*", "alarms"], "browser_action": { "default_popup": "popup.html" }, @@ -28,5 +28,5 @@ "options_ui": { "page": "popup.html" }, - "web_accessible_resources": ["menu-fixer.js"] + "web_accessible_resources": ["menu-fixer.js", "comments-bridge.js"] } diff --git a/Extensions/combined/popup.css b/Extensions/combined/popup.css index e8fcb65..0eba8de 100644 --- a/Extensions/combined/popup.css +++ b/Extensions/combined/popup.css @@ -13,6 +13,26 @@ } /*** MATERIAL 3 ***/ +.ryd-discussion-popup { + --yt-spec-base-background: var(--background); + --yt-spec-text-primary: #f5eded; + --yt-spec-text-secondary: #ccbebe; + --yt-spec-badge-chip-background: var(--tertiary); + --yt-spec-10-percent-layer: #624d4d; + --yt-spec-call-to-action: var(--primary); + margin: 0; + width: 100%; +} +.ryd-discussion-popup .ryd-discussion-inbox { + margin-top: 12px; + padding: 0 8px 12px; + max-height: 400px; + overflow-y: auto; +} +.ryd-discussion-popup .ryd-discussion-toolbar { + justify-content: flex-start; +} + .m3-primary { background: var(--primary) !important; color: var(--background) !important; diff --git a/Extensions/combined/popup.html b/Extensions/combined/popup.html index 68f9c72..a37d6a8 100644 --- a/Extensions/combined/popup.html +++ b/Extensions/combined/popup.html @@ -5,6 +5,7 @@ __MSG_extensionName__ + @@ -85,9 +86,19 @@
+ +


diff --git a/Extensions/combined/popup.js b/Extensions/combined/popup.js index 1faf6d8..bbc0525 100644 --- a/Extensions/combined/popup.js +++ b/Extensions/combined/popup.js @@ -1,5 +1,6 @@ import { config as appConfig, getApiUrl, getApiEndpoint } from "./src/config.js"; import { getGitHubLoginErrorMessage } from "./src/github-login-error"; +import { initDiscussionPopup } from "./src/discussion/popup"; import { hasAuthenticationDataPermission, onAuthenticationDataPermissionRemoved, @@ -47,6 +48,7 @@ function localizeHtmlPage() { } localizeHtmlPage(); +initDiscussionPopup(); /* Links */ createLink(config.links.website, "link_website"); @@ -195,6 +197,7 @@ function initPatreonAuth() { const tierLabels = { premium: chrome.i18n.getMessage("patreonTierPremium"), + commenter: chrome.i18n.getMessage("patreonTierCommenter"), supporter: chrome.i18n.getMessage("patreonTierSupporter"), basic: chrome.i18n.getMessage("patreonTierBasic"), none: chrome.i18n.getMessage("patreonTierNone"), diff --git a/Extensions/combined/ryd.background.js b/Extensions/combined/ryd.background.js index e5ae7e8..8f4f261 100644 --- a/Extensions/combined/ryd.background.js +++ b/Extensions/combined/ryd.background.js @@ -2,6 +2,7 @@ import { config, getApiUrl, getApiEndpoint, getChangelogUrl } from "./src/config import { createVoteClient } from "../common/vote-client"; import { createBrowserCredentialStore } from "./src/vote-client-adapter"; import { loginWithGitHub } from "./src/github-auth"; +import { createDiscussionBackground } from "./src/discussion-background"; import { hasAuthenticationDataPermission, onAuthenticationDataPermissionRemoved, @@ -38,6 +39,14 @@ const voteClient = createVoteClient({ cryptoImpl: globalThis.crypto, }); +const discussionBackground = createDiscussionBackground({ + api, + voteClient, + hasConsent: async () => + !usesFirefoxDataCollectionConsent() || (await hasAuthenticationDataPermission({ queryBackground: false })), +}); +discussionBackground.initialize(); + initExtConfig(); voteClient.ensureRegistered().catch((error) => console.error("Vote registration failed", error)); @@ -130,11 +139,15 @@ function queueAccountStorageMutation(operation) { function clearPatreonAuth(done) { const generation = ++authenticationGeneration; + discussionBackground.clearSession(true); broadcastPatreonStatus(false, null, null, generation); queueAccountStorageMutation((complete) => { chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"], complete); }) - .then(() => done?.()) + .then(() => { + discussionBackground.resumeSession().catch(() => {}); + done?.(); + }) .catch((error) => console.error("Account session cleanup failed", error)); } @@ -192,7 +205,10 @@ function extractOAuthParams(responseUrl) { } api.runtime.onMessage.addListener((request, sender, sendResponse) => { - if (request.message === "patreon_logout") { + if (request?.message === "ryd_discussion") { + discussionBackground.handleMessage(request, sender).then(sendResponse); + return true; + } else if (request.message === "patreon_logout") { clearPatreonAuth(); } else if (request.message === "ryd_has_authentication_consent") { hasAuthenticationDataPermission({ queryBackground: false }).then((granted) => sendResponse({ granted })); @@ -573,6 +589,7 @@ const sentIds = new Set(); let toSend = []; function storageChangeHandler(changes, area) { + discussionBackground.onStorageChanged(changes, area); if (changes.disableVoteSubmission !== undefined) { handleDisableVoteSubmissionChangeEvent(changes.disableVoteSubmission.newValue); } diff --git a/Extensions/combined/ryd.background.spec.js b/Extensions/combined/ryd.background.spec.js index 73204e5..1362987 100644 --- a/Extensions/combined/ryd.background.spec.js +++ b/Extensions/combined/ryd.background.spec.js @@ -189,7 +189,17 @@ describe("background account consent lifecycle", () => { await flushPromises(); expect(stored).toEqual({}); expect(chrome.storage.sync.remove).toHaveBeenCalledTimes(1); - expect(chrome.tabs.sendMessage.mock.calls.every(([, message]) => message.authenticated === false)).toBe(true); + expect( + chrome.tabs.sendMessage.mock.calls + .filter(([, message]) => message.message === "patreon_status_changed") + .every(([, message]) => message.authenticated === false), + ).toBe(true); + expect( + chrome.tabs.sendMessage.mock.calls + .filter(([, message]) => message.message === "ryd_discussion_event" && message.type === "session") + .every(([, message]) => message.data.authenticated === false), + ).toBe(true); + expect(response).toHaveBeenCalledWith(expect.objectContaining({ success: false })); }, ); diff --git a/Extensions/combined/ryd.content-script.js b/Extensions/combined/ryd.content-script.js index 5fa1a23..a7ceb7a 100644 --- a/Extensions/combined/ryd.content-script.js +++ b/Extensions/combined/ryd.content-script.js @@ -31,6 +31,7 @@ import { reactionControlsCanInitialize, } from "./src/initialization-cycle"; import { initPatreonFeatures } from "./src/patreon"; +import { initDiscussion } from "./src/discussion"; import { cancelObsoleteVoteDataRequests, clearVoteDataRequestCache } from "./src/vote-data-request"; import { actionBarHasHydratedData, @@ -94,6 +95,7 @@ const pageWorldHelpersReady = injectPageWorldHelpers(); await initExtConfig(); await pageWorldHelpersReady; initPatreonFeatures(); +initDiscussion(); // Settings that affect page-world helpers must continue to update even when // YouTube's reaction controls have not finished initializing. diff --git a/Extensions/combined/src/discussion-background.js b/Extensions/combined/src/discussion-background.js new file mode 100644 index 0000000..12bfac6 --- /dev/null +++ b/Extensions/combined/src/discussion-background.js @@ -0,0 +1,785 @@ +import { HubConnectionBuilder, HttpTransportType, LogLevel } from "@microsoft/signalr"; +import { createVoteClient, solvePuzzle } from "../../common/vote-client"; +import { discussionConfig } from "./discussion-config"; + +const ANONYMOUS_SESSION = Object.freeze({ + authenticated: false, + profile: null, + canPost: false, + isModerator: false, + membershipTier: "none", +}); +const RECONNECT_ALARM = "ryd-discussion-reconnect"; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const VIDEO_ID = /^[a-zA-Z0-9_-]{11}$/; +const VIDEO_OPERATIONS = new Set([ + "comments", + "replies", + "comment", + "createComment", + "editComment", + "deleteComment", + "react", + "report", +]); +const OPERATIONS = { + cancelScope: [], + capabilities: [], + session: [], + profile: ["nickname"], + comments: ["videoId", "sort", "cursor"], + replies: ["rootId", "cursor"], + comment: ["id"], + createComment: ["videoId", "text", "parentId", "replyToId", "requestId"], + editComment: ["id", "text"], + deleteComment: ["id"], + react: ["id", "value"], + report: ["id", "reason"], + reports: ["cursor", "status"], + dismissReport: ["id", "reason"], + moderateComment: ["id", "hidden", "reason"], + suspendProfile: ["id", "suspended", "reason"], + notifications: ["cursor"], + readNotifications: ["ids", "all"], +}; + +class DiscussionError extends Error { + constructor(code, message = code, status = 0) { + super(message); + this.code = code; + this.status = status; + } +} + +function callBrowser(api, target, method, ...args) { + return new Promise((resolve, reject) => { + if (typeof target?.[method] !== "function") return resolve(undefined); + const callback = (value) => + api.runtime?.lastError ? reject(new Error(api.runtime.lastError.message)) : resolve(value); + try { + const result = target[method](...args, callback); + if (result?.then) result.then(resolve, reject); + } catch (error) { + try { + Promise.resolve(target[method](...args)).then(resolve, reject); + } catch (_) { + reject(error); + } + } + }); +} + +function isYouTubePage(url) { + try { + const parsed = new URL(url); + return ( + parsed.protocol === "https:" && + parsed.hostname === "www.youtube.com" && + (parsed.pathname === "/watch" || /^\/shorts\/[a-zA-Z0-9_-]{11}\/?$/.test(parsed.pathname)) + ); + } catch (_) { + return false; + } +} + +function validateRequest(api, request, sender) { + if (sender?.id !== api.runtime.id) throw new DiscussionError("invalid_sender"); + const extensionUrl = api.runtime.getURL?.(""); + const ownPage = extensionUrl && typeof sender.url === "string" && sender.url.startsWith(extensionUrl); + const videoPage = sender.frameId === 0 && isYouTubePage(sender.url) && isYouTubePage(sender.tab?.url); + const operation = request?.operation; + const cancellationPage = + operation === "cancelScope" && + sender.frameId === 0 && + [sender.url, sender.tab?.url].every((value) => { + try { + return new URL(value).origin === "https://www.youtube.com"; + } catch (_) { + return false; + } + }); + if (!ownPage && !videoPage && !cancellationPage) throw new DiscussionError("invalid_sender"); + const fields = Object.hasOwn(OPERATIONS, operation) ? OPERATIONS[operation] : null; + const payload = request.payload ?? {}; + if ( + !fields || + !payload || + typeof payload !== "object" || + Array.isArray(payload) || + Object.keys(payload).some((key) => !fields.includes(key)) + ) + throw new DiscussionError("invalid_request"); + for (const key of ["id", "rootId", "parentId", "replyToId", "requestId"]) { + if (payload[key] != null && (typeof payload[key] !== "string" || !UUID.test(payload[key]))) + throw new DiscussionError("invalid_request"); + } + if (fields.includes("id") && !UUID.test(payload.id || "")) throw new DiscussionError("invalid_request"); + if (fields.includes("rootId") && !UUID.test(payload.rootId || "")) throw new DiscussionError("invalid_request"); + if (fields.includes("videoId") && !VIDEO_ID.test(payload.videoId || "")) throw new DiscussionError("invalid_request"); + if (operation === "createComment" && !UUID.test(payload.requestId || "")) + throw new DiscussionError("invalid_request"); + for (const [key, max] of [ + ["text", 2000], + ["nickname", 32], + ["reason", 1000], + ["cursor", 2048], + ]) { + if (payload[key] != null && (typeof payload[key] !== "string" || payload[key].length > max)) + throw new DiscussionError("invalid_request"); + } + if (fields.includes("text") && !payload.text?.trim()) throw new DiscussionError("invalid_request"); + if (operation === "profile" && (typeof payload.nickname !== "string" || payload.nickname.trim().length < 3)) + throw new DiscussionError("invalid_request"); + if (operation === "report" && !payload.reason?.trim()) throw new DiscussionError("invalid_request"); + if (payload.sort != null && !["top", "new"].includes(payload.sort)) throw new DiscussionError("invalid_request"); + if (payload.status != null && !["open", "resolved"].includes(payload.status)) + throw new DiscussionError("invalid_request"); + if (operation === "react" && ![-1, 0, 1].includes(payload.value)) throw new DiscussionError("invalid_request"); + for (const key of ["hidden", "suspended"]) { + if (fields.includes(key) && typeof payload[key] !== "boolean") throw new DiscussionError("invalid_request"); + } + if ( + operation === "readNotifications" && + !(payload.all === true && payload.ids == null) && + !( + payload.all == null && + Array.isArray(payload.ids) && + payload.ids.length > 0 && + payload.ids.length <= 100 && + payload.ids.every((id) => typeof id === "string" && UUID.test(id)) + ) + ) + throw new DiscussionError("invalid_request"); + const scope = request.scope; + if (scope != null) { + if ( + !scope || + typeof scope !== "object" || + Array.isArray(scope) || + Object.keys(scope).some((key) => !["id", "videoId"].includes(key)) || + typeof scope.id !== "string" || + !UUID.test(scope.id) || + typeof scope.videoId !== "string" || + !VIDEO_ID.test(scope.videoId) || + !Number.isInteger(sender.tab?.id) || + (!videoPage && !cancellationPage) || + (!VIDEO_OPERATIONS.has(operation) && operation !== "cancelScope") + ) + throw new DiscussionError("invalid_scope"); + if (operation !== "cancelScope") { + const current = new URL(sender.tab.url); + const videoId = current.pathname === "/watch" ? current.searchParams.get("v") : current.pathname.split("/")[2]; + if (scope.videoId !== videoId || (payload.videoId && payload.videoId !== scope.videoId)) + throw new DiscussionError("navigation_changed"); + } + } else if (operation === "cancelScope" || (videoPage && VIDEO_OPERATIONS.has(operation))) { + throw new DiscussionError("scope_required"); + } + return { operation, payload, scope }; +} + +function safeSession(data) { + const profile = data.profile; + return { + authenticated: data.authenticated === true, + profile: profile ? { id: profile.id, nickname: profile.nickname, suspended: profile.suspended === true } : null, + canPost: data.canPost === true, + isModerator: data.isModerator === true, + membershipTier: ["none", "supporter", "commenter", "premium"].includes(data.membershipTier) + ? data.membershipTier + : "none", + }; +} + +function createConnection(url, accessTokenFactory) { + return new HubConnectionBuilder() + .withUrl(url, { transport: HttpTransportType.WebSockets, skipNegotiation: true, accessTokenFactory }) + .withAutomaticReconnect([0, 2000, 10000, 30000]) + .withKeepAliveInterval(15000) + .withServerTimeout(45000) + .configureLogging(LogLevel.None) + .build(); +} + +export function createDiscussionBackground({ + api, + voteClient, + fetchImpl = (...args) => fetch(...args), + hasConsent = async () => true, + settings = discussionConfig, + connectionFactory = createConnection, + solve = solvePuzzle, +} = {}) { + const local = api.storage.local; + const origin = settings.apiOrigin.replace(/\/$/, ""); + const pendingRequests = new Map(); + const reactionQueues = new Map(); + const navigationScopes = new Map(); + let generation = 0; + let accountEpoch = 0; + let token = ""; + let session = { ...ANONYMOUS_SESSION }; + let connection = null; + let connecting = null; + let accountQueue = Promise.resolve(); + let catchupQueue = Promise.resolve(); + let stateQueue = Promise.resolve(); + let rejectedToken = ""; + let accountBlocked = false; + let hintedRevision = "0"; + let hasInboxSnapshot = false; + let inbox = { items: [], unreadCount: 0, revision: "0", nextCursor: null }; + let stopped = false; + + const store = (method, ...args) => callBrowser(api, local, method, ...args); + const writeState = (expected, method, ...args) => { + const work = stateQueue + .catch(() => {}) + .then(() => { + if (expected !== generation) return; + return store(method, ...args); + }); + stateQueue = work; + return work; + }; + const credentials = settings.testMode + ? createVoteClient({ + apiBaseUrl: origin, + fetchImpl, + credentialStore: { + async load() { + return (await store("get", [settings.credentialStorageKey]))?.[settings.credentialStorageKey] ?? null; + }, + async save(value) { + await store("set", { [settings.credentialStorageKey]: value }); + }, + async clear() { + await store("remove", [settings.credentialStorageKey]); + }, + }, + cryptoImpl: globalThis.crypto, + }) + : voteClient; + + function assertCurrent(expected) { + if (stopped || expected !== generation) throw new DiscussionError("session_changed"); + } + + function assertScope(scope) { + if (scope?.cancelled) throw new DiscussionError("navigation_changed", "The video changed"); + } + + function scopeFor(value, sender) { + if (!value) return null; + const documentId = sender.documentId || "top-frame"; + const key = `${sender.tab.id}:${documentId}:${value.id}`; + if (!navigationScopes.has(key)) + navigationScopes.set(key, { + tabId: sender.tab.id, + documentId, + videoId: value.videoId, + cancelled: false, + controllers: new Set(), + }); + const scope = navigationScopes.get(key); + if (scope.videoId !== value.videoId) throw new DiscussionError("invalid_scope"); + return scope; + } + + function cancelScope(scope) { + scope.cancelled = true; + for (const controller of scope.controllers) controller.abort(); + } + + async function request( + path, + { method = "GET", body, auth = token, expected = generation, scope, accountBound = true } = {}, + ) { + const assertRequest = () => assertCurrent(accountBound ? expected : generation); + assertRequest(); + assertScope(scope); + const controller = new AbortController(); + pendingRequests.set(controller, accountBound); + scope?.controllers.add(controller); + const timeout = setTimeout(() => controller.abort(), 20000); + try { + const response = await fetchImpl(`${origin}/api/discussion${path}`, { + method, + credentials: "omit", + cache: "no-store", + signal: controller.signal, + headers: { + Accept: "application/json", + ...(body == null ? {} : { "Content-Type": "application/json" }), + ...(auth ? { Authorization: `Bearer ${auth}` } : {}), + }, + ...(body == null ? {} : { body: JSON.stringify(body) }), + }); + assertRequest(); + assertScope(scope); + const data = response.status === 204 ? null : await response.json().catch(() => null); + assertRequest(); + assertScope(scope); + if (!response.ok) { + if ( + response.status === 401 && + data?.code === "authentication_required" && + accountBound && + auth && + auth === token && + path !== "/me" + ) { + rejectedToken = token; + clearSession(); + } + throw new DiscussionError(data?.code || "request_failed", data?.message || "Request failed", response.status); + } + return data; + } catch (error) { + assertScope(scope); + if (error instanceof DiscussionError) throw error; + throw new DiscussionError( + controller.signal.aborted ? "request_cancelled" : "network_error", + "Discussion is unavailable", + ); + } finally { + clearTimeout(timeout); + pendingRequests.delete(controller); + scope?.controllers.delete(controller); + } + } + + async function broadcast(type, data, expected = generation) { + const message = { message: "ryd_discussion_event", type, data }; + if (expected !== generation || stopped) return; + const tabs = await callBrowser( + api, + api.tabs, + "query", + type === "toast" ? { active: true, lastFocusedWindow: true } : {}, + ); + if (expected !== generation || stopped) return; + const targets = (tabs || []).filter((tab) => isYouTubePage(tab.url)); + await Promise.allSettled( + (type === "toast" ? targets.slice(0, 1) : targets).map((tab) => + callBrowser(api, api.tabs, "sendMessage", tab.id, message), + ), + ); + if (type !== "toast" && expected === generation) { + await callBrowser(api, api.runtime, "sendMessage", message).catch(() => {}); + } + } + + async function updateBadge(expected = generation) { + if (expected !== generation || stopped) return; + const action = api.action || api.browserAction; + await callBrowser(api, action, "setBadgeText", { + text: inbox.unreadCount ? String(Math.min(inbox.unreadCount, 99)) + (inbox.unreadCount > 99 ? "+" : "") : "", + }).catch(() => {}); + if (expected === generation) + await callBrowser(api, action, "setBadgeBackgroundColor", { color: "#cc0000" }).catch(() => {}); + } + + async function publishInbox(expected, changed = [], showToast = false) { + assertCurrent(expected); + await writeState(expected, "set", { + [settings.stateStorageKey]: { profileId: session.profile?.id ?? null, ...inbox }, + }); + assertCurrent(expected); + await updateBadge(expected); + await broadcast("inbox", inbox, expected); + if (showToast) { + const notification = changed.find((item) => !item.read && ["reply", "likes"].includes(item.type)); + if (notification) await broadcast("toast", notification, expected); + } + } + + function catchup(showToast = false, expected = generation) { + const work = catchupQueue + .catch(() => {}) + .then(async () => { + assertCurrent(expected); + if (!session.authenticated || !token) return; + const since = inbox.revision; + const isChanges = hasInboxSnapshot; + const changed = []; + let cursor = null; + let page; + do { + const params = new URLSearchParams(); + if (isChanges) params.set("afterRevision", since); + if (cursor) params.set("cursor", cursor); + page = await request(`/notifications${params.size ? `?${params}` : ""}`, { expected }); + changed.push(...(page.items || [])); + cursor = page.nextCursor; + } while (cursor && isChanges); + assertCurrent(expected); + const previous = new Map(inbox.items.map((item) => [item.id, item])); + const novel = changed.filter((item) => previous.get(item.id)?.revision !== item.revision); + for (const item of changed) previous.set(item.id, item); + inbox = { + items: [...previous.values()] + .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))) + .slice(0, 100), + nextCursor: isChanges ? inbox.nextCursor : page.nextCursor, + unreadCount: page.unreadCount, + revision: page.revision, + }; + hasInboxSnapshot = true; + await publishInbox(expected, novel, showToast && isChanges); + }); + catchupQueue = work; + return work; + } + + async function connect(expected = generation) { + if (connecting || connection || !session.authenticated || !token || stopped) return connecting; + const work = (async () => { + assertCurrent(expected); + const activeToken = token; + const socket = connectionFactory(`${origin}/api/discussion/notifications/hub`, async () => { + assertCurrent(expected); + const ticket = await request("/notifications/ticket", { method: "POST", auth: activeToken, expected }); + return ticket.token; + }); + connection = socket; + socket.on("NotificationsChanged", (event) => { + const revision = event?.revision; + if (expected !== generation || typeof revision !== "string" || !/^(0|[1-9][0-9]*)$/.test(revision)) return; + if (BigInt(revision) <= BigInt(inbox.revision) || BigInt(revision) <= BigInt(hintedRevision)) return; + hintedRevision = revision; + catchup(true, expected).catch(() => { + if (expected === generation) hintedRevision = inbox.revision; + }); + }); + socket.onclose(() => { + if (connection === socket) connection = null; + }); + socket.onreconnected(() => { + if (expected !== generation || connection !== socket || stopped) return; + catchup(false, expected).catch(() => {}); + }); + try { + await socket.start(); + assertCurrent(expected); + await catchup(false, expected).catch(() => {}); + } catch (error) { + if (connection === socket) connection = null; + await socket.stop().catch(() => {}); + throw error; + } + })(); + connecting = work; + try { + await work; + } finally { + if (connecting === work) connecting = null; + } + } + + function clearSession(blockAccount = false, preserveQueuedRequests = false) { + if (blockAccount) accountBlocked = true; + if (!preserveQueuedRequests) accountEpoch++; + generation++; + for (const [controller, accountBound] of pendingRequests) if (accountBound) controller.abort(); + const previousConnection = connection; + connection = null; + connecting = null; + previousConnection?.stop().catch(() => {}); + token = ""; + hintedRevision = "0"; + hasInboxSnapshot = false; + session = { ...ANONYMOUS_SESSION }; + inbox = { items: [], unreadCount: 0, revision: "0", nextCursor: null }; + writeState(generation, "remove", [settings.stateStorageKey]).catch(() => {}); + updateBadge().catch(() => {}); + broadcast("session", session).catch(() => {}); + broadcast("inbox", inbox).catch(() => {}); + } + + function refreshSession() { + const work = accountQueue + .catch(() => {}) + .then(async () => { + const beforeRead = generation; + if (stopped || accountBlocked) return session; + const consent = await hasConsent(); + assertCurrent(beforeRead); + let nextToken = ""; + if (consent) { + if (settings.testMode) { + const data = await store("get", [settings.authStorageKey]); + nextToken = data?.[settings.authStorageKey]?.sessionToken || ""; + } else { + const data = await callBrowser(api, api.storage.sync, "get", [ + "patreonAuthenticated", + "patreonSessionToken", + ]); + nextToken = data?.patreonAuthenticated ? data.patreonSessionToken || "" : ""; + } + } + assertCurrent(beforeRead); + if ( + (nextToken && nextToken === rejectedToken) || + (nextToken === token && (!nextToken || session.authenticated)) + ) + return session; + clearSession(false, true); + const expected = generation; + token = nextToken; + if (!token) return session; + try { + session = safeSession(await request("/me", { expected })); + } catch (error) { + assertCurrent(expected); + if (error.status !== 401) throw error; + rejectedToken = token; + token = ""; + session = { ...ANONYMOUS_SESSION }; + return session; + } + assertCurrent(expected); + await broadcast("session", session, expected); + await catchup(false, expected).catch(() => {}); + connect(expected).catch(() => {}); + return session; + }); + accountQueue = work; + return work; + } + + async function withReactions(result, expected, single = false, scope) { + assertScope(scope); + const items = single ? [result] : result.items || []; + if (!items.length) return result; + try { + const registered = await credentials.getRegisteredUser(); + assertCurrent(expected); + assertScope(scope); + if (!registered) { + for (const item of items) item.myReaction = 0; + return result; + } + const { userId } = registered; + const values = await request("/reactions/state", { + method: "POST", + body: { userId, commentIds: items.map((item) => item.id) }, + expected, + scope, + }); + const ownValues = new Map((values.items || []).map((item) => [item.commentId, item.value])); + for (const item of items) item.myReaction = ownValues.get(item.id) ?? 0; + } catch (error) { + assertCurrent(expected); + assertScope(scope); + // Public reading remains available when stored vote credentials are unavailable. + } + return result; + } + + function react(payload, expected, scope) { + const previous = reactionQueues.get(payload.id) || Promise.resolve(); + const work = previous + .catch(() => {}) + .then(async () => { + assertCurrent(expected); + assertScope(scope); + const { userId } = await credentials.ensureRegistered(); + assertCurrent(expected); + assertScope(scope); + for (let attempt = 0; attempt < 3; attempt++) { + const puzzle = await request(`/comments/${payload.id}/reaction/challenge`, { + method: "POST", + body: { userId, value: payload.value }, + expected, + scope, + }); + const solved = await solve(puzzle); + assertCurrent(expected); + assertScope(scope); + if (!solved) continue; + return request(`/comments/${payload.id}/reaction/confirm`, { + method: "POST", + body: { userId, challengeId: puzzle.challengeId, solution: solved.solution }, + expected, + scope, + }); + } + throw new DiscussionError("puzzle_failed", "Please try the reaction again"); + }); + reactionQueues.set(payload.id, work); + work + .finally(() => { + if (reactionQueues.get(payload.id) === work) reactionQueues.delete(payload.id); + }) + .catch(() => {}); + return work; + } + + async function execute(operation, payload, scope) { + assertScope(scope); + if (operation === "capabilities") return request("/capabilities", { auth: "", accountBound: false }); + const queuedEpoch = accountEpoch; + await refreshSession(); + if (queuedEpoch !== accountEpoch) throw new DiscussionError("session_changed"); + assertScope(scope); + const expected = generation; + const { id, cursor } = payload; + const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""; + const send = (path, options = {}) => request(path, { ...options, scope }); + switch (operation) { + case "session": + return session; + case "profile": { + const data = safeSession( + await send("/me/profile", { method: "PUT", body: { nickname: payload.nickname }, expected }), + ); + session = data; + await broadcast("session", session, expected); + return data; + } + case "comments": + return withReactions( + await send( + `/videos/${payload.videoId}/comments?sort=${payload.sort || "top"}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, + { expected }, + ), + expected, + false, + scope, + ); + case "replies": + return withReactions( + await send(`/comments/${payload.rootId}/replies${query}`, { expected }), + expected, + false, + scope, + ); + case "comment": + return withReactions(await send(`/comments/${id}`, { expected }), expected, true, scope); + case "createComment": { + const { videoId, ...body } = payload; + return send(`/videos/${videoId}/comments`, { method: "POST", body, expected }); + } + case "editComment": + return send(`/comments/${id}`, { method: "PATCH", body: { text: payload.text }, expected }); + case "deleteComment": + return send(`/comments/${id}`, { method: "DELETE", expected }); + case "react": + return react(payload, expected, scope); + case "report": + return send(`/comments/${id}/reports`, { method: "POST", body: { reason: payload.reason }, expected }); + case "reports": + return request( + `/moderation/reports?status=${payload.status || "open"}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, + { expected }, + ); + case "dismissReport": + return request(`/moderation/reports/${id}/dismiss`, { + method: "POST", + body: { reason: payload.reason }, + expected, + }); + case "moderateComment": + return request(`/moderation/comments/${id}`, { + method: "POST", + body: { hidden: payload.hidden, reason: payload.reason }, + expected, + }); + case "suspendProfile": + return request(`/moderation/profiles/${id}/suspension`, { + method: "POST", + body: { suspended: payload.suspended, reason: payload.reason }, + expected, + }); + case "notifications": + return request(`/notifications${query}`, { expected }); + case "readNotifications": { + const data = await request("/notifications/read", { method: "POST", body: payload, expected }); + await catchup(false, expected); + return data; + } + default: + throw new DiscussionError("invalid_request"); + } + } + + async function handleMessage(requestMessage, sender) { + try { + const { operation, payload, scope: scopeValue } = validateRequest(api, requestMessage, sender); + const scope = scopeFor(scopeValue, sender); + if (operation === "cancelScope") { + cancelScope(scope); + return { ok: true, data: { cancelled: true } }; + } + const data = await execute(operation, payload, scope); + assertScope(scope); + if ( + scope && + ((data?.videoId && data.videoId !== scope.videoId) || + data?.items?.some((item) => item.videoId && item.videoId !== scope.videoId)) + ) + throw new DiscussionError("navigation_changed"); + return { ok: true, data }; + } catch (error) { + return { + ok: false, + error: { + code: error.code || "request_failed", + message: error.message || "Request failed", + status: error.status || 0, + }, + }; + } + } + + function onStorageChanged(changes, area) { + const changed = settings.testMode + ? area === "local" && changes[settings.authStorageKey] + : area === "sync" && (changes.patreonAuthenticated || changes.patreonSessionToken || changes.patreonUser); + if (changed) { + rejectedToken = ""; + clearSession(); + refreshSession().catch(() => {}); + } + } + + function initialize() { + api.tabs?.onRemoved?.addListener((tabId) => { + for (const [key, scope] of navigationScopes) + if (scope.tabId === tabId) { + cancelScope(scope); + navigationScopes.delete(key); + } + }); + api.alarms?.onAlarm?.addListener((alarm) => { + if (alarm.name !== RECONNECT_ALARM || stopped) return; + refreshSession() + .then(() => Promise.allSettled([catchup(false), connect()])) + .catch(() => {}); + }); + // Alarm recovery also covers service-worker termination and offline browser startup. + api.alarms?.create(RECONNECT_ALARM, { periodInMinutes: 1 }); + refreshSession().catch(() => {}); + } + + return { + handleMessage, + refreshSession, + clearSession, + resumeSession() { + accountBlocked = false; + return refreshSession(); + }, + onStorageChanged, + initialize, + stop() { + clearSession(); + stopped = true; + for (const controller of pendingRequests.keys()) controller.abort(); + api.alarms?.clear(RECONNECT_ALARM); + }, + }; +} + +export { ANONYMOUS_SESSION, createConnection, validateRequest }; diff --git a/Extensions/combined/src/discussion-background.spec.js b/Extensions/combined/src/discussion-background.spec.js new file mode 100644 index 0000000..9bb1999 --- /dev/null +++ b/Extensions/combined/src/discussion-background.spec.js @@ -0,0 +1,574 @@ +import { createDiscussionBackground, ANONYMOUS_SESSION } from "./discussion-background"; + +const ID = "11111111-1111-4111-8111-111111111111"; +const PROFILE_ID = "22222222-2222-4222-8222-222222222222"; +const NOTIFICATION_ID = "33333333-3333-4333-8333-333333333333"; +const VIDEO_ID = "video123456"; +const ORIGIN = "https://returnyoutubedislikeapi.com"; +const SCOPE = { id: "44444444-4444-4444-8444-444444444444", videoId: VIDEO_ID }; +const VIDEO_OPERATIONS = new Set([ + "comments", + "replies", + "comment", + "createComment", + "editComment", + "deleteComment", + "react", + "report", + "cancelScope", +]); +const sender = { + id: "extension-id", + url: `https://www.youtube.com/watch?v=${VIDEO_ID}`, + tab: { id: 1, url: `https://www.youtube.com/watch?v=${VIDEO_ID}` }, + frameId: 0, +}; +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); +const deferred = () => { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; + +function storageArea(data = {}) { + return { + data, + get: jest.fn((keys, callback) => callback(Object.fromEntries(keys.map((key) => [key, data[key]])))), + set: jest.fn((values, callback) => { + Object.assign(data, values); + callback?.(); + }), + remove: jest.fn((keys, callback) => { + keys.forEach((key) => delete data[key]); + callback?.(); + }), + }; +} + +function harness({ authenticated = false, consent = true, fetchOverride, testMode = false, solveOverride } = {}) { + const sync = storageArea(authenticated ? { patreonAuthenticated: true, patreonSessionToken: "account-token" } : {}); + const local = storageArea(); + let alarmListener; + const api = { + runtime: { + id: "extension-id", + getURL: (path) => `chrome-extension://extension-id/${path}`, + sendMessage: jest.fn((message, callback) => callback?.()), + }, + storage: { sync, local }, + action: { + setBadgeText: jest.fn((value, callback) => callback?.()), + setBadgeBackgroundColor: jest.fn((value, callback) => callback?.()), + }, + tabs: { + query: jest.fn((query, callback) => + callback([ + { id: 1, url: sender.url }, + { id: 2, url: sender.url }, + { id: 3, url: "https://example.com/" }, + ]), + ), + sendMessage: jest.fn((id, message, callback) => callback?.()), + }, + alarms: { + create: jest.fn(), + clear: jest.fn(), + onAlarm: { + addListener: (listener) => { + alarmListener = listener; + }, + }, + }, + }; + const notification = { + id: NOTIFICATION_ID, + type: "likes", + commentId: ID, + rootId: ID, + videoId: VIDEO_ID, + actorNickname: null, + likeCount: 1, + read: false, + createdAt: "2026-09-06T10:00:00Z", + updatedAt: "2026-09-06T10:00:00Z", + revision: "1", + }; + let inbox = { + items: authenticated ? [notification] : [], + unreadCount: authenticated ? 1 : 0, + revision: "1", + nextCursor: null, + }; + const calls = []; + const fetchImpl = jest.fn(async (url, options) => { + const path = url.replace(`${ORIGIN}/api/discussion`, ""); + const body = options.body ? JSON.parse(options.body) : null; + calls.push({ path, body, options, url }); + if (fetchOverride) { + const override = await fetchOverride(path, options, body); + if (override) return override; + } + let data; + if (path === "/me" || path === "/me/profile") + data = { + authenticated: true, + canPost: true, + isModerator: false, + membershipTier: "commenter", + profile: { id: PROFILE_ID, nickname: "Tester", suspended: false, providerId: "private" }, + token: "private", + userId: "private", + }; + else if (path.startsWith("/notifications/ticket")) + data = { token: "hub-ticket", expiresAt: "2026-09-06T10:01:00Z" }; + else if (path.startsWith("/notifications")) data = inbox; + else if (path.endsWith("/reaction/challenge")) + data = { challengeId: ID, challenge: "AAAAAAAAAAAAAAAAAAAAAA==", difficulty: 1 }; + else if (path.endsWith("/reaction/confirm")) data = { commentId: ID, likes: 1, dislikes: 0, myReaction: 1 }; + else if (path === "/reactions/state") data = { items: [{ commentId: ID, value: -1 }] }; + else if (path.includes("/comments")) + data = { + items: [{ id: ID, videoId: VIDEO_ID, author: { id: PROFILE_ID, nickname: "Tester" }, text: "Hello" }], + nextCursor: null, + }; + else data = { enabled: true }; + return { ok: true, status: 200, json: async () => structuredClone(data) }; + }); + const sockets = []; + const connectionFactory = jest.fn((url, tokenFactory) => { + const handlers = {}; + const socket = { + url, + tokenFactory, + handlers, + on: (name, handler) => { + handlers[name] = handler; + }, + onclose: (handler) => { + handlers.close = handler; + }, + onreconnected: (handler) => { + handlers.reconnected = handler; + }, + start: jest.fn(async () => { + await tokenFactory(); + }), + stop: jest.fn(async () => {}), + }; + sockets.push(socket); + return socket; + }); + const voteClient = { + ensureRegistered: jest.fn(async () => ({ userId: "existing-voter-secret" })), + getRegisteredUser: jest.fn(async () => ({ userId: "existing-voter-secret" })), + }; + const client = createDiscussionBackground({ + api, + voteClient, + fetchImpl, + hasConsent: async () => consent, + connectionFactory, + solve: solveOverride || jest.fn(async () => ({ solution: "AAAAAA==" })), + settings: { + apiOrigin: ORIGIN, + testMode, + authStorageKey: "testAuth", + credentialStorageKey: "testCredentials", + stateStorageKey: "discussionState", + }, + }); + return { + api, + client, + calls, + sockets, + fetchImpl, + voteClient, + sync, + local, + notification, + setInbox: (value) => { + inbox = value; + }, + alarm: () => alarmListener({ name: "ryd-discussion-reconnect" }), + send: (operation, payload = {}, source = sender, scope = SCOPE) => + client.handleMessage( + { + message: "ryd_discussion", + operation, + payload, + ...(VIDEO_OPERATIONS.has(operation) && scope ? { scope } : {}), + }, + source, + ), + }; +} + +describe("discussion worker boundary", () => { + test("public comments do not create a voter identity before the first reaction", async () => { + const h = harness(); + h.voteClient.getRegisteredUser.mockResolvedValue(null); + const response = await h.send("comments", { videoId: VIDEO_ID, sort: "new" }); + expect(response.ok).toBe(true); + expect(response.data.items[0].myReaction).toBe(0); + expect(h.voteClient.ensureRegistered).not.toHaveBeenCalled(); + expect(h.calls.some((call) => call.path === "/reactions/state")).toBe(false); + }); + test.each([ + ["comments", { videoId: VIDEO_ID, userId: "forged" }, sender], + ["session", { sessionToken: "forged" }, sender], + ["react", { id: ID, value: 1, recipient: "forged" }, sender], + ["session", {}, { ...sender, id: "different-extension" }], + ["session", {}, { ...sender, frameId: 1 }], + ["session", {}, { ...sender, url: "https://www.youtube.com.evil.test/watch?v=video123456" }], + ["fetch", { url: "https://example.com" }, sender], + ["react", { id: ID, value: 2 }, sender], + ["createComment", { videoId: VIDEO_ID, text: "Hi" }, sender], + ])("rejects invalid %s requests before sending network traffic", async (operation, payload, source) => { + const h = harness(); + expect((await h.send(operation, payload, source)).ok).toBe(false); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + + test("public reads enrich viewer reactions without exposing the voter credential", async () => { + const h = harness(); + const response = await h.send("comments", { videoId: VIDEO_ID, sort: "new" }); + expect(response.ok).toBe(true); + expect(response.data.items[0].myReaction).toBe(-1); + expect(JSON.stringify(response)).not.toContain("existing-voter-secret"); + expect(h.calls.find((call) => call.path === "/reactions/state").body).toEqual({ + userId: "existing-voter-secret", + commentIds: [ID], + }); + expect(h.calls.every((call) => !call.options.headers.Authorization)).toBe(true); + expect(h.sockets).toHaveLength(0); + }); + + test("preserves the same anonymous reaction identity across sign-in and sign-out", async () => { + const h = harness(); + await h.send("react", { id: ID, value: 1 }); + Object.assign(h.sync.data, { patreonAuthenticated: true, patreonSessionToken: "account-token" }); + await h.send("react", { id: ID, value: -1 }); + delete h.sync.data.patreonSessionToken; + await h.send("react", { id: ID, value: 0 }); + expect(h.calls.filter((call) => call.path.endsWith("/reaction/challenge")).map((call) => call.body)).toEqual([ + { userId: "existing-voter-secret", value: 1 }, + { userId: "existing-voter-secret", value: -1 }, + { userId: "existing-voter-secret", value: 0 }, + ]); + expect(h.sync.set).not.toHaveBeenCalled(); + expect(h.sync.remove).not.toHaveBeenCalled(); + expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION); + h.client.stop(); + }); + + test("returns a safe server session to the popup and uses only hub tickets on sockets", async () => { + const h = harness({ authenticated: true }); + const response = await h.send( + "session", + {}, + { id: "extension-id", url: "chrome-extension://extension-id/popup.html" }, + ); + await flush(); + expect(response.data.profile).toEqual({ id: PROFILE_ID, nickname: "Tester", suspended: false }); + expect(JSON.stringify(response)).not.toMatch(/private|account-token|existing-voter-secret/); + expect(h.sockets).toHaveLength(1); + expect(await h.sockets[0].tokenFactory()).toBe("hub-ticket"); + expect(h.sockets[0].url).not.toContain("token"); + h.client.stop(); + }); + + test("does not read or transmit stored account credentials after consent removal", async () => { + const h = harness({ authenticated: true, consent: false }); + expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION); + expect(h.sync.get).not.toHaveBeenCalled(); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + + test("test accounts never fall back to real Patreon storage", async () => { + const h = harness({ authenticated: true, testMode: true }); + expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION); + expect(h.sync.get).not.toHaveBeenCalled(); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + + test("discards an account response arriving after logout", async () => { + const pending = deferred(); + const h = harness({ authenticated: true, fetchOverride: (path) => (path === "/me" ? pending.promise : undefined) }); + const response = h.send("session"); + await flush(); + h.client.clearSession(); + pending.resolve({ + ok: true, + status: 200, + json: async () => ({ authenticated: true, canPost: true, profile: { id: PROFILE_ID, nickname: "Old account" } }), + }); + expect((await response).ok).toBe(false); + expect( + h.api.tabs.sendMessage.mock.calls.some(([, event]) => event.type === "session" && event.data.authenticated), + ).toBe(false); + expect(h.sockets).toHaveLength(0); + }); + + test("the public capability probe survives account initialization and logout", async () => { + const pending = deferred(); + let signal; + const h = harness({ + fetchOverride: (path, options) => { + if (path !== "/capabilities") return; + signal = options.signal; + return pending.promise; + }, + }); + const response = h.send("capabilities"); + await flush(); + h.client.clearSession(); + expect(signal.aborted).toBe(false); + pending.resolve({ ok: true, status: 200, json: async () => ({ enabled: true }) }); + expect(await response).toEqual({ ok: true, data: { enabled: true } }); + expect(h.calls[0].options.headers.Authorization).toBeUndefined(); + h.client.stop(); + }); + + test("does not restore an old account while logout storage cleanup is pending", async () => { + const h = harness({ authenticated: true }); + await h.send("session"); + h.client.clearSession(true); + expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION); + delete h.sync.data.patreonSessionToken; + await h.client.resumeSession(); + expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION); + h.client.stop(); + }); + + test("a request queued across an account change cannot execute under the replacement account", async () => { + const held = deferred(); + const h = harness({ authenticated: true, fetchOverride: (path) => (path === "/me" ? held.promise : undefined) }); + const reading = h.send("session"); + await flush(); + const queued = h.send("createComment", { videoId: VIDEO_ID, text: "Old account draft", requestId: ID }); + h.client.clearSession(true); + held.resolve({ ok: true, status: 200, json: async () => ({ authenticated: true }) }); + expect((await reading).ok).toBe(false); + expect((await queued).error.code).toBe("session_changed"); + expect(h.calls.some((call) => call.path.includes("/videos/"))).toBe(false); + h.client.stop(); + }); + + test("moderation reports accept only open or resolved status and encode their cursor", async () => { + const h = harness(); + expect((await h.send("reports", { status: "resolved", cursor: "opaque&value" })).ok).toBe(true); + expect(h.calls.at(-1).path).toBe("/moderation/reports?status=resolved&cursor=opaque%26value"); + expect((await h.send("reports", { status: "hidden" })).error.code).toBe("invalid_request"); + }); + + test("serializes reactions so a slow confirmation cannot overwrite a newer choice", async () => { + const pending = deferred(); + let confirms = 0; + const h = harness({ + fetchOverride: (path) => (path.endsWith("/reaction/confirm") && confirms++ === 0 ? pending.promise : undefined), + }); + const first = h.send("react", { id: ID, value: 1 }); + const second = h.send("react", { id: ID, value: -1 }); + await flush(); + expect(h.calls.filter((call) => call.path.endsWith("/reaction/challenge"))).toHaveLength(1); + pending.resolve({ + ok: true, + status: 200, + json: async () => ({ commentId: ID, likes: 1, dislikes: 0, myReaction: 1 }), + }); + await Promise.all([first, second]); + expect(h.calls.filter((call) => call.path.endsWith("/reaction/challenge")).map((call) => call.body.value)).toEqual([ + 1, -1, + ]); + }); + + test("updates grouped notification revisions and emits one active-tab toast without replaying history", async () => { + const h = harness({ authenticated: true }); + await h.send("session"); + await flush(); + expect(h.api.tabs.sendMessage.mock.calls.filter(([, event]) => event.type === "toast")).toHaveLength(0); + expect(h.api.action.setBadgeText).toHaveBeenLastCalledWith({ text: "1" }, expect.any(Function)); + const updated = { ...h.notification, revision: "2", likeCount: 2, updatedAt: "2026-09-06T10:05:00Z" }; + h.setInbox({ items: [updated], unreadCount: 1, revision: "2", nextCursor: null }); + h.sockets[0].handlers.NotificationsChanged({ revision: "2" }); + await flush(); + await flush(); + h.sockets[0].handlers.NotificationsChanged({ revision: "2" }); + await flush(); + const toasts = h.api.tabs.sendMessage.mock.calls.filter(([, event]) => event.type === "toast"); + expect(toasts).toHaveLength(1); + expect(toasts[0][0]).toBe(1); + expect(toasts[0][1].data.likeCount).toBe(2); + expect(h.calls.some((call) => call.path === "/notifications?afterRevision=1")).toBe(true); + h.client.stop(); + }); + + test("alarm reconnects a closed socket and silently catches up notifications", async () => { + const h = harness({ authenticated: true }); + h.client.initialize(); + await flush(); + await flush(); + h.sockets[0].handlers.close(); + h.alarm(); + await flush(); + await flush(); + expect(h.sockets).toHaveLength(2); + expect(h.api.tabs.sendMessage.mock.calls.filter(([, event]) => event.type === "toast")).toHaveLength(0); + h.client.stop(); + }); + + test("a reconnect callback from a cleared account cannot request its old inbox", async () => { + const h = harness({ authenticated: true }); + await h.send("session"); + await flush(); + const oldSocket = h.sockets[0]; + h.client.clearSession(true); + await flush(); + const count = h.calls.length; + oldSocket.handlers.reconnected(); + await flush(); + expect(h.calls).toHaveLength(count); + h.client.stop(); + }); + + test("an expired cached account clears its inbox, badge and socket without changing the voter identity", async () => { + const h = harness({ + authenticated: true, + fetchOverride: (path) => + path.includes("cursor=expired") + ? { ok: false, status: 401, json: async () => ({ code: "authentication_required" }) } + : undefined, + }); + await h.send("session"); + await flush(); + expect((await h.send("notifications", { cursor: "expired" })).error.status).toBe(401); + await flush(); + expect((await h.send("session")).data).toEqual(ANONYMOUS_SESSION); + expect(h.sockets[0].stop).toHaveBeenCalled(); + expect(h.api.action.setBadgeText).toHaveBeenLastCalledWith({ text: "" }, expect.any(Function)); + expect( + h.api.tabs.sendMessage.mock.calls.some(([, event]) => event.type === "inbox" && event.data.items.length === 0), + ).toBe(true); + expect((await h.send("react", { id: ID, value: -1 })).ok).toBe(true); + const challenge = h.calls.findLast((call) => call.path.endsWith("/reaction/challenge")); + expect(challenge.body.userId).toBe("existing-voter-secret"); + expect(challenge.options.headers.Authorization).toBeUndefined(); + h.client.stop(); + }); + + test("live inbox preserves history pagination without advancing its baseline from a manual history page", async () => { + let revision = "25"; + const h = harness({ + authenticated: true, + fetchOverride: (path) => + path.includes("cursor=manual-history") + ? { ok: true, status: 200, json: async () => ({ items: [], revision: "999", nextCursor: null }) } + : path.includes("afterRevision=") + ? { + ok: true, + status: 200, + json: async () => ({ + items: revision === "26" ? [{ ...h.notification, revision }] : [], + unreadCount: 25, + revision, + nextCursor: null, + }), + } + : undefined, + }); + h.setInbox({ items: [h.notification], unreadCount: 25, revision: "25", nextCursor: "older-history" }); + await h.send("session"); + await flush(); + expect((await h.send("notifications", { cursor: "manual-history" })).data.revision).toBe("999"); + revision = "26"; + h.sockets[0].handlers.NotificationsChanged({ revision: "26" }); + await flush(); + await flush(); + const event = h.api.tabs.sendMessage.mock.calls.findLast(([, event]) => event.type === "inbox")[1]; + expect(event.data.nextCursor).toBe("older-history"); + expect(h.calls.findLast((call) => call.path.includes("afterRevision=")).path).toBe( + "/notifications?afterRevision=25", + ); + expect(event.data.revision).toBe("26"); + h.client.stop(); + }); + + test("a voter registration rejection does not clear a valid logged-in account or its inbox", async () => { + const h = harness({ + authenticated: true, + fetchOverride: (path) => + path.endsWith("/reaction/challenge") + ? { ok: false, status: 401, json: async () => ({ code: "voter_registration_required" }) } + : undefined, + }); + await h.send("session"); + await flush(); + expect((await h.send("react", { id: ID, value: 1 })).error.code).toBe("voter_registration_required"); + expect((await h.send("session")).data.authenticated).toBe(true); + expect(h.sockets[0].stop).not.toHaveBeenCalled(); + expect(h.api.action.setBadgeText).toHaveBeenLastCalledWith({ text: "1" }, expect.any(Function)); + h.client.stop(); + }); + + test("navigation aborts only the selected document scope and keeps another document and inbox request alive", async () => { + const pending = []; + let hold = false; + const h = harness({ + authenticated: true, + fetchOverride: (path, options) => { + if (!hold || (!path.startsWith("/videos/") && !path.includes("cursor=pending"))) return; + return new Promise((resolve, reject) => { + pending.push({ path, signal: options.signal, resolve }); + options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + }, + }); + await h.send("session"); + await flush(); + hold = true; + const firstSource = { ...sender, documentId: "document-one" }; + const secondSource = { ...sender, documentId: "document-two" }; + const first = h.send("comments", { videoId: VIDEO_ID }, firstSource); + const second = h.send("comments", { videoId: VIDEO_ID }, secondSource); + const inbox = h.send("notifications", { cursor: "pending" }); + await flush(); + expect(pending).toHaveLength(3); + expect((await h.send("cancelScope", {}, firstSource)).ok).toBe(true); + expect(pending[0].signal.aborted).toBe(true); + expect(pending[1].signal.aborted).toBe(false); + expect(pending[2].signal.aborted).toBe(false); + for (const request of pending.slice(1)) + request.resolve({ ok: true, status: 200, json: async () => ({ items: [], nextCursor: null }) }); + expect((await first).error.code).toBe("navigation_changed"); + expect((await second).ok).toBe(true); + expect((await inbox).ok).toBe(true); + h.client.stop(); + }); + + test("cancelled scopes cannot confirm a solved puzzle or start a queued reaction", async () => { + const solved = deferred(); + const h = harness({ solveOverride: () => solved.promise }); + const first = h.send("react", { id: ID, value: 1 }); + const queued = h.send("react", { id: ID, value: -1 }); + await flush(); + await h.send("cancelScope"); + solved.resolve({ solution: "AAAAAA==" }); + expect((await first).error.code).toBe("navigation_changed"); + expect((await queued).error.code).toBe("navigation_changed"); + expect(h.calls.filter((call) => call.path.endsWith("/reaction/challenge"))).toHaveLength(1); + expect(h.calls.filter((call) => call.path.endsWith("/reaction/confirm"))).toHaveLength(0); + expect((await h.send("comments", { videoId: VIDEO_ID })).error.code).toBe("navigation_changed"); + }); + + test("video operations require a scope bound to the current tab video", async () => { + const h = harness(); + expect((await h.send("comments", { videoId: VIDEO_ID }, sender, null)).error.code).toBe("scope_required"); + expect( + (await h.send("comments", { videoId: VIDEO_ID }, sender, { ...SCOPE, videoId: "other123456" })).error.code, + ).toBe("navigation_changed"); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/Extensions/combined/src/discussion-config.js b/Extensions/combined/src/discussion-config.js new file mode 100644 index 0000000..745fcba --- /dev/null +++ b/Extensions/combined/src/discussion-config.js @@ -0,0 +1,11 @@ +import { getApiUrl } from "./config"; + +const testOrigin = typeof __RYD_DISCUSSION_TEST_ORIGIN__ === "string" ? __RYD_DISCUSSION_TEST_ORIGIN__ : ""; + +export const discussionConfig = Object.freeze({ + apiOrigin: testOrigin || getApiUrl(), + testMode: Boolean(testOrigin), + authStorageKey: testOrigin ? "rydDiscussionTestAuth" : null, + credentialStorageKey: "rydDiscussionTestCredentials", + stateStorageKey: testOrigin ? "rydDiscussionTestState" : "rydDiscussionState", +}); diff --git a/Extensions/combined/src/discussion-navigation-fetch.spec.js b/Extensions/combined/src/discussion-navigation-fetch.spec.js new file mode 100644 index 0000000..628e084 --- /dev/null +++ b/Extensions/combined/src/discussion-navigation-fetch.spec.js @@ -0,0 +1,94 @@ +import { createServer } from "node:http"; +import { createDiscussionBackground } from "./discussion-background"; + +test("navigation cancels a real HTTP fetch while another tab and the inbox finish normally", async () => { + const videoId = "abcdefghijk"; + const otherVideoId = "zyxwvutsrqp"; + const held = []; + let allRequested; + let outgoingClosed; + const requested = new Promise((resolve) => { + allRequested = resolve; + }); + const closed = new Promise((resolve) => { + outgoingClosed = resolve; + }); + const server = createServer((request, response) => { + held.push({ url: request.url, response }); + if (request.url.includes(videoId)) response.once("close", outgoingClosed); + if (held.length === 3) allRequested(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const area = { + get: (keys, callback) => callback({}), + set: (values, callback) => callback?.(), + remove: (keys, callback) => callback?.(), + }; + const api = { + runtime: { id: "extension-id", getURL: (path) => `chrome-extension://extension-id/${path}` }, + storage: { sync: area, local: area }, + }; + const signals = new Map(); + const client = createDiscussionBackground({ + api, + voteClient: { ensureRegistered: async () => ({ userId: "unused" }) }, + settings: { apiOrigin: `http://127.0.0.1:${server.address().port}`, testMode: false, stateStorageKey: "state" }, + fetchImpl: (url, options) => { + signals.set(new URL(url).pathname, options.signal); + return fetch(url, options); + }, + }); + const sender = (id, video) => ({ + id: "extension-id", + frameId: 0, + documentId: `document-${id}`, + url: `https://www.youtube.com/watch?v=${video}`, + tab: { id, url: `https://www.youtube.com/watch?v=${video}` }, + }); + const scope = { id: "44444444-4444-4444-8444-444444444444", videoId }; + const send = (operation, payload, source, value) => + client.handleMessage( + { + message: "ryd_discussion", + operation, + payload, + ...(value ? { scope: value } : {}), + }, + source, + ); + try { + const outgoing = send("comments", { videoId }, sender(1, videoId), scope); + const other = send("comments", { videoId: otherVideoId }, sender(2, otherVideoId), { + ...scope, + videoId: otherVideoId, + }); + const inbox = send("notifications", { cursor: "pending" }, sender(1, videoId)); + await requested; + const cancellation = await send( + "cancelScope", + {}, + { + ...sender(1, videoId), + tab: { id: 1, url: "https://www.youtube.com/" }, + }, + scope, + ); + expect(cancellation.ok).toBe(true); + expect((await outgoing).error.code).toBe("navigation_changed"); + await closed; + expect(signals.get(`/api/discussion/videos/${videoId}/comments`).aborted).toBe(true); + expect(signals.get(`/api/discussion/videos/${otherVideoId}/comments`).aborted).toBe(false); + expect(signals.get("/api/discussion/notifications").aborted).toBe(false); + for (const { url, response } of held) + if (!url.includes(videoId)) { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ items: [], nextCursor: null })); + } + expect((await other).ok).toBe(true); + expect((await inbox).ok).toBe(true); + } finally { + client.stop(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } +}); diff --git a/Extensions/combined/src/discussion-notifications-socket.spec.js b/Extensions/combined/src/discussion-notifications-socket.spec.js new file mode 100644 index 0000000..7b4290e --- /dev/null +++ b/Extensions/combined/src/discussion-notifications-socket.spec.js @@ -0,0 +1,136 @@ +import { WebSocketServer } from "ws"; +import { createDiscussionBackground, createConnection } from "./discussion-background"; + +test("real SignalR events and reconnects fetch durable notifications with fresh hub tickets", async () => { + const server = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => server.once("listening", resolve)); + const profileId = "22222222-2222-4222-8222-222222222222"; + let peer; + const receivedTickets = []; + let ticketRequests = 0; + let connected; + const ready = new Promise((resolve) => { + connected = resolve; + }); + server.on("connection", (socket, request) => { + peer = socket; + receivedTickets.push(request.headers.authorization); + expect(request.headers.authorization).toBe(`Bearer hub-ticket-${ticketRequests}`); + expect(request.url).toBe("/api/discussion/notifications/hub"); + socket.on("message", (bytes) => { + const message = JSON.parse(bytes.toString().split("\u001e")[0]); + if (message.protocol) { + socket.send("{}\u001e"); + connected(); + } + }); + }); + let resolveToast; + let resolveReconnectedInbox; + const reconnectedInbox = new Promise((resolve) => { + resolveReconnectedInbox = resolve; + }); + let toasts = 0; + const toast = new Promise((resolve) => { + resolveToast = resolve; + }); + const area = { + get: (keys, callback) => callback({ patreonAuthenticated: true, patreonSessionToken: "account-token" }), + set: (values, callback) => callback?.(), + remove: (keys, callback) => callback?.(), + }; + const api = { + runtime: { + id: "extension-id", + getURL: (path) => `chrome-extension://extension-id/${path}`, + sendMessage: (message, callback) => callback?.(), + }, + storage: { sync: area, local: area }, + tabs: { + query: (query, callback) => callback([{ id: 1, url: "https://www.youtube.com/watch?v=video123456" }]), + sendMessage: (id, message, callback) => { + if (message.type === "toast") { + toasts++; + resolveToast(message.data); + } + if (message.type === "inbox" && message.data.revision === "2") resolveReconnectedInbox(message.data); + callback?.(); + }, + }, + }; + let revision = "0"; + const notification = { + id: "33333333-3333-4333-8333-333333333333", + commentId: "11111111-1111-4111-8111-111111111111", + type: "reply", + read: false, + revision: "1", + updatedAt: "2026-09-06T10:05:00Z", + }; + const notificationReads = []; + const client = createDiscussionBackground({ + api, + voteClient: { ensureRegistered: async () => ({ userId: "unused" }) }, + settings: { apiOrigin: "https://returnyoutubedislikeapi.com", testMode: false, stateStorageKey: "state" }, + connectionFactory: (url, tokenFactory) => + createConnection(`http://127.0.0.1:${server.address().port}/api/discussion/notifications/hub`, tokenFactory), + fetchImpl: async (url) => { + let data; + if (url.endsWith("/me")) + data = { authenticated: true, profile: { id: profileId, nickname: "Tester" }, canPost: true }; + else if (url.endsWith("/ticket")) data = { token: `hub-ticket-${++ticketRequests}` }; + else { + notificationReads.push(url); + data = { + items: revision === "0" ? [] : [notification], + unreadCount: revision === "0" ? 0 : 1, + revision, + nextCursor: null, + }; + } + return { ok: true, status: 200, json: async () => data }; + }, + }); + try { + await client.refreshSession(); + await ready; + // Let the socket's initial durable catchup complete before sending a new event. + await new Promise((resolve) => setTimeout(resolve, 20)); + const before = notificationReads.length; + revision = "1"; + peer.send(`${JSON.stringify({ type: 1, target: "NotificationsChanged", arguments: [{ revision }] })}\u001e`); + expect(await toast).toEqual(notification); + expect(notificationReads.length).toBeGreaterThan(before); + expect(notificationReads.at(-1)).toContain("afterRevision=0"); + revision = "2"; + notification.revision = revision; + peer.terminate(); + const recovered = await reconnectedInbox; + expect(recovered.revision).toBe("2"); + expect(receivedTickets).toEqual(["Bearer hub-ticket-1", "Bearer hub-ticket-2"]); + expect(notificationReads.at(-1)).toContain("afterRevision=1"); + expect(toasts).toBe(1); + } finally { + client.stop(); + for (const socket of server.clients) socket.terminate(); + await new Promise((resolve) => server.close(resolve)); + } +}); + +test("the configured SignalR retry policy stops after four reconnect attempts", async () => { + const connection = createConnection( + "https://returnyoutubedislikeapi.com/api/discussion/notifications/hub", + async () => "ticket", + ); + const policy = connection._reconnectPolicy; + expect( + [0, 1, 2, 3, 4].map((previousRetryCount) => + policy.nextRetryDelayInMilliseconds({ + previousRetryCount, + elapsedMilliseconds: 0, + retryReason: new Error("disconnected"), + }), + ), + ).toEqual([0, 2000, 10000, 30000, null]); + await connection.stop(); +}); diff --git a/Extensions/combined/src/discussion/bridge.spec.js b/Extensions/combined/src/discussion/bridge.spec.js new file mode 100644 index 0000000..48363cb --- /dev/null +++ b/Extensions/combined/src/discussion/bridge.spec.js @@ -0,0 +1,218 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url":"https://www.youtube.com/watch?v=abcdefghijk"} + */ + +const source = require("fs").readFileSync(require("path").join(__dirname, "../../comments-bridge.js"), "utf8"); +const VIDEO = "abcdefghijk"; +let handler; + +beforeEach(() => { + document.body.replaceChildren(); + window.history.replaceState({}, "", `/watch?v=${VIDEO}`); + delete window[Symbol.for("return-youtube-dislike.discussion-bridge")]; + const spy = jest.spyOn(document, "addEventListener"); + window.eval(source); + handler = spy.mock.calls.find(([name]) => name === "ryd-discussion-state-request")[1]; + spy.mockRestore(); +}); +afterEach(() => { + document.removeEventListener("ryd-discussion-state-request", handler, true); + delete window.ytInitialPlayerResponse; +}); + +function state(root) { + root.dispatchEvent(new Event("ryd-discussion-state-request")); + return JSON.parse(root.getAttribute("data-ryd-discussion-native-state")); +} +function watch() { + document.body.innerHTML = ``; + const root = document.querySelector("ytd-watch-flexy"); + root.querySelector("ytd-comments").polymerController = { + data: { + contents: [ + { + messageRenderer: { + text: { + runs: [ + { text: "Комментарии отключены." }, + { + navigationEndpoint: { + urlEndpoint: { url: "https://support.google.com/youtube/answer/9706180?hl=ru" }, + }, + }, + ], + }, + }, + }, + ], + }, + }; + return root; +} + +function miniplayerNotice( + text, + helpUrl = "//support.google.com/youtube/bin/answer.py?answer=9632097&nohelpkit=1&hl=en", +) { + return { + miniplayer: { + miniplayerRenderer: { + playbackMode: "PLAYBACK_MODE_PAUSED_ONLY", + minimizedEndpoint: { + openPopupAction: { + popup: { + notificationActionRenderer: { + responseText: { simpleText: text }, + actionButton: { + buttonRenderer: { + command: { + urlEndpoint: { url: helpUrl }, + commandMetadata: { webCommandMetadata: { url: helpUrl } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; +} + +const ENGLISH_KIDS_NOTICE = "Miniplayer is off for videos made for kids. Tap play to resume"; + +test("reads disabled watch evidence from the page-owned controller without text matching", () => { + expect(state(watch())).toEqual({ videoId: VIDEO, disabled: true, madeForKids: false }); +}); + +test("only an explicit kids flag for the current video excludes the discussion", () => { + const root = watch(); + window.ytInitialPlayerResponse = { videoDetails: { videoId: "zyxwvutsrqp", isMadeForKids: true } }; + expect(state(root).madeForKids).toBe(false); + window.ytInitialPlayerResponse = { + videoDetails: { videoId: VIDEO }, + microformat: { playerMicroformatRenderer: { isFamilySafe: true } }, + }; + expect(state(root).madeForKids).toBe(false); + window.ytInitialPlayerResponse.videoDetails.isMadeForKids = true; + expect(state(root).madeForKids).toBe(true); +}); + +test("recognizes the current video's explicit miniplayer kids notice", () => { + const root = watch(); + window.ytInitialPlayerResponse = { + videoDetails: { videoId: VIDEO }, + playabilityStatus: miniplayerNotice(ENGLISH_KIDS_NOTICE), + }; + expect(state(root).madeForKids).toBe(true); + window.ytInitialPlayerResponse.videoDetails.videoId = "zyxwvutsrqp"; + expect(state(root).madeForKids).toBe(false); +}); + +test.each([ + ["en", "//support.google.com/youtube/bin/answer.py?answer=9632097&nohelpkit=1&hl=en"], + ["ru", "//support.google.com/youtube/bin/answer.py?answer=9632097&nohelpkit=1&hl=ru"], + ["ru", "https://support.google.com/youtube/answer/9632097?hl=ru"], +])("uses the current miniplayer's made-for-kids help link independently of %s wording", (language, helpUrl) => { + const root = watch(); + document.documentElement.lang = language; + window.ytInitialPlayerResponse = { + videoDetails: { videoId: VIDEO }, + playabilityStatus: miniplayerNotice("Localized miniplayer notice", helpUrl), + }; + expect(state(root).madeForKids).toBe(true); + delete window.ytInitialPlayerResponse.playabilityStatus.miniplayer.miniplayerRenderer.minimizedEndpoint + .openPopupAction.popup.notificationActionRenderer.actionButton.buttonRenderer.command.urlEndpoint; + expect(state(root).madeForKids).toBe(true); + window.ytInitialPlayerResponse.playabilityStatus.miniplayer.miniplayerRenderer.playbackMode = "PLAYBACK_MODE_ALLOW"; + expect(state(root).madeForKids).toBe(false); +}); + +test.each([ + "https://support.google.com/youtube/answer/9706180", + "https://support.google.com.example.test/youtube/answer/9632097", + "https://support.google.com/youtube/bin/answer.py?answer=9706180", + "", +])("does not infer a kids restriction from miniplayer text with an unrelated or absent help link: %s", (helpUrl) => { + const root = watch(); + window.ytInitialPlayerResponse = { + videoDetails: { videoId: VIDEO }, + playabilityStatus: miniplayerNotice(ENGLISH_KIDS_NOTICE, helpUrl), + }; + expect(state(root).madeForKids).toBe(false); +}); + +test("ignores matching miniplayer text in descriptions, recommendations and advertisements", () => { + const root = watch(); + const kids = { videoDetails: { videoId: "zyxwvutsrqp" }, playabilityStatus: miniplayerNotice(ENGLISH_KIDS_NOTICE) }; + window.ytInitialPlayerResponse = { + videoDetails: { videoId: VIDEO, shortDescription: ENGLISH_KIDS_NOTICE }, + playabilityStatus: miniplayerNotice( + "Miniplayer is unavailable. Please try again.", + "https://support.google.com/youtube/answer/9706180", + ), + endscreen: { videos: [kids] }, + adPlacements: [kids], + }; + expect(state(root).madeForKids).toBe(false); +}); + +test("finds a disabled Shorts comment button through its controller model", () => { + window.history.replaceState({}, "", `/shorts/${VIDEO}`); + document.body.innerHTML = ``; + const root = document.querySelector("ytd-reel-video-renderer"); + root.querySelector("reel-action-bar-view-model").polymerController = { + data: { + buttonViewModels: [ + { buttonViewModel: { iconName: "SHORTS_COMMENT", state: "BUTTON_VIEW_MODEL_STATE_DISABLED" } }, + ], + }, + }; + expect(state(root)).toEqual({ videoId: VIDEO, disabled: true, madeForKids: false }); + root.querySelector("reel-action-bar-view-model").polymerController.data.buttonViewModels[0].buttonViewModel.state = + "BUTTON_VIEW_MODEL_STATE_ACTIVE"; + expect(state(root).disabled).toBe(false); +}); + +test.each(["isMadeForKids", "madeForKids", "is_made_for_kids"])( + "accepts current response and microformat %s flags but ignores unrelated nested videos", + (flag) => { + const root = watch(); + window.ytInitialPlayerResponse = { + videoDetails: { videoId: VIDEO }, + endscreen: { videos: [{ videoId: "zyxwvutsrqp", [flag]: true }] }, + adPlacements: [{ videoDetails: { videoId: "zyxwvutsrqp", [flag]: true } }], + microformat: { playerMicroformatRenderer: { recommendations: [{ [flag]: true }] } }, + }; + expect(state(root).madeForKids).toBe(false); + window.ytInitialPlayerResponse.microformat.playerMicroformatRenderer[flag] = true; + expect(state(root).madeForKids).toBe(true); + window.ytInitialPlayerResponse.microformat.playerMicroformatRenderer.videoId = "zyxwvutsrqp"; + expect(state(root).madeForKids).toBe(false); + window.ytInitialPlayerResponse[flag] = true; + expect(state(root).madeForKids).toBe(true); + }, +); + +test("does not expose a result for an outgoing Shorts renderer", () => { + window.history.replaceState({}, "", `/shorts/${VIDEO}`); + document.body.innerHTML = ''; + expect(state(document.querySelector("ytd-reel-video-renderer"))).toBeNull(); +}); + +test("recognizes an unmarked current reel by contained player identity while rejecting retained reels", () => { + window.history.replaceState({}, "", `/shorts/${VIDEO}`); + document.body.innerHTML = + "
"; + document.getElementById("shorts-player").getVideoData = () => ({ video_id: VIDEO }); + for (const action of document.querySelectorAll("reel-action-bar-view-model")) + action.data = { + buttonViewModels: [ + { buttonViewModel: { iconName: "SHORTS_COMMENT", state: "BUTTON_VIEW_MODEL_STATE_DISABLED" } }, + ], + }; + expect(state(document.getElementById("retained"))).toBeNull(); + expect(state(document.getElementById("current"))).toEqual({ videoId: VIDEO, disabled: true, madeForKids: false }); +}); diff --git a/Extensions/combined/src/discussion/client.js b/Extensions/combined/src/discussion/client.js new file mode 100644 index 0000000..428e576 --- /dev/null +++ b/Extensions/combined/src/discussion/client.js @@ -0,0 +1,88 @@ +function runtimeApi() { + return typeof chrome !== "undefined" ? chrome.runtime : globalThis.browser?.runtime; +} + +export function requestDiscussion(operation, payload = {}, scope) { + return new Promise((resolve, reject) => { + const runtime = runtimeApi(); + if (!runtime?.sendMessage) { + reject(Object.assign(new Error("Discussion service unavailable"), { code: "unavailable" })); + return; + } + let settled = false; + const finish = (response) => { + if (settled) return; + settled = true; + if (runtime.lastError) { + reject(Object.assign(new Error(runtime.lastError.message), { code: "unavailable" })); + } else if (response?.ok) { + resolve(response.data); + } else { + reject(Object.assign(new Error(response?.error?.message || "Discussion request failed"), response?.error)); + } + }; + try { + const result = runtime.sendMessage( + { message: "ryd_discussion", operation, payload, ...(scope ? { scope } : {}) }, + finish, + ); + if (result?.then) + result.then(finish, (error) => { + if (!settled) { + settled = true; + reject(error); + } + }); + } catch (error) { + reject(error); + } + }); +} + +const VIDEO_OPERATIONS = new Set([ + "comments", + "replies", + "comment", + "createComment", + "editComment", + "deleteComment", + "react", + "report", +]); + +export function createDiscussionRequestScope(videoId, request = requestDiscussion) { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 15) | 64; + bytes[8] = (bytes[8] & 63) | 128; + const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""); + const scope = { + id: `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`, + videoId, + }; + let cancelled = false; + const cancelledError = () => Object.assign(new Error("The video changed"), { code: "navigation_changed" }); + return { + request(operation, payload = {}) { + if (cancelled) return Promise.reject(cancelledError()); + return request(operation, payload, VIDEO_OPERATIONS.has(operation) ? scope : undefined).then((data) => { + if (cancelled) throw cancelledError(); + return data; + }); + }, + cancel() { + if (cancelled) return; + cancelled = true; + request("cancelScope", {}, scope).catch(() => {}); + }, + }; +} + +export function subscribeDiscussionEvents(listener) { + const event = runtimeApi()?.onMessage; + const receive = (message) => { + if (message?.message === "ryd_discussion_event") listener(message); + }; + event?.addListener(receive); + return () => event?.removeListener(receive); +} diff --git a/Extensions/combined/src/discussion/client.spec.js b/Extensions/combined/src/discussion/client.spec.js new file mode 100644 index 0000000..a76e93c --- /dev/null +++ b/Extensions/combined/src/discussion/client.spec.js @@ -0,0 +1,76 @@ +/** @jest-environment jsdom */ +import { createDiscussionRequestScope, requestDiscussion, subscribeDiscussionEvents } from "./client"; + +afterEach(() => { + delete global.chrome; +}); + +test("uses a typed worker envelope and returns only the response data", async () => { + const sendMessage = jest.fn((message, done) => done({ ok: true, data: { items: [] } })); + global.chrome = { runtime: { sendMessage } }; + await expect(requestDiscussion("comments", { videoId: "abcdefghijk", sort: "top" })).resolves.toEqual({ items: [] }); + expect(sendMessage.mock.calls[0][0]).toEqual({ + message: "ryd_discussion", + operation: "comments", + payload: { videoId: "abcdefghijk", sort: "top" }, + }); +}); + +test("preserves safe server error codes and status for translated feedback", async () => { + global.chrome = { + runtime: { + sendMessage: (_, done) => done({ ok: false, error: { code: "nickname_taken", message: "Taken", status: 409 } }), + }, + }; + await expect(requestDiscussion("profile", { nickname: "Writer" })).rejects.toMatchObject({ + code: "nickname_taken", + status: 409, + }); +}); + +test("subscribes to discussion-only events and removes its listener", () => { + let receive; + const removeListener = jest.fn(); + global.chrome = { + runtime: { + onMessage: { + addListener: (listener) => { + receive = listener; + }, + removeListener, + }, + }, + }; + const callback = jest.fn(); + const unsubscribe = subscribeDiscussionEvents(callback); + receive({ message: "patreon_status_changed" }); + expect(callback).not.toHaveBeenCalled(); + const event = { message: "ryd_discussion_event", type: "inbox", data: { items: [] } }; + receive(event); + expect(callback).toHaveBeenCalledWith(event); + unsubscribe(); + expect(removeListener).toHaveBeenCalledWith(receive); +}); + +test("a navigation scope sends cancellation and rejects a response that arrives afterwards", async () => { + let complete; + const sendMessage = jest.fn((message, done) => { + if (message.operation === "comments") complete = done; + else done({ ok: true, data: { cancelled: true } }); + }); + global.chrome = { runtime: { sendMessage } }; + const scoped = createDiscussionRequestScope("abcdefghijk"); + const response = scoped.request("comments", { videoId: "abcdefghijk" }); + const scope = sendMessage.mock.calls[0][0].scope; + scoped.cancel(); + expect(sendMessage.mock.calls[1][0]).toEqual({ + message: "ryd_discussion", + operation: "cancelScope", + payload: {}, + scope, + }); + complete({ ok: true, data: { items: [] } }); + await expect(response).rejects.toMatchObject({ code: "navigation_changed" }); + await expect(scoped.request("comments")).rejects.toMatchObject({ code: "navigation_changed" }); + expect(sendMessage).toHaveBeenCalledTimes(2); +}); diff --git a/Extensions/combined/src/discussion/detector.js b/Extensions/combined/src/discussion/detector.js new file mode 100644 index 0000000..efee49d --- /dev/null +++ b/Extensions/combined/src/discussion/detector.js @@ -0,0 +1,87 @@ +export const NATIVE_STATE_ATTRIBUTE = "data-ryd-discussion-native-state"; +export const NATIVE_STATE_REQUEST = "ryd-discussion-state-request"; + +export function discussionRoute(url = location.href) { + try { + const parsed = new URL(url); + if (parsed.hostname !== "www.youtube.com") return null; + const surface = parsed.pathname === "/watch" ? "watch" : parsed.pathname.startsWith("/shorts/") ? "shorts" : null; + const videoId = surface === "shorts" ? parsed.pathname.split("/")[2] : parsed.searchParams.get("v"); + return surface && /^[A-Za-z0-9_-]{11}$/.test(videoId || "") ? { videoId, surface } : null; + } catch { + return null; + } +} + +export function isVisibleSurface(root) { + if (!root?.isConnected || root.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + for (let element = root; element; element = element.parentElement) { + const style = getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden") return false; + } + return true; +} + +function nativeState(root) { + root.removeAttribute(NATIVE_STATE_ATTRIBUTE); + root.dispatchEvent(new Event(NATIVE_STATE_REQUEST, { bubbles: false })); + let state; + try { + state = JSON.parse(root.getAttribute(NATIVE_STATE_ATTRIBUTE)); + } catch { + return null; + } finally { + root.removeAttribute(NATIVE_STATE_ATTRIBUTE); + } + return state; +} + +function disabledWatchNotice(root) { + return [...root.querySelectorAll("ytd-comments ytd-message-renderer a[href]")].some((anchor) => { + try { + const url = new URL(anchor.href); + return ( + url.hostname === "support.google.com" && url.pathname === "/youtube/answer/9706180" && isVisibleSurface(anchor) + ); + } catch { + return false; + } + }); +} + +export function findDiscussionSurface(route = discussionRoute()) { + if (!route) return null; + const roots = document.querySelectorAll( + route.surface === "watch" ? "ytd-watch-flexy, ytd-watch-grid" : "ytd-reel-video-renderer", + ); + for (const root of roots) { + if (!isVisibleSurface(root)) continue; + if (route.surface === "shorts") { + const rect = root.getBoundingClientRect(); + if ( + rect.width <= 0 || + rect.height <= 0 || + rect.bottom <= 0 || + rect.right <= 0 || + rect.top >= innerHeight || + rect.left >= innerWidth + ) + continue; + } + if (route.surface === "watch" && root.getAttribute("video-id") !== route.videoId) continue; + const state = nativeState(root); + if ( + state?.madeForKids === true || + ["is-made-for-kids", "made-for-kids"].some((key) => root.hasAttribute(key) && root.getAttribute(key) !== "false") + ) + continue; + const disabled = state?.videoId === route.videoId && state.disabled === true; + if (!disabled && !(route.surface === "watch" && disabledWatchNotice(root))) continue; + const host = + route.surface === "watch" + ? root.querySelector("ytd-comments") + : root.querySelector("reel-action-bar-view-model, #actions"); + if (host) return { ...route, root, host }; + } + return null; +} diff --git a/Extensions/combined/src/discussion/detector.spec.js b/Extensions/combined/src/discussion/detector.spec.js new file mode 100644 index 0000000..50d3244 --- /dev/null +++ b/Extensions/combined/src/discussion/detector.spec.js @@ -0,0 +1,97 @@ +/** @jest-environment jsdom */ + +import { discussionRoute, findDiscussionSurface, NATIVE_STATE_ATTRIBUTE, NATIVE_STATE_REQUEST } from "./detector"; + +const VIDEO = "abcdefghijk"; +let listener; + +beforeEach(() => { + document.body.replaceChildren(); + document.documentElement.lang = "en"; +}); +afterEach(() => { + if (listener) document.removeEventListener(NATIVE_STATE_REQUEST, listener, true); + listener = null; +}); + +function watch(videoId = VIDEO) { + const root = document.createElement("ytd-watch-flexy"); + root.setAttribute("video-id", videoId); + root.innerHTML = ""; + document.body.append(root); + return root; +} + +function respond(state) { + listener = (event) => event.target.setAttribute(NATIVE_STATE_ATTRIBUTE, JSON.stringify(state)); + document.addEventListener(NATIVE_STATE_REQUEST, listener, true); +} + +test("only supports canonical desktop watch and Shorts routes with a valid identity", () => { + expect(discussionRoute(`https://www.youtube.com/watch?v=${VIDEO}`)).toEqual({ videoId: VIDEO, surface: "watch" }); + expect(discussionRoute(`https://www.youtube.com/shorts/${VIDEO}`)).toEqual({ videoId: VIDEO, surface: "shorts" }); + for (const url of [ + "https://www.youtube.com/", + "https://www.youtube.com/watch?v=oops", + `https://m.youtube.com/watch?v=${VIDEO}`, + `https://www.youtube.com.evil.test/watch?v=${VIDEO}`, + ]) + expect(discussionRoute(url)).toBeNull(); +}); + +test.each(["Comments are turned off.", "Комментарии отключены."])( + "recognizes the native disabled help endpoint independent of text: %s", + (text) => { + const root = watch(); + root.querySelector("ytd-comments").innerHTML = + `${text}Подробнее`; + expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })?.root).toBe(root); + }, +); + +test("missing comments and generic messages never count as disabled", () => { + const root = watch(); + root.querySelector("ytd-comments").innerHTML = "Loading comments"; + expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })).toBeNull(); +}); + +test("accepts positive native disabled evidence when kids status is unknown", () => { + const root = watch(); + respond({ videoId: VIDEO, disabled: true }); + expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })?.host).toBe(root.querySelector("ytd-comments")); + expect(root.hasAttribute(NATIVE_STATE_ATTRIBUTE)).toBe(false); +}); + +test("positive made-for-kids proof overrides disabled proof and help link", () => { + watch(); + respond({ videoId: VIDEO, disabled: true, madeForKids: true }); + expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })).toBeNull(); +}); + +test("ignores stale hidden roots and stale bridge identity", () => { + const hidden = watch(); + hidden.hidden = true; + watch("zyxwvutsrqp"); + respond({ videoId: VIDEO, disabled: true }); + expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })).toBeNull(); + hidden.hidden = false; + document.removeEventListener(NATIVE_STATE_REQUEST, listener, true); + respond({ videoId: "zyxwvutsrqp", disabled: true }); + expect(findDiscussionSurface({ videoId: VIDEO, surface: "watch" })).toBeNull(); +}); + +test("Shorts requires a current native disabled state and action surface", () => { + document.body.innerHTML = + ""; + document.querySelector("ytd-reel-video-renderer").getBoundingClientRect = () => ({ + width: 400, + height: 700, + top: 0, + left: 0, + bottom: 700, + right: 400, + }); + expect(findDiscussionSurface({ videoId: VIDEO, surface: "shorts" })).toBeNull(); + respond({ videoId: VIDEO, disabled: true, madeForKids: false }); + expect(findDiscussionSurface({ videoId: VIDEO, surface: "shorts" })?.host.tagName).toBe("REEL-ACTION-BAR-VIEW-MODEL"); +}); diff --git a/Extensions/combined/src/discussion/dom.js b/Extensions/combined/src/discussion/dom.js new file mode 100644 index 0000000..9eb7037 --- /dev/null +++ b/Extensions/combined/src/discussion/dom.js @@ -0,0 +1,72 @@ +export function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text != null) node.textContent = text; + return node; +} + +export function button(text, onClick, className = "ryd-discussion-button") { + const node = element("button", className, text); + node.type = "button"; + if (onClick) node.addEventListener("click", onClick); + return node; +} + +export function statusNode() { + const node = element("p", "ryd-discussion-status"); + node.setAttribute("role", "status"); + node.setAttribute("aria-live", "polite"); + return node; +} + +export function isDiscussionCommentId(value) { + return /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value || ""); +} + +export function discussionLinkTarget(value = location.href) { + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.hostname !== "www.youtube.com") return null; + const videoId = + url.pathname === "/watch" + ? url.searchParams.get("v") + : /^\/shorts\/([A-Za-z0-9_-]{11})\/?$/.exec(url.pathname)?.[1]; + const commentId = new URLSearchParams(url.hash.slice(1)).get("rydComment") || url.searchParams.get("rydComment"); + return /^[A-Za-z0-9_-]{11}$/.test(videoId || "") && isDiscussionCommentId(commentId) + ? { videoId, commentId } + : null; + } catch { + return null; + } +} + +export function threadUrl(notification) { + if (!/^[A-Za-z0-9_-]{11}$/.test(notification?.videoId || "") || !isDiscussionCommentId(notification?.commentId)) + return null; + const url = new URL("https://www.youtube.com/watch"); + url.searchParams.set("v", notification.videoId); + url.hash = new URLSearchParams({ rydComment: notification.commentId }).toString(); + return url.href; +} + +export function shortDate(value, language) { + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? "" + : date.toLocaleDateString(language, { month: "short", day: "numeric", year: "numeric" }); +} + +export function thumbIcon(dislike = false) { + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + icon.setAttribute("viewBox", "0 0 24 24"); + icon.setAttribute("aria-hidden", "true"); + icon.classList.add("ryd-discussion-thumb"); + if (dislike) icon.classList.add("is-down"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute( + "d", + "M7 10v12H2V10h5Zm2 12V10l5-8 1.1.4c.9.4 1.4 1.4 1.2 2.4L15.5 9H20a2 2 0 0 1 2 2v2l-3 9H9ZM3.5 11.5v9H5.5v-9h-2Zm7 9h7.4l2.6-7.7V11a.5.5 0 0 0-.5-.5h-6.3l1.1-6a.8.8 0 0 0-.2-.6L10.5 10.4v10.1Z", + ); + icon.append(path); + return icon; +} diff --git a/Extensions/combined/src/discussion/dom.spec.js b/Extensions/combined/src/discussion/dom.spec.js new file mode 100644 index 0000000..93bde4e --- /dev/null +++ b/Extensions/combined/src/discussion/dom.spec.js @@ -0,0 +1,37 @@ +/** @jest-environment jsdom */ + +import { discussionLinkTarget, isDiscussionCommentId, threadUrl } from "./dom"; + +const videoId = "abcdefghijk"; +const commentId = "11111111-1111-4111-8111-111111111111"; + +test("notification links carry a validated fragment through a native video URL", () => { + const url = threadUrl({ videoId, commentId }); + expect(new URL(url).searchParams.has("rydComment")).toBe(false); + expect(discussionLinkTarget(url)).toEqual({ videoId, commentId }); + expect(discussionLinkTarget(`https://www.youtube.com/shorts/${videoId}#rydComment=${commentId}`)).toEqual({ + videoId, + commentId, + }); + expect(discussionLinkTarget(`https://www.youtube.com/watch?v=${videoId}&rydComment=${commentId}`)).toEqual({ + videoId, + commentId, + }); +}); + +test.each([ + `https://example.com/watch?v=${videoId}#rydComment=${commentId}`, + `http://www.youtube.com/watch?v=${videoId}#rydComment=${commentId}`, + `https://www.youtube.com/feed?v=${videoId}#rydComment=${commentId}`, + `https://www.youtube.com/watch?v=short#rydComment=${commentId}`, + `https://www.youtube.com/watch?v=${videoId}#rydComment=${"-".repeat(36)}`, + "javascript:alert(1)", +])("rejects an invalid discussion destination: %s", (url) => { + expect(discussionLinkTarget(url)).toBeNull(); +}); + +test("UUID validation rejects same-length malformed identifiers", () => { + expect(isDiscussionCommentId(commentId)).toBe(true); + expect(isDiscussionCommentId("1".repeat(36))).toBe(false); + expect(threadUrl({ videoId, commentId: "-".repeat(36) })).toBeNull(); +}); diff --git a/Extensions/combined/src/discussion/inbox.js b/Extensions/combined/src/discussion/inbox.js new file mode 100644 index 0000000..563ca01 --- /dev/null +++ b/Extensions/combined/src/discussion/inbox.js @@ -0,0 +1,174 @@ +import { requestDiscussion } from "./client"; +import { discussionText as t, discussionError, discussionLanguage } from "./localize"; +import { element, button, statusNode, threadUrl, shortDate } from "./dom"; + +export function notificationText(notification) { + return notification.type === "reply" + ? t("notificationReply", [notification.actorNickname || t("someone")]) + : notification.likeCount > 1 + ? t("notificationLikes", [notification.likeCount]) + : t("notificationLike"); +} + +export function createDiscussionInbox({ request = requestDiscussion, authenticated = true } = {}) { + const node = element("section", "ryd-discussion-inbox"); + const heading = element("div", "ryd-discussion-toolbar"); + heading.append(element("h3", "ryd-discussion-subtitle", t("notifications"))); + const readAll = button(t("markAllRead"), async () => { + readAll.disabled = true; + try { + await request("readNotifications", { all: true }); + await load(); + } catch (error) { + status.textContent = discussionError(error); + } finally { + readAll.disabled = false; + } + }); + heading.append(readAll); + const list = element("div", "ryd-discussion-inbox-list"); + const status = statusNode(); + const more = button(t("more"), () => load(true)); + more.hidden = true; + node.append(heading, status, list, more); + let items = []; + let cursor = null; + let disposed = false; + let generation = 0; + let hasPage = false; + let accountId = null; + + function render() { + list.replaceChildren(); + readAll.hidden = !authenticated || !items.some((item) => !item.read); + if (!authenticated) { + status.textContent = t("signedOutInbox"); + more.hidden = true; + return; + } + status.textContent = items.length ? "" : t("notificationEmpty"); + for (const item of items) { + const row = element("article", `ryd-discussion-notification${item.read ? "" : " is-unread"}`); + row.dataset.notificationId = item.id; + const link = element("a", "ryd-discussion-notification-link", notificationText(item)); + const url = threadUrl(item); + if (url) { + link.href = url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.addEventListener("click", () => { + request("readNotifications", { ids: [item.id] }) + .then(() => { + item.read = true; + render(); + }) + .catch(() => {}); + }); + } + const date = element("time", "ryd-discussion-meta", shortDate(item.createdAt, discussionLanguage())); + date.dateTime = item.createdAt; + row.append(link, date); + if (!item.read) + row.append( + button(t("markRead"), async (event) => { + event.currentTarget.disabled = true; + try { + await request("readNotifications", { ids: [item.id] }); + item.read = true; + render(); + } catch (error) { + status.textContent = discussionError(error); + event.target.disabled = false; + } + }), + ); + list.append(row); + } + more.hidden = !cursor; + } + + async function load(append = false) { + if (disposed || !authenticated) { + render(); + return; + } + const current = ++generation; + status.textContent = t("loading"); + more.disabled = true; + try { + const result = await request("notifications", append && cursor ? { cursor } : {}); + if (disposed || current !== generation) return; + const combined = append ? [...items, ...(result.items || [])] : result.items || []; + items = [...new Map(combined.map((item) => [item.id, item])).values()]; + cursor = result.nextCursor; + hasPage = true; + render(); + } catch (error) { + if (!disposed && current === generation) status.textContent = discussionError(error); + } finally { + more.disabled = false; + } + } + + return { + node, + load, + setSession(session) { + authenticated = session?.authenticated === true; + const nextId = authenticated ? session.profile?.id || null : null; + generation++; + if (!authenticated || nextId !== accountId) { + items = []; + cursor = null; + hasPage = false; + } + accountId = nextId; + render(); + }, + update(inbox, { live = false } = {}) { + if (disposed || !authenticated) return; + generation++; + if (live) { + items = [...new Map([...items, ...(inbox?.items || [])].map((item) => [item.id, item])).values()].sort((a, b) => + String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt)), + ); + } else { + items = inbox?.items || []; + } + if (!live || !hasPage) cursor = inbox?.nextCursor; + hasPage = true; + render(); + }, + destroy() { + disposed = true; + generation++; + node.remove(); + }, + }; +} + +export function showDiscussionToast(notification) { + document.querySelector(".ryd-discussion-toast")?.remove(); + const toast = element("aside", "ryd-discussion-toast"); + toast.setAttribute("role", "status"); + toast.setAttribute("aria-live", "polite"); + const copy = element("div"); + copy.append(element("strong", "", t("title")), element("p", "", notificationText(notification))); + const url = threadUrl(notification); + if (url) { + const link = element("a", "ryd-discussion-button", t("view")); + link.href = url; + link.dataset.notificationId = notification.id; + link.addEventListener("click", () => + requestDiscussion("readNotifications", { ids: [notification.id] }).catch(() => {}), + ); + copy.append(link); + } + toast.append( + copy, + button("×", () => toast.remove(), "ryd-discussion-close"), + ); + toast.lastChild.setAttribute("aria-label", t("close")); + document.body.append(toast); + setTimeout(() => toast.remove(), 10000); +} diff --git a/Extensions/combined/src/discussion/inbox.spec.js b/Extensions/combined/src/discussion/inbox.spec.js new file mode 100644 index 0000000..7f77c6f --- /dev/null +++ b/Extensions/combined/src/discussion/inbox.spec.js @@ -0,0 +1,101 @@ +/** @jest-environment jsdom */ + +import { createDiscussionInbox, notificationText } from "./inbox"; +import { threadUrl } from "./dom"; + +const ID = "11111111-1111-4111-8111-111111111111"; +const notification = { + id: ID, + videoId: "abcdefghijk", + commentId: ID, + type: "reply", + actorNickname: "Reader", + createdAt: "2026-09-01T00:00:00Z", + read: false, +}; +let inbox; +beforeEach(() => { + document.body.replaceChildren(); + document.documentElement.lang = "en"; +}); +afterEach(() => inbox?.destroy()); + +test("builds a canonical discussion link rather than accepting an arbitrary destination", () => { + expect(threadUrl(notification)).toBe(`https://www.youtube.com/watch?v=abcdefghijk#rydComment=${ID}`); + expect(threadUrl({ ...notification, videoId: "javascript:alert(1)" })).toBeNull(); +}); + +test("renders nicknames as text and identifies each notification for inbox actions", async () => { + const request = jest + .fn() + .mockResolvedValue({ items: [{ ...notification, actorNickname: "" }], nextCursor: null }); + inbox = createDiscussionInbox({ request }); + document.body.append(inbox.node); + await inbox.load(); + expect(inbox.node.querySelector("img")).toBeNull(); + expect(inbox.node.querySelector("[data-notification-id]").dataset.notificationId).toBe(ID); + expect(inbox.node.textContent).toContain(""); +}); + +test("logout prevents an older inbox response from exposing the previous account's rows", async () => { + let resolve; + const request = jest.fn( + () => + new Promise((done) => { + resolve = done; + }), + ); + inbox = createDiscussionInbox({ request }); + document.body.append(inbox.node); + const pending = inbox.load(); + inbox.setSession({ authenticated: false }); + resolve({ items: [notification] }); + await pending; + expect(inbox.node.querySelector("[data-notification-id]")).toBeNull(); +}); + +test("a newer shared inbox broadcast cannot be overwritten by an older pending read", async () => { + let resolve; + const request = jest.fn( + () => + new Promise((done) => { + resolve = done; + }), + ); + inbox = createDiscussionInbox({ request }); + document.body.append(inbox.node); + const pending = inbox.load(); + inbox.update({ items: [notification], unreadCount: 1 }); + resolve({ items: [], unreadCount: 0 }); + await pending; + expect(inbox.node.querySelector("[data-notification-id]")).not.toBeNull(); +}); + +test("anonymous likes notifications do not expose a reaction identity", () => { + document.documentElement.lang = "ru"; + expect(notificationText({ type: "likes", actorNickname: null })).toBe("Кому-то понравился ваш комментарий"); +}); + +test("a live update preserves loaded history and the remaining pagination cursor", async () => { + const older = { ...notification, id: "22222222-2222-4222-8222-222222222222", createdAt: "2026-08-31T00:00:00Z" }; + const newest = { ...notification, id: "33333333-3333-4333-8333-333333333333", createdAt: "2026-09-02T00:00:00Z" }; + const request = jest.fn().mockResolvedValue({ items: [older], nextCursor: "oldest-page" }); + inbox = createDiscussionInbox({ request }); + inbox.update({ items: [notification], nextCursor: "older-page" }); + await inbox.load(true); + inbox.update({ items: [newest, notification], nextCursor: "older-page" }, { live: true }); + expect([...inbox.node.querySelectorAll("[data-notification-id]")].map((node) => node.dataset.notificationId)).toEqual( + [newest.id, notification.id, older.id], + ); + await inbox.load(true); + expect(request).toHaveBeenLastCalledWith("notifications", { cursor: "oldest-page" }); +}); + +test("an account replacement clears old rows and pagination even without an intervening anonymous event", () => { + inbox = createDiscussionInbox(); + inbox.setSession({ authenticated: true, profile: { id: "first-account" } }); + inbox.update({ items: [notification], nextCursor: "older-page" }); + inbox.setSession({ authenticated: true, profile: { id: "second-account" } }); + expect(inbox.node.querySelector("[data-notification-id]")).toBeNull(); + expect(inbox.node.lastChild.hidden).toBe(true); +}); diff --git a/Extensions/combined/src/discussion/index.js b/Extensions/combined/src/discussion/index.js new file mode 100644 index 0000000..0827488 --- /dev/null +++ b/Extensions/combined/src/discussion/index.js @@ -0,0 +1,284 @@ +import { createDiscussionRequestScope, requestDiscussion, subscribeDiscussionEvents } from "./client"; +import { discussionRoute, findDiscussionSurface } from "./detector"; +import { discussionLanguage, discussionText as t } from "./localize"; +import { createDiscussionPanel } from "./panel"; +import { showDiscussionToast } from "./inbox"; +import { button, element, discussionLinkTarget, isDiscussionCommentId } from "./dom"; + +let installed = null; + +export function initDiscussion({ request = requestDiscussion } = {}) { + if (installed) return installed; + let panel = null; + let panelScope = null; + let surface = null; + let launcher = null; + let backdrop = null; + let drawerOpenVideoId = null; + let focusedCommentKey = null; + let session = { authenticated: false, profile: null, canPost: false, isModerator: false }; + let capabilities = {}; + let inbox = null; + let disposed = false; + let navigationPending = false; + let navigationOrigin = null; + let language = discussionLanguage(); + let scheduled = null; + let sessionGeneration = 0; + + const bridge = document.createElement("script"); + bridge.src = chrome.runtime.getURL("comments-bridge.js"); + bridge.onload = () => { + bridge.remove(); + refresh(); + }; + bridge.onerror = () => { + bridge.remove(); + refresh(); + }; + (document.head || document.documentElement).append(bridge); + + function clear() { + focusedCommentKey = null; + panelScope?.cancel(); + panelScope = null; + panel?.destroy(); + panel = null; + launcher?.remove(); + launcher = null; + backdrop?.remove(); + backdrop = null; + surface = null; + } + + function closeDrawer() { + drawerOpenVideoId = null; + if (!panel || surface?.surface !== "shorts") return; + panel.root.hidden = true; + if (backdrop) backdrop.hidden = true; + launcher?.setAttribute("aria-expanded", "false"); + launcher?.focus(); + } + + function openDrawer() { + if (!panel || surface?.surface !== "shorts") return; + drawerOpenVideoId = surface.videoId; + panel.root.hidden = false; + backdrop.hidden = false; + launcher.setAttribute("aria-expanded", "true"); + panel.root.focus(); + } + + async function focusLinkedComment(target, force = false) { + if (!target || !panel || surface?.videoId !== target.videoId || discussionRoute()?.videoId !== target.videoId) + return; + const key = `${target.videoId}:${target.commentId}`; + if (!force && focusedCommentKey === key) return; + focusedCommentKey = key; + if (surface.surface === "shorts") openDrawer(); + await panel.focusComment(target.commentId); + } + + function notificationLinkClick(event) { + if ( + event.defaultPrevented || + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) + return; + const anchor = event.target?.closest?.(".ryd-discussion-notification-link, .ryd-discussion-toast a[href]"); + if (!anchor || anchor.hasAttribute("download") || (anchor.target && anchor.target !== "_self")) return; + const target = discussionLinkTarget(anchor.href); + if (!target || !panel || surface?.videoId !== target.videoId || discussionRoute()?.videoId !== target.videoId) + return; + event.preventDefault(); + event.stopPropagation(); + const url = new URL(location.href); + url.searchParams.delete("rydComment"); + url.hash = new URLSearchParams({ rydComment: target.commentId }).toString(); + history.replaceState(history.state, "", url.href); + const notificationId = anchor.closest("[data-notification-id]")?.dataset.notificationId; + if (isDiscussionCommentId(notificationId)) request("readNotifications", { ids: [notificationId] }).catch(() => {}); + focusLinkedComment(target, true); + } + + async function refresh() { + if (disposed) return; + const route = discussionRoute(); + const linkedTarget = discussionLinkTarget(); + if (route?.surface !== "shorts" || route.videoId !== drawerOpenVideoId) drawerOpenVideoId = null; + if (navigationPending && route?.videoId === navigationOrigin) return; + if (navigationPending && route?.videoId !== navigationOrigin) navigationPending = false; + const nextLanguage = discussionLanguage(); + if (nextLanguage !== language) { + language = nextLanguage; + clear(); + } + const next = capabilities.enabled === true ? findDiscussionSurface(route) : null; + if (!next) { + clear(); + return; + } + if ( + surface?.videoId === next.videoId && + surface?.root === next.root && + surface?.host === next.host && + panel?.root.isConnected && + (!launcher || launcher.isConnected) + ) { + await focusLinkedComment(linkedTarget); + return; + } + clear(); + surface = next; + panelScope = createDiscussionRequestScope(next.videoId, request); + panel = createDiscussionPanel({ + videoId: next.videoId, + surface: next.surface, + session, + capabilities, + request: panelScope.request, + ...(next.surface === "shorts" ? { onClose: closeDrawer } : {}), + }); + if (inbox) panel.setInbox(inbox); + if (next.surface === "watch") { + next.host.insertAdjacentElement("afterend", panel.root); + } else { + launcher = button("RYD", openDrawer, "ryd-discussion-launcher"); + launcher.id = "ryd-discussion-launcher"; + launcher.dataset.videoId = next.videoId; + launcher.setAttribute("aria-label", t("open")); + launcher.setAttribute("title", t("open")); + launcher.setAttribute("aria-haspopup", "dialog"); + launcher.setAttribute("aria-expanded", "false"); + next.host.insertAdjacentElement("afterend", launcher); + backdrop = element("div", "ryd-discussion-backdrop"); + backdrop.hidden = true; + backdrop.addEventListener("click", closeDrawer); + panel.root.hidden = true; + document.body.append(backdrop, panel.root); + if (drawerOpenVideoId === next.videoId) openDrawer(); + } + const currentPanel = panel; + await currentPanel.load(); + if (currentPanel !== panel || disposed) return; + await focusLinkedComment(discussionLinkTarget() || linkedTarget); + } + + function schedule() { + if (disposed || scheduled) return; + scheduled = setTimeout(() => { + scheduled = null; + refresh(); + }, 150); + } + function navigationStart() { + drawerOpenVideoId = null; + navigationPending = true; + navigationOrigin = discussionRoute()?.videoId; + clear(); + } + function navigationFinish() { + navigationPending = false; + refresh(); + } + function keyboard(event) { + if (!panel || surface?.surface !== "shorts" || panel.root.hidden) return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closeDrawer(); + } + if (event.key === "Tab") { + const focusable = [...panel.root.querySelectorAll("button, input, textarea, select, a[href]")].filter( + (node) => !node.disabled && !node.closest("[hidden]"), + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.root)) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + } + } + const observer = new MutationObserver(schedule); + observer.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["lang", "video-id", "is-active", "hidden"], + }); + document.addEventListener("yt-navigate-start", navigationStart); + document.addEventListener("yt-navigate-finish", navigationFinish); + window.addEventListener("pagehide", navigationStart); + window.addEventListener("pageshow", navigationFinish); + window.addEventListener("hashchange", refresh); + window.addEventListener("popstate", refresh); + document.addEventListener("click", notificationLinkClick, true); + document.addEventListener("keydown", keyboard, true); + const timer = setInterval(refresh, 1000); + const unsubscribe = subscribeDiscussionEvents((event) => { + if (disposed) return; + if (event.type === "session") { + sessionGeneration++; + session = event.data; + panel?.setSession(session); + if (!session?.authenticated) { + inbox = null; + document.querySelector(".ryd-discussion-toast")?.remove(); + } + } + if (event.type === "inbox") { + inbox = event.data; + panel?.setInbox(inbox); + } + if (event.type === "toast" && session.authenticated) showDiscussionToast(event.data); + }); + const initialSession = sessionGeneration; + request("session") + .then((value) => { + if (!disposed && initialSession === sessionGeneration) { + session = value; + panel?.setSession(session); + } + }) + .catch(() => {}); + request("capabilities") + .then((value) => { + if (!disposed) { + capabilities = value || {}; + refresh(); + } + }) + .catch(() => {}); + refresh(); + installed = { + refresh, + destroy() { + disposed = true; + drawerOpenVideoId = null; + clear(); + clearInterval(timer); + clearTimeout(scheduled); + observer.disconnect(); + unsubscribe(); + bridge.remove(); + document.removeEventListener("yt-navigate-start", navigationStart); + document.removeEventListener("yt-navigate-finish", navigationFinish); + window.removeEventListener("pagehide", navigationStart); + window.removeEventListener("pageshow", navigationFinish); + window.removeEventListener("hashchange", refresh); + window.removeEventListener("popstate", refresh); + document.removeEventListener("click", notificationLinkClick, true); + document.removeEventListener("keydown", keyboard, true); + installed = null; + }, + }; + return installed; +} diff --git a/Extensions/combined/src/discussion/index.spec.js b/Extensions/combined/src/discussion/index.spec.js new file mode 100644 index 0000000..f84b21e --- /dev/null +++ b/Extensions/combined/src/discussion/index.spec.js @@ -0,0 +1,349 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url":"https://www.youtube.com/watch?v=abcdefghijk"} + */ + +import { initDiscussion } from "./index"; +import { NATIVE_STATE_REQUEST, NATIVE_STATE_ATTRIBUTE } from "./detector"; +import { threadUrl } from "./dom"; + +const VIDEO = "abcdefghijk"; +const OTHER = "zyxwvutsrqp"; +const ROOT_COMMENT = "11111111-1111-4111-8111-111111111111"; +const REPLY_COMMENT = "22222222-2222-4222-8222-222222222222"; +const flush = async () => { + for (let i = 0; i < 15; i++) await Promise.resolve(); +}; +let feature; +let listener; +let eventListener; + +beforeEach(() => { + jest.useFakeTimers(); + document.body.innerHTML = ``; + window.history.replaceState({}, "", `/watch?v=${VIDEO}`); + document.documentElement.lang = "en"; + global.chrome = { + runtime: { + getURL: (path) => `chrome-extension://test/${path}`, + onMessage: { + addListener: (value) => { + eventListener = value; + }, + removeListener: jest.fn(), + }, + }, + }; + listener = (event) => + event.target.setAttribute( + NATIVE_STATE_ATTRIBUTE, + JSON.stringify({ videoId: event.target.getAttribute("video-id"), disabled: true, madeForKids: false }), + ); + document.addEventListener(NATIVE_STATE_REQUEST, listener, true); +}); +afterEach(() => { + feature?.destroy(); + document.removeEventListener(NATIVE_STATE_REQUEST, listener, true); + jest.useRealTimers(); + delete global.chrome; +}); + +function request(operation) { + if (operation === "session") return Promise.resolve({ authenticated: false, canPost: false, profile: null }); + if (operation === "capabilities") return Promise.resolve({ enabled: true }); + return Promise.resolve({ items: [], nextCursor: null }); +} + +function shortsFixture(videoId = VIDEO) { + window.history.replaceState({}, "", `/shorts/${videoId}`); + document.body.innerHTML = ``; + const reel = document.querySelector("ytd-reel-video-renderer"); + reel.getBoundingClientRect = () => ({ width: 400, height: 600, top: 0, left: 0, right: 400, bottom: 600 }); + return reel; +} + +function linkRequest(operation, payload) { + if (operation === "session") + return Promise.resolve({ authenticated: true, canPost: false, profile: { id: "reader" } }); + if (operation === "comment") + return Promise.resolve({ + id: payload.id, + videoId: VIDEO, + parentId: payload.id === REPLY_COMMENT ? ROOT_COMMENT : null, + replyToId: payload.id === REPLY_COMMENT ? ROOT_COMMENT : null, + author: { id: "author", nickname: "Writer" }, + text: payload.id === REPLY_COMMENT ? "Linked reply" : "Root comment", + createdAt: "2026-09-01T00:00:00Z", + replyCount: 1, + likes: 0, + dislikes: 0, + }); + return request(operation); +} + +function notificationAnchor({ target = "", videoId = VIDEO } = {}) { + const toast = document.createElement("aside"); + toast.className = "ryd-discussion-toast"; + const anchor = document.createElement("a"); + anchor.href = threadUrl({ videoId, commentId: REPLY_COMMENT }); + anchor.target = target; + anchor.dataset.notificationId = ROOT_COMMENT; + toast.append(anchor); + document.body.append(toast); + return anchor; +} + +test("repeated native observer checks mount exactly one panel and fetch once", async () => { + const call = jest.fn(request); + feature = initDiscussion({ request: call }); + await flush(); + jest.advanceTimersByTime(4000); + await flush(); + expect(document.querySelectorAll("#ryd-discussion-panel")).toHaveLength(1); + expect(call.mock.calls.filter(([operation]) => operation === "comments")).toHaveLength(1); +}); + +test.each(["pending", "unavailable", "disabled"])( + "%s capabilities leave the native comments page untouched", + async (state) => { + const native = document.body.innerHTML; + const call = jest.fn((operation) => { + if (operation !== "capabilities") return request(operation); + if (state === "pending") return new Promise(() => {}); + if (state === "unavailable") return Promise.reject(Object.assign(new Error("Not found"), { status: 404 })); + return Promise.resolve({ enabled: false }); + }); + feature = initDiscussion({ request: call }); + await flush(); + jest.advanceTimersByTime(4000); + await flush(); + expect(document.body.innerHTML).toBe(native); + expect(call.mock.calls.some(([operation]) => operation === "comments")).toBe(false); + }, +); + +test("an enabled service still shows subsequent comment request failures", async () => { + feature = initDiscussion({ + request: (operation) => + operation === "comments" + ? Promise.reject(Object.assign(new Error("Unavailable"), { code: "network_error" })) + : request(operation), + }); + await flush(); + expect(document.getElementById("ryd-discussion-panel")).not.toBeNull(); + expect(document.querySelector(".ryd-discussion-thread > .ryd-discussion-status").textContent).toBe( + "Unable to connect. Please try again.", + ); +}); + +test("navigation removes outgoing content immediately and ignores its delayed response", async () => { + let resolve; + const call = jest.fn((operation) => + operation === "comments" + ? new Promise((done) => { + resolve = done; + }) + : request(operation), + ); + feature = initDiscussion({ request: call }); + await flush(); + document.dispatchEvent(new Event("yt-navigate-start")); + expect(document.getElementById("ryd-discussion-panel")).toBeNull(); + const commentsScope = call.mock.calls.find(([operation]) => operation === "comments")[2]; + expect(commentsScope.videoId).toBe(VIDEO); + expect(call).toHaveBeenCalledWith("cancelScope", {}, commentsScope); + resolve({ items: [{ id: "late", text: "Old private draft" }] }); + await flush(); + window.history.replaceState({}, "", `/watch?v=${OTHER}`); + document.querySelector("ytd-watch-flexy").setAttribute("video-id", OTHER); + call.mockImplementation(request); + document.dispatchEvent(new Event("yt-navigate-finish")); + await flush(); + expect(document.getElementById("ryd-discussion-panel").dataset.videoId).toBe(OTHER); + expect(document.body.textContent).not.toContain("Old private draft"); +}); + +test("a logout event wins over an older initial session read", async () => { + let resolve; + feature = initDiscussion({ + request: (operation) => + operation === "session" + ? new Promise((done) => { + resolve = done; + }) + : request(operation), + }); + eventListener({ + message: "ryd_discussion_event", + type: "session", + data: { authenticated: false, canPost: false, profile: null }, + }); + resolve({ authenticated: true, canPost: true, profile: { id: "old", nickname: "Previous" } }); + await flush(); + expect(document.querySelector("textarea[name='comment']")).toBeNull(); + expect(document.body.textContent).not.toContain("Previous"); +}); + +test("restoring a cached page recreates the panel with a fresh request scope", async () => { + const call = jest.fn(request); + feature = initDiscussion({ request: call }); + await flush(); + const previousScope = call.mock.calls.find(([operation]) => operation === "comments")[2]; + window.dispatchEvent(new Event("pagehide")); + expect(document.getElementById("ryd-discussion-panel")).toBeNull(); + expect(call).toHaveBeenCalledWith("cancelScope", {}, previousScope); + window.dispatchEvent(new Event("pageshow")); + await flush(); + const reads = call.mock.calls.filter(([operation]) => operation === "comments"); + expect(reads).toHaveLength(2); + expect(reads[1][2].videoId).toBe(VIDEO); + expect(reads[1][2].id).not.toBe(previousScope.id); + expect(document.querySelectorAll("#ryd-discussion-panel")).toHaveLength(1); +}); + +test("switching YouTube language refreshes the panel language", async () => { + feature = initDiscussion({ request }); + await flush(); + document.documentElement.lang = "ru"; + await feature.refresh(); + expect(document.querySelector(".ryd-discussion-title").textContent).toBe("Комментарии RYD"); +}); + +test("an opened Shorts drawer survives a transiently hidden and replaced same-video container", async () => { + const oldReel = shortsFixture(); + const call = jest.fn(request); + feature = initDiscussion({ request: call }); + await flush(); + document.getElementById("ryd-discussion-launcher").click(); + const oldDrawer = document.getElementById("ryd-discussion-drawer"); + const oldScope = call.mock.calls.find(([operation]) => operation === "comments")[2]; + expect(oldDrawer.hidden).toBe(false); + oldReel.hidden = true; + await feature.refresh(); + expect(document.getElementById("ryd-discussion-drawer")).toBeNull(); + expect(call).toHaveBeenCalledWith("cancelScope", {}, oldScope); + shortsFixture(); + await feature.refresh(); + const nextDrawer = document.getElementById("ryd-discussion-drawer"); + expect(nextDrawer).not.toBe(oldDrawer); + expect(nextDrawer.hidden).toBe(false); + expect(document.querySelector(".ryd-discussion-backdrop").hidden).toBe(false); + expect(document.getElementById("ryd-discussion-launcher").getAttribute("aria-expanded")).toBe("true"); + expect(document.querySelectorAll("#ryd-discussion-drawer")).toHaveLength(1); + const nextScope = call.mock.calls.filter(([operation]) => operation === "comments").at(-1)[2]; + expect(nextScope.videoId).toBe(VIDEO); + expect(nextScope.id).not.toBe(oldScope.id); +}); + +test("closing a Shorts drawer clears its intent before the native container is replaced", async () => { + shortsFixture(); + feature = initDiscussion({ request }); + await flush(); + document.getElementById("ryd-discussion-launcher").click(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + shortsFixture(); + await feature.refresh(); + expect(document.getElementById("ryd-discussion-drawer").hidden).toBe(true); + expect(document.getElementById("ryd-discussion-launcher").getAttribute("aria-expanded")).toBe("false"); +}); + +test.each(["navigation-event", "different-video"])( + "%s does not carry an opened Shorts drawer into the next panel", + async (change) => { + shortsFixture(); + feature = initDiscussion({ request }); + await flush(); + document.getElementById("ryd-discussion-launcher").click(); + if (change === "navigation-event") document.dispatchEvent(new Event("yt-navigate-start")); + shortsFixture(change === "different-video" ? OTHER : VIDEO); + if (change === "navigation-event") document.dispatchEvent(new Event("yt-navigate-finish")); + else await feature.refresh(); + await flush(); + expect(document.getElementById("ryd-discussion-drawer").hidden).toBe(true); + }, +); + +test("remembered Shorts open intent cannot retain an ineligible panel", async () => { + const reel = shortsFixture(); + feature = initDiscussion({ request }); + await flush(); + document.getElementById("ryd-discussion-launcher").click(); + reel.setAttribute("made-for-kids", "true"); + await feature.refresh(); + expect(document.getElementById("ryd-discussion-drawer")).toBeNull(); + expect(document.getElementById("ryd-discussion-launcher")).toBeNull(); + expect(document.querySelector(".ryd-discussion-backdrop")).toBeNull(); +}); + +test("a hash target focuses a reply in an existing panel and switches from inbox without stealing focus again", async () => { + const call = jest.fn(linkRequest); + feature = initDiscussion({ request: call }); + await flush(); + const original = document.getElementById("ryd-discussion-panel"); + original.querySelector("[data-action=inbox]").click(); + window.history.replaceState({}, "", `/watch?v=${VIDEO}#rydComment=${REPLY_COMMENT}`); + window.dispatchEvent(new Event("hashchange")); + await flush(); + expect(document.getElementById("ryd-discussion-panel")).toBe(original); + expect(original.querySelector(".ryd-discussion-inbox").hidden).toBe(true); + expect(document.activeElement.dataset.commentId).toBe(REPLY_COMMENT); + const before = call.mock.calls.filter(([operation]) => operation === "comment").length; + const tab = original.querySelector("[data-action=discussion]"); + tab.focus(); + await feature.refresh(); + expect(document.activeElement).toBe(tab); + expect(call.mock.calls.filter(([operation]) => operation === "comment")).toHaveLength(before); + window.history.replaceState({}, "", `/watch?v=${VIDEO}#rydComment=${ROOT_COMMENT}`); + window.dispatchEvent(new Event("popstate")); + await flush(); + expect(document.activeElement.dataset.commentId).toBe(ROOT_COMMENT); +}); + +test.each(["watch", "shorts"])( + "a same-video %s toast click focuses directly before native navigation", + async (surface) => { + if (surface === "shorts") shortsFixture(); + const call = jest.fn(linkRequest); + feature = initDiscussion({ request: call }); + await flush(); + const anchor = notificationAnchor(); + const click = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }); + anchor.dispatchEvent(click); + expect(click.defaultPrevented).toBe(true); + expect(location.hash).toBe(`#rydComment=${REPLY_COMMENT}`); + expect(location.pathname).toBe(surface === "shorts" ? `/shorts/${VIDEO}` : "/watch"); + await flush(); + expect(document.activeElement.dataset.commentId).toBe(REPLY_COMMENT); + expect(call).toHaveBeenCalledWith("readNotifications", { ids: [ROOT_COMMENT] }); + if (surface === "shorts") expect(document.getElementById("ryd-discussion-drawer").hidden).toBe(false); + }, +); + +test.each([ + { ctrlKey: true }, + { metaKey: true }, + { shiftKey: true }, + { altKey: true }, + { button: 1 }, + { target: "_blank" }, + { videoId: OTHER }, +])("notification links preserve modified clicks and separate destinations: %j", async (options) => { + feature = initDiscussion({ request: linkRequest }); + await flush(); + const anchor = notificationAnchor(options); + let preventedByFeature; + anchor.addEventListener("click", (event) => { + preventedByFeature = event.defaultPrevented; + event.preventDefault(); + }); + anchor.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, ...options })); + expect(preventedByFeature).toBe(false); + expect(location.hash).toBe(""); +}); + +test("a new page opened with a fragment focuses its target after initial panel hydration", async () => { + window.history.replaceState({}, "", `/watch?v=${VIDEO}#rydComment=${REPLY_COMMENT}`); + feature = initDiscussion({ request: linkRequest }); + await flush(); + expect(document.activeElement.dataset.commentId).toBe(REPLY_COMMENT); +}); diff --git a/Extensions/combined/src/discussion/localize.js b/Extensions/combined/src/discussion/localize.js new file mode 100644 index 0000000..393d40e --- /dev/null +++ b/Extensions/combined/src/discussion/localize.js @@ -0,0 +1,44 @@ +import english from "../../_locales/en/messages.json"; +import russian from "../../_locales/ru/messages.json"; + +export function discussionLanguage(language = document.documentElement.lang || navigator.language) { + return /^ru(?:-|$)/i.test(language || "") ? "ru" : "en"; +} + +export function discussionText(key, substitutions = [], language) { + const name = `discussion_${key}`; + const catalog = discussionLanguage(language) === "ru" ? russian : english; + const text = catalog[name]?.message || english[name]?.message || key; + return text.replace(/\$(\d+)/g, (_, number) => String(substitutions[Number(number) - 1] ?? "")); +} + +export function discussionError(error) { + const keys = { + nickname_required: "nicknameRequired", + nickname_taken: "nicknameTaken", + invalid_nickname: "nicknameInvalid", + membership_tier_insufficient: "tierRequired", + membership_inactive: "tierRequired", + invalid_session: "signInRequired", + session_upgrade_required: "signInRequired", + unauthorized: "signInRequired", + comments_not_allowed: "unavailable", + comments_disabled_required: "unavailable", + made_for_kids: "unavailable", + video_not_eligible: "unavailable", + comment_not_found: "commentUnavailable", + not_found: "commentUnavailable", + rate_limited: "rateLimited", + conflict: "conflict", + forbidden: "forbidden", + }; + const statusKey = + error?.status === 401 + ? "signInRequired" + : error?.status === 403 + ? "forbidden" + : error?.status === 429 + ? "rateLimited" + : "error"; + return discussionText(keys[error?.code] || statusKey); +} diff --git a/Extensions/combined/src/discussion/panel.js b/Extensions/combined/src/discussion/panel.js new file mode 100644 index 0000000..0f6b9c2 --- /dev/null +++ b/Extensions/combined/src/discussion/panel.js @@ -0,0 +1,700 @@ +import { requestDiscussion } from "./client"; +import { discussionText as t, discussionError, discussionLanguage } from "./localize"; +import { element, button, statusNode, shortDate, thumbIcon, isDiscussionCommentId } from "./dom"; +import { createDiscussionInbox } from "./inbox"; + +const SIGNED_OUT = { authenticated: false, canPost: false, isModerator: false, profile: null }; + +export function createDiscussionPanel({ + videoId, + surface = "watch", + session = SIGNED_OUT, + capabilities = {}, + request = requestDiscussion, + onClose, +} = {}) { + const maxLength = capabilities.maxCommentLength || 2000; + const root = element("section", `ryd-discussion ryd-discussion-${surface}`); + root.id = surface === "shorts" ? "ryd-discussion-drawer" : "ryd-discussion-panel"; + root.dataset.videoId = videoId; + root.setAttribute("aria-label", t("title")); + if (surface === "shorts") { + root.setAttribute("role", "dialog"); + root.setAttribute("aria-modal", "true"); + root.tabIndex = -1; + } + const header = element("header", "ryd-discussion-header"); + const brand = element("div", "ryd-discussion-brand"); + brand.append(element("span", "ryd-discussion-logo", "RYD"), element("h2", "ryd-discussion-title", t("title"))); + const headerButtons = element("div", "ryd-discussion-header-actions"); + const inboxButton = button(t("notifications"), () => showTab("inbox")); + const moderationButton = button(t("moderation"), () => showTab("moderation")); + const discussionButton = button(t("discussionTab"), () => showTab("discussion")); + inboxButton.dataset.action = "inbox"; + moderationButton.dataset.action = "moderation"; + discussionButton.dataset.action = "discussion"; + headerButtons.append(discussionButton, inboxButton, moderationButton); + if (onClose) { + const close = button("×", onClose, "ryd-discussion-close"); + close.setAttribute("aria-label", t("close")); + headerButtons.append(close); + } + header.append(brand, headerButtons); + const intro = element("p", "ryd-discussion-description", t("subtitle")); + const independent = element("p", "ryd-discussion-independent", t("independent")); + const body = element("div", "ryd-discussion-body"); + const discussion = element("div", "ryd-discussion-thread"); + const composer = element("div", "ryd-discussion-composer"); + const toolbar = element("div", "ryd-discussion-toolbar"); + const sort = element("select", "ryd-discussion-select"); + sort.setAttribute("aria-label", t("sort")); + for (const value of ["top", "new"]) { + const option = element("option", "", t(value)); + option.value = value; + sort.append(option); + } + sort.addEventListener("change", () => load()); + const refresh = button(t("refresh"), () => load()); + toolbar.append(sort, refresh); + const status = statusNode(); + const list = element("div", "ryd-discussion-comments"); + const more = button(t("more"), () => load(true), "ryd-discussion-button ryd-discussion-more"); + more.hidden = true; + discussion.append(composer, toolbar, status, list, more); + const inbox = createDiscussionInbox({ request, authenticated: session.authenticated }); + const moderation = element("div", "ryd-discussion-moderation"); + inbox.node.hidden = true; + moderation.hidden = true; + body.append(discussion, inbox.node, moderation); + root.append(header, intro, independent, body); + let disposed = false; + let accountGeneration = 0; + let sequence = 0; + let items = []; + let cursor = null; + let activeTab = "discussion"; + const drafts = new Map(); + const postingIds = new Map(); + const rootReplyHandlers = new Map(); + let moderationSequence = 0; + let reportCursor = null; + let reportStatus = "open"; + let reports = []; + let highlightedId = null; + let focusSequence = 0; + + function current() { + return !disposed; + } + function setError(error) { + if (current()) status.textContent = discussionError(error); + } + + function showTab(tab, reload = true) { + if (tab === "moderation" && !session.isModerator) return; + if (tab === "inbox" && !session.authenticated) return; + const previousTab = activeTab; + activeTab = tab; + discussion.hidden = tab !== "discussion"; + inbox.node.hidden = tab !== "inbox"; + moderation.hidden = tab !== "moderation"; + discussionButton.setAttribute("aria-pressed", String(tab === "discussion")); + inboxButton.setAttribute("aria-pressed", String(tab === "inbox")); + moderationButton.setAttribute("aria-pressed", String(tab === "moderation")); + if (tab === "inbox") inbox.load(); + if (tab === "moderation") loadReports(); + if (reload && tab === "discussion" && previousTab === "moderation") load(); + } + + function renderSession() { + inboxButton.hidden = !session.authenticated; + moderationButton.hidden = !session.isModerator; + discussionButton.hidden = !session.authenticated; + if ((activeTab === "inbox" && !session.authenticated) || (activeTab === "moderation" && !session.isModerator)) + showTab("discussion"); + composer.replaceChildren(); + if (!session.canPost || session.profile?.suspended) { + composer.append(element("p", "ryd-discussion-gate", t("gate"))); + const join = element("a", "ryd-discussion-link", t("join")); + join.href = "https://www.patreon.com/returnyoutubedislike/membership"; + join.target = "_blank"; + join.rel = "noopener noreferrer"; + composer.append(join); + if (!session.authenticated) composer.append(element("p", "ryd-discussion-meta", t("signInRequired"))); + } else if (!session.profile?.nickname) { + composer.append(nicknameForm()); + } else { + const account = element("div", "ryd-discussion-account"); + account.append( + element("span", "ryd-discussion-meta", t("as", [session.profile.nickname])), + button(t("nicknameChange"), () => { + composer.replaceChildren(nicknameForm(true)); + }), + ); + composer.append(account, commentForm()); + } + renderComments(); + } + + function nicknameForm(allowCancel = false) { + const form = element("form", "ryd-discussion-form"); + const label = element("label", "ryd-discussion-label", t("nicknameLabel")); + const input = element("input", "ryd-discussion-input"); + input.name = "nickname"; + input.value = session.profile?.nickname || ""; + input.required = true; + input.minLength = capabilities.minNicknameLength || 3; + input.maxLength = capabilities.maxNicknameLength || 32; + input.autocomplete = "nickname"; + label.append(input); + const message = statusNode(); + const actions = element("div", "ryd-discussion-form-actions"); + const submit = button(t("nicknameSave"), null, "ryd-discussion-button ryd-discussion-primary"); + submit.type = "submit"; + actions.append(submit); + if (allowCancel) actions.append(button(t("cancel"), renderSession)); + form.append( + element("p", "ryd-discussion-description", t("nicknameRequired")), + label, + element("p", "ryd-discussion-meta", t("nicknameHint")), + message, + actions, + ); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submit.disabled) return; + const nickname = input.value.trim().normalize("NFKC"); + if ( + nickname.length < input.minLength || + nickname.length > input.maxLength || + !/^[\p{L}\p{N} ._-]+$/u.test(nickname) || + !/[\p{L}\p{N}]/u.test(nickname) + ) { + message.textContent = t("nicknameInvalid"); + return; + } + submit.disabled = true; + message.textContent = t("saving"); + const accountSnapshot = accountGeneration; + try { + const result = await request("profile", { nickname }); + if (!current() || accountSnapshot !== accountGeneration) return; + session = result; + renderSession(); + } catch (error) { + if (current()) message.textContent = discussionError(error); + } finally { + submit.disabled = false; + } + }); + return form; + } + + function commentForm({ parentId = null, replyToId = null, comment = null, onCancel, onSaved } = {}) { + const key = comment ? `edit:${comment.id}` : `reply:${replyToId || "root"}`; + const form = element("form", "ryd-discussion-form"); + const label = element("label", "ryd-discussion-label"); + label.append(element("span", "ryd-discussion-sr-only", t(comment ? "edit" : replyToId ? "reply" : "comment"))); + const input = element("textarea", "ryd-discussion-textarea"); + input.name = "comment"; + input.placeholder = t("placeholder"); + input.rows = 3; + input.maxLength = maxLength; + input.required = true; + input.value = drafts.get(key) ?? comment?.text ?? ""; + label.append(input); + const footer = element("div", "ryd-discussion-form-actions"); + const counter = element("span", "ryd-discussion-counter"); + const submit = button( + t(comment ? "save" : replyToId ? "postReply" : "post"), + null, + "ryd-discussion-button ryd-discussion-primary", + ); + submit.type = "submit"; + submit.dataset.action = comment ? "save" : "post"; + const update = () => { + drafts.set(key, input.value); + counter.textContent = t("characters", [input.value.length, maxLength]); + submit.disabled = !input.value.trim() || input.value.length > maxLength; + postingIds.delete(key); + }; + input.addEventListener("input", update); + input.addEventListener("keydown", (event) => { + if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { + event.preventDefault(); + form.requestSubmit(); + } + }); + counter.textContent = t("characters", [input.value.length, maxLength]); + submit.disabled = !input.value.trim(); + footer.append(counter); + if (onCancel) footer.append(button(t("cancel"), onCancel)); + footer.append(submit); + const message = statusNode(); + form.append(label, message, footer); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submit.disabled) return; + const text = input.value.trim(); + if (!text || text.length > maxLength) return; + submit.disabled = true; + input.disabled = true; + message.textContent = t("saving"); + const requestId = postingIds.get(key) || globalThis.crypto.randomUUID(); + postingIds.set(key, requestId); + try { + const payload = comment + ? { id: comment.id, text } + : { videoId, text, requestId, ...(parentId ? { parentId, replyToId } : {}) }; + const result = await request(comment ? "editComment" : "createComment", payload); + if (!current()) return; + drafts.delete(key); + postingIds.delete(key); + if (onSaved) onSaved(result); + else { + input.value = ""; + update(); + message.textContent = ""; + await load(); + } + } catch (error) { + if (current()) message.textContent = discussionError(error); + } finally { + if (current()) { + input.disabled = false; + submit.disabled = !input.value.trim(); + } + } + }); + return form; + } + + function commentRow(comment, rootId = comment.parentId || comment.id) { + const row = element("article", `ryd-discussion-comment${comment.parentId ? " ryd-discussion-reply" : ""}`); + row.dataset.commentId = comment.id; + if (comment.id === highlightedId) row.classList.add("is-highlighted"); + const heading = element("div", "ryd-discussion-comment-heading"); + const initial = (comment.author?.nickname || "RYD").slice(0, 1).toUpperCase(); + heading.append( + element("span", "ryd-discussion-avatar", initial), + element("strong", "ryd-discussion-author", comment.author?.nickname || t("deleted")), + ); + const date = element("time", "ryd-discussion-meta", shortDate(comment.createdAt, discussionLanguage())); + date.dateTime = comment.createdAt; + heading.append(date); + if (comment.editedAt) heading.append(element("span", "ryd-discussion-meta", t("edited"))); + const text = element( + "p", + "ryd-discussion-comment-text", + comment.deleted || comment.hidden ? t("deleted") : comment.text, + ); + const actions = element("div", "ryd-discussion-comment-actions"); + const controls = element("div", "ryd-discussion-inline-editor"); + const replies = element("div", "ryd-discussion-replies"); + row.append(heading, text, actions, controls, replies); + const alive = () => current() && row.isConnected; + if (!comment.deleted && !comment.hidden) { + for (const value of [1, -1]) { + const reaction = button( + "", + async () => { + if (reaction.disabled) return; + const buttons = [...actions.querySelectorAll("[data-reaction]")]; + buttons.forEach((item) => { + item.disabled = true; + }); + try { + const result = await request("react", { + id: comment.id, + value: comment.myReaction === value ? 0 : value, + }); + if (!alive()) return; + Object.assign(comment, result); + updateReactions(); + } catch (error) { + setError(error); + } finally { + buttons.forEach((item) => { + item.disabled = false; + }); + } + }, + "ryd-discussion-button ryd-discussion-reaction", + ); + reaction.dataset.reaction = String(value); + actions.append(reaction); + } + function updateReactions() { + for (const reaction of actions.querySelectorAll("[data-reaction]")) { + const liked = reaction.dataset.reaction === "1"; + const count = liked ? comment.likes || 0 : comment.dislikes || 0; + reaction.replaceChildren(thumbIcon(!liked), element("span", "", count.toLocaleString(discussionLanguage()))); + reaction.setAttribute("aria-label", `${t(liked ? "like" : "dislike")} (${count})`); + reaction.setAttribute("aria-pressed", String(comment.myReaction === (liked ? 1 : -1))); + } + } + updateReactions(); + const reply = button(t("reply"), () => { + controls.replaceChildren(); + if (!session.canPost) { + controls.append(element("p", "ryd-discussion-gate", t("tierRequired"))); + return; + } + if (!session.profile?.nickname) { + controls.append(nicknameForm()); + return; + } + controls.append( + commentForm({ + parentId: rootId, + replyToId: comment.id, + onCancel: () => controls.replaceChildren(), + onSaved: (created) => { + controls.replaceChildren(); + rootReplyHandlers.get(rootId)?.(created); + }, + }), + ); + controls.querySelector("textarea")?.focus(); + }); + reply.dataset.action = "reply"; + actions.append(reply); + if (session.authenticated && comment.author?.id === session.profile?.id) { + if (comment.canEdit) { + const edit = button(t("edit"), () => { + controls.replaceChildren( + commentForm({ + comment, + onCancel: () => controls.replaceChildren(), + onSaved: (updated) => { + Object.assign(comment, updated); + row.replaceWith(commentRow(comment, rootId)); + }, + }), + ); + controls.querySelector("textarea")?.focus(); + }); + edit.dataset.action = "edit"; + actions.append(edit); + } + if (comment.canDelete) { + const remove = button(t("delete"), () => { + const prompt = element("div", "ryd-discussion-confirm"); + prompt.append( + element("span", "", t("deleteConfirm")), + button(t("cancel"), () => controls.replaceChildren()), + ); + const confirm = button( + t("delete"), + async () => { + confirm.disabled = true; + try { + const updated = await request("deleteComment", { id: comment.id }); + if (alive()) { + Object.assign(comment, updated); + row.replaceWith(commentRow(comment, rootId)); + } + } catch (error) { + setError(error); + confirm.disabled = false; + } + }, + "ryd-discussion-button ryd-discussion-danger", + ); + confirm.dataset.action = "confirm-delete"; + prompt.append(confirm); + controls.replaceChildren(prompt); + }); + remove.dataset.action = "delete"; + actions.append(remove); + } + } + if (session.authenticated) { + const report = button(t("report"), () => showReport(comment, controls)); + report.dataset.action = "report"; + actions.append(report); + } + } + let repliesCursor = null; + let replyItems = []; + let replySequence = 0; + let replyLoading = false; + let repliesLoaded = false; + const moreReplies = button(t("more"), () => loadReplies(true)); + const showReplies = button(t("loadReplies", [comment.replyCount || 0]), () => loadReplies()); + showReplies.dataset.action = "load-replies"; + showReplies.hidden = Boolean(comment.parentId) || !comment.replyCount; + actions.append(showReplies); + function renderReplies() { + const timestamp = (value) => + String(value).replace(/(?:\.(\d+))?Z$/, (_, fraction = "") => `.${fraction.padEnd(9, "0")}Z`); + replyItems = [...new Map(replyItems.map((item) => [item.id, item])).values()].sort( + (a, b) => timestamp(a.createdAt).localeCompare(timestamp(b.createdAt)) || a.id.localeCompare(b.id), + ); + comment.replyCount = Math.max(comment.replyCount || 0, replyItems.length); + replies.replaceChildren(...replyItems.map((item) => commentRow(item, rootId))); + if (repliesCursor) replies.append(moreReplies); + } + function addReply(created) { + if (!alive()) return; + if (!replyItems.some((item) => item.id === created.id)) comment.replyCount = (comment.replyCount || 0) + 1; + replyItems.push(created); + renderReplies(); + showReplies.textContent = t("loadReplies", [comment.replyCount]); + showReplies.hidden = repliesLoaded; + if (!repliesLoaded) loadReplies(); + } + async function loadReplies(append = false) { + if (replyLoading) return; + replyLoading = true; + showReplies.disabled = true; + const currentReply = ++replySequence; + try { + const result = await request("replies", { + rootId, + ...(append && repliesCursor ? { cursor: repliesCursor } : {}), + }); + if (!alive() || currentReply !== replySequence) return; + replyItems = [...replyItems, ...(result.items || [])]; + repliesCursor = result.nextCursor; + repliesLoaded = true; + renderReplies(); + showReplies.hidden = true; + } catch (error) { + setError(error); + } finally { + showReplies.disabled = false; + replyLoading = false; + } + } + if (!comment.parentId) rootReplyHandlers.set(comment.id, addReply); + return row; + } + + function showReport(comment, host) { + const form = element("form", "ryd-discussion-form"); + const label = element("label", "ryd-discussion-label", t("reportReason")); + const reason = element("select", "ryd-discussion-select"); + for (const [value, key] of [ + ["spam", "reportSpam"], + ["abuse", "reportAbuse"], + ["other", "reportOther"], + ]) { + const option = element("option", "", t(key)); + option.value = value; + reason.append(option); + } + label.append(reason); + const detailsLabel = element("label", "ryd-discussion-label", t("reportDetails")); + const details = element("textarea", "ryd-discussion-textarea"); + details.maxLength = 400; + details.rows = 2; + detailsLabel.append(details); + const message = statusNode(); + const submit = button(t("reportSend"), null, "ryd-discussion-button ryd-discussion-primary"); + submit.type = "submit"; + submit.dataset.action = "send-report"; + const actions = element("div", "ryd-discussion-form-actions"); + actions.append( + button(t("cancel"), () => host.replaceChildren()), + submit, + ); + form.append(element("h4", "ryd-discussion-subtitle", t("reportTitle")), label, detailsLabel, message, actions); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submit.disabled) return; + submit.disabled = true; + try { + await request("report", { + id: comment.id, + reason: `${reason.value}${details.value.trim() ? `: ${details.value.trim()}` : ""}`, + }); + if (current()) host.replaceChildren(element("p", "ryd-discussion-status", t("reportDone"))); + } catch (error) { + message.textContent = discussionError(error); + submit.disabled = false; + } + }); + host.replaceChildren(form); + } + + function renderComments() { + rootReplyHandlers.clear(); + list.replaceChildren(...items.map((item) => commentRow(item))); + } + + async function load(append = false) { + if (!current()) return; + const currentSequence = ++sequence; + status.textContent = t("loading"); + refresh.disabled = true; + more.disabled = true; + try { + const result = await request("comments", { videoId, sort: sort.value, ...(append && cursor ? { cursor } : {}) }); + if (!current() || currentSequence !== sequence) return; + items = append ? [...items, ...(result.items || [])] : result.items || []; + items = [...new Map(items.map((item) => [item.id, item])).values()]; + cursor = result.nextCursor; + renderComments(); + status.textContent = items.length ? "" : t("empty"); + more.hidden = !cursor; + } catch (error) { + if (current() && currentSequence === sequence) setError(error); + } finally { + if (current() && currentSequence === sequence) { + refresh.disabled = false; + more.disabled = false; + } + } + } + + async function loadReports(append = false) { + if (!current() || !session.isModerator) return; + const currentSequence = ++moderationSequence; + moderation.replaceChildren(element("p", "ryd-discussion-status", t("loading"))); + try { + const result = await request("reports", { + status: reportStatus, + ...(append && reportCursor ? { cursor: reportCursor } : {}), + }); + if (!current() || currentSequence !== moderationSequence || !session.isModerator) return; + reports = append ? [...reports, ...(result.items || [])] : result.items || []; + reportCursor = result.nextCursor; + const reportToolbar = element("div", "ryd-discussion-toolbar"); + const reportFilter = element("select", "ryd-discussion-select"); + reportFilter.dataset.action = "report-status"; + reportFilter.setAttribute("aria-label", t("reportStatus")); + for (const [value, label] of [ + ["open", "openReports"], + ["resolved", "resolvedReports"], + ]) { + const option = element("option", "", t(label)); + option.value = value; + reportFilter.append(option); + } + reportFilter.value = reportStatus; + reportFilter.addEventListener("change", () => { + reportStatus = reportFilter.value; + reportCursor = null; + loadReports(); + }); + reportToolbar.append(element("h3", "ryd-discussion-subtitle", t("moderation")), reportFilter); + moderation.replaceChildren(reportToolbar); + if (!reports.length) moderation.append(element("p", "ryd-discussion-status", t("queueEmpty"))); + for (const report of reports) { + const row = element("article", "ryd-discussion-report"); + row.dataset.reportId = report.id; + row.append( + element("strong", "ryd-discussion-author", report.comment?.author?.nickname || t("deleted")), + element("p", "ryd-discussion-comment-text", report.comment?.text || t("deleted")), + element("p", "ryd-discussion-report-reason", report.reason), + ); + const reasonLabel = element("label", "ryd-discussion-label", t("moderationReason")); + const reason = element("input", "ryd-discussion-input"); + reason.maxLength = 300; + reasonLabel.append(reason); + row.append(reasonLabel); + const actions = element("div", "ryd-discussion-comment-actions"); + const feedback = statusNode(); + const addAction = (key, operation, payload) => { + const action = button(t(key), async () => { + const buttons = [...actions.querySelectorAll("button")]; + buttons.forEach((item) => { + item.disabled = true; + }); + try { + await request(operation, { ...payload, ...(reason.value.trim() ? { reason: reason.value.trim() } : {}) }); + if (current()) await loadReports(); + } catch (error) { + feedback.textContent = discussionError(error); + buttons.forEach((item) => { + item.disabled = false; + }); + } + }); + action.dataset.action = key; + actions.append(action); + }; + if (reportStatus === "open") addAction("dismiss", "dismissReport", { id: report.id }); + addAction(report.comment?.hidden ? "restore" : "hide", "moderateComment", { + id: report.comment.id, + hidden: !report.comment.hidden, + }); + if (report.comment.author) + addAction(report.authorSuspended ? "unban" : "ban", "suspendProfile", { + id: report.comment.author.id, + suspended: !report.authorSuspended, + }); + row.append(actions, feedback); + moderation.append(row); + } + if (reportCursor) moderation.append(button(t("more"), () => loadReports(true))); + } catch (error) { + if (current() && currentSequence === moderationSequence) + moderation.replaceChildren( + element("p", "ryd-discussion-status", discussionError(error)), + button(t("retry"), () => loadReports()), + ); + } + } + + async function focusComment(id) { + if (!isDiscussionCommentId(id)) return; + const focus = ++focusSequence; + const account = accountGeneration; + const stillCurrent = () => current() && focus === focusSequence && account === accountGeneration; + try { + const target = await request("comment", { id }); + if (!stillCurrent() || target.videoId !== videoId) return; + highlightedId = id; + const threadRoot = target.parentId ? await request("comment", { id: target.parentId }) : target; + if (!stillCurrent() || threadRoot.videoId !== videoId) return; + showTab("discussion", false); + if (!items.some((item) => item.id === threadRoot.id)) items.unshift(threadRoot); + renderComments(); + const row = [...list.querySelectorAll("[data-comment-id]")].find( + (item) => item.dataset.commentId === threadRoot.id, + ); + if (target.parentId) row.querySelector(".ryd-discussion-replies").append(commentRow(target, threadRoot.id)); + const focused = [...list.querySelectorAll("[data-comment-id]")].find((item) => item.dataset.commentId === id); + focused?.scrollIntoView?.({ block: "center", behavior: "smooth" }); + if (focused) { + focused.tabIndex = -1; + focused.focus({ preventScroll: true }); + } + } catch (error) { + if (stillCurrent()) setError(error); + } + } + + renderSession(); + showTab("discussion"); + return { + root, + load, + focusComment, + setSession(next) { + const previousIdentity = session.authenticated ? session.profile?.id || "authenticated" : "anonymous"; + const nextIdentity = next?.authenticated ? next.profile?.id || "authenticated" : "anonymous"; + accountGeneration++; + moderationSequence++; + session = next || SIGNED_OUT; + inbox.setSession(session); + renderSession(); + if (previousIdentity !== nextIdentity) load(); + }, + setInbox(value) { + inbox.update(value, { live: true }); + inboxButton.textContent = `${t("notifications")}${value?.unreadCount ? ` (${value.unreadCount})` : ""}`; + }, + destroy() { + disposed = true; + sequence++; + moderationSequence++; + drafts.clear(); + postingIds.clear(); + inbox.destroy(); + root.remove(); + }, + }; +} diff --git a/Extensions/combined/src/discussion/panel.spec.js b/Extensions/combined/src/discussion/panel.spec.js new file mode 100644 index 0000000..05ce6a0 --- /dev/null +++ b/Extensions/combined/src/discussion/panel.spec.js @@ -0,0 +1,433 @@ +/** @jest-environment jsdom */ + +import { createDiscussionPanel } from "./panel"; +import { discussionText } from "./localize"; + +const VIDEO = "abcdefghijk"; +const ROOT = "11111111-1111-1111-1111-111111111111"; +const REPLY = "22222222-2222-2222-2222-222222222222"; +const PROFILE = "33333333-3333-3333-3333-333333333333"; +const session = { + authenticated: true, + canPost: true, + isModerator: false, + profile: { id: PROFILE, nickname: "Writer" }, +}; +const flush = async () => { + for (let i = 0; i < 15; i++) await Promise.resolve(); +}; +let panel; +let request; + +function comment(overrides = {}) { + return { + id: ROOT, + videoId: VIDEO, + parentId: null, + replyToId: null, + author: { id: PROFILE, nickname: "Writer" }, + text: "First comment", + createdAt: "2026-09-01T12:00:00Z", + editedAt: null, + deleted: false, + hidden: false, + likes: 2, + dislikes: 1, + myReaction: 0, + replyCount: 0, + canEdit: true, + canDelete: true, + ...overrides, + }; +} + +async function mount(options = {}, comments = [comment()]) { + request = jest.fn(async (operation) => + operation === "comments" ? { items: comments, nextCursor: null } : { items: [], nextCursor: null }, + ); + panel = createDiscussionPanel({ videoId: VIDEO, request, ...options }); + document.body.append(panel.root); + await panel.load(); + return panel.root; +} + +function submit(form, text) { + const input = form.querySelector("textarea"); + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); +} + +beforeEach(() => { + document.body.replaceChildren(); + document.documentElement.lang = "en"; + Object.defineProperty(globalThis.crypto, "randomUUID", { + configurable: true, + value: () => "44444444-4444-4444-4444-444444444444", + }); +}); +afterEach(() => panel?.destroy()); + +test("an older comment deep-link response cannot replace the user's newer focused target", async () => { + const root = await mount({ session }); + let resolveReply; + request.mockImplementation((operation, payload) => { + if (operation === "comment" && payload.id === REPLY) + return new Promise((resolve) => { + resolveReply = resolve; + }); + if (operation === "comment") return Promise.resolve(comment()); + return Promise.resolve({ items: [], nextCursor: null }); + }); + const oldFocus = panel.focusComment(REPLY); + await panel.focusComment(ROOT); + resolveReply(comment({ id: REPLY, parentId: ROOT, text: "Older linked reply" })); + await oldFocus; + expect(document.activeElement.dataset.commentId).toBe(ROOT); + expect(root.querySelector(`[data-comment-id="${REPLY}"]`)).toBeNull(); +}); + +test("signed-out users can react without seeing author or moderator controls", async () => { + const root = await mount(); + expect(root.querySelector("textarea")).toBeNull(); + expect(root.querySelector("[data-action='edit']")).toBeNull(); + expect(root.querySelector("[data-action='moderation']").hidden).toBe(true); + request.mockResolvedValueOnce({ commentId: ROOT, likes: 3, dislikes: 1, myReaction: 1 }); + root.querySelector("[data-reaction='1']").click(); + await flush(); + expect(request).toHaveBeenLastCalledWith("react", { id: ROOT, value: 1 }); + expect(root.querySelector("[data-reaction='1']").getAttribute("aria-pressed")).toBe("true"); + expect(root.querySelector("[data-reaction='1']").textContent).toBe("3"); + request.mockResolvedValueOnce({ commentId: ROOT, likes: 2, dislikes: 1, myReaction: 0 }); + root.querySelector("[data-reaction='1']").click(); + await flush(); + expect(request).toHaveBeenLastCalledWith("react", { id: ROOT, value: 0 }); +}); + +test("renders comment bodies and nicknames as literal text", async () => { + const root = await mount({}, [ + comment({ + text: '\nSecond line', + author: { id: PROFILE, nickname: "" }, + }), + ]); + expect(root.querySelector(".ryd-discussion-comment-text").textContent).toContain(" { + const root = await mount({ session }); + const form = root.querySelector(".ryd-discussion-composer form"); + request.mockRejectedValueOnce({ code: "unavailable" }); + submit(form, "A new comment"); + await flush(); + const first = request.mock.calls.find(([operation]) => operation === "createComment")[1]; + expect(form.querySelector("textarea").value).toBe("A new comment"); + request.mockResolvedValueOnce(comment({ text: "A new comment" })); + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await flush(); + const creates = request.mock.calls.filter(([operation]) => operation === "createComment"); + expect(creates).toHaveLength(2); + expect(creates[1][1].requestId).toBe(first.requestId); + expect(first).toMatchObject({ videoId: VIDEO, text: "A new comment" }); +}); + +test("guards blank posts and duplicate clicks while saving", async () => { + const root = await mount({ session }); + const form = root.querySelector(".ryd-discussion-composer form"); + submit(form, " "); + expect(request.mock.calls.filter(([operation]) => operation === "createComment")).toHaveLength(0); + let resolve; + request.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + submit(form, "Hello"); + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + expect(request.mock.calls.filter(([operation]) => operation === "createComment")).toHaveLength(1); + resolve(comment()); + await flush(); +}); + +test("nickname onboarding persists a chosen nickname without deriving one from account details", async () => { + const root = await mount({ + session: { ...session, profile: null, fullName: "Private Name", email: "private@example.test" }, + }); + const input = root.querySelector("input[name='nickname']"); + expect(input.value).toBe(""); + expect(root.textContent).not.toContain("private@example.test"); + input.value = "New Writer"; + request.mockResolvedValueOnce({ ...session, profile: { id: PROFILE, nickname: "New Writer" } }); + input.closest("form").dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await flush(); + expect(request).toHaveBeenLastCalledWith("profile", { nickname: "New Writer" }); + expect(root.querySelector("textarea[name='comment']")).not.toBeNull(); +}); + +test("inactive members retain their own edit/delete controls while another account does not", async () => { + const root = await mount({ session: { ...session, canPost: false } }); + expect(root.querySelector("[data-action='edit']")).not.toBeNull(); + expect(root.querySelector("[data-action='delete']")).not.toBeNull(); + panel.setSession({ ...session, profile: { id: REPLY, nickname: "Other" } }); + expect(root.querySelector("[data-action='edit']")).toBeNull(); + panel.setSession({ authenticated: false, profile: null }); + expect(root.querySelector("[data-action='report']")).toBeNull(); +}); + +test("a nickname request finishing after logout cannot restore the previous account composer", async () => { + const root = await mount({ session: { ...session, profile: null } }); + let resolve; + request.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + const input = root.querySelector("input[name='nickname']"); + input.value = "Writer"; + input.closest("form").dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + panel.setSession({ authenticated: false, canPost: false, profile: null }); + resolve(session); + await flush(); + expect(root.querySelector("textarea[name='comment']")).toBeNull(); + expect(root.querySelector("[data-action='inbox']").hidden).toBe(true); +}); + +test("signing in refreshes ownership flags previously fetched while signed out", async () => { + const root = await mount({}, [comment({ canEdit: false, canDelete: false })]); + request.mockResolvedValueOnce({ items: [comment()], nextCursor: null }); + panel.setSession(session); + await flush(); + expect(request).toHaveBeenLastCalledWith("comments", { videoId: VIDEO, sort: "top" }); + expect(root.querySelector("[data-action='edit']")).not.toBeNull(); +}); + +test("deletion waits for explicit inline confirmation and shows a tombstone", async () => { + const root = await mount({ session }); + root.querySelector("[data-action='delete']").click(); + expect(request.mock.calls.some(([operation]) => operation === "deleteComment")).toBe(false); + request.mockResolvedValueOnce(comment({ deleted: true, text: "", author: null })); + root.querySelector("[data-action='confirm-delete']").click(); + await flush(); + expect(request).toHaveBeenLastCalledWith("deleteComment", { id: ROOT }); + expect(root.querySelector(".ryd-discussion-comment-text").textContent).toBe(discussionText("deleted")); +}); + +test("replies preserve the root and immediate target IDs", async () => { + const root = await mount({ session }, [comment({ replyCount: 1 })]); + request.mockResolvedValueOnce({ + items: [comment({ id: REPLY, parentId: ROOT, replyToId: ROOT, text: "First reply" })], + nextCursor: null, + }); + root.querySelector("[data-action='load-replies']").click(); + await flush(); + const reply = root.querySelector(`[data-comment-id='${REPLY}']`); + reply.querySelector("[data-action='reply']").click(); + request.mockResolvedValueOnce( + comment({ id: "55555555-5555-5555-5555-555555555555", parentId: ROOT, replyToId: REPLY }), + ); + submit(reply.querySelector("form"), "Nested response"); + await flush(); + expect(request.mock.calls.find(([operation]) => operation === "createComment")[1]).toMatchObject({ + parentId: ROOT, + replyToId: REPLY, + text: "Nested response", + }); + expect(root.querySelectorAll(".ryd-discussion-reply")).toHaveLength(2); +}); + +test("posting a reply preserves loaded pages and merges the next page without duplicating the new reply", async () => { + const previous = Array.from({ length: 21 }, (_, index) => + comment({ + id: `${String(index + 10).padStart(8, "0")}-1111-4111-8111-111111111111`, + parentId: ROOT, + replyToId: ROOT, + text: `Reply ${index + 1}`, + createdAt: `2026-09-01T12:00:${String(index).padStart(2, "0")}Z`, + }), + ); + const created = comment({ + id: REPLY, + parentId: ROOT, + replyToId: ROOT, + text: "Newest reply", + createdAt: "2026-09-01T12:01:00Z", + }); + const root = await mount({ session }, [comment({ replyCount: 21 })]); + request.mockImplementation(async (operation, payload) => { + if (operation === "createComment") return created; + if (operation === "replies") + return payload.cursor + ? { items: [previous[20], created], nextCursor: null } + : { items: previous.slice(0, 20), nextCursor: "remaining-replies" }; + return { items: [], nextCursor: null }; + }); + root.querySelector("[data-action='load-replies']").click(); + await flush(); + root.querySelector("[data-action='reply']").click(); + submit(root.querySelector(".ryd-discussion-inline-editor form"), created.text); + await flush(); + expect(root.querySelectorAll(".ryd-discussion-reply")).toHaveLength(21); + expect(root.querySelector(`[data-comment-id='${REPLY}']`).textContent).toContain(created.text); + expect(request.mock.calls.filter(([operation]) => operation === "replies")).toHaveLength(1); + root.querySelector(".ryd-discussion-replies > button").click(); + await flush(); + expect(request).toHaveBeenLastCalledWith("replies", { rootId: ROOT, cursor: "remaining-replies" }); + expect([...root.querySelectorAll(".ryd-discussion-reply")].map((row) => row.dataset.commentId)).toEqual([ + ...previous.map((item) => item.id), + REPLY, + ]); +}); + +test("posting while the first reply page is loading keeps the new reply when that page arrives", async () => { + const thread = comment({ replyCount: 1 }); + const root = await mount({ session }, [thread]); + const previous = comment({ id: REPLY, parentId: ROOT, replyToId: ROOT, text: "Previous reply" }); + const created = comment({ + id: "55555555-5555-4555-8555-555555555555", + parentId: ROOT, + replyToId: ROOT, + text: "New reply", + createdAt: "2026-09-01T12:00:00.001Z", + }); + let resolveReplies; + request.mockImplementation((operation) => { + if (operation === "replies") + return new Promise((resolve) => { + resolveReplies = resolve; + }); + return Promise.resolve(created); + }); + root.querySelector("[data-action='load-replies']").click(); + root.querySelector("[data-action='reply']").click(); + submit(root.querySelector(".ryd-discussion-inline-editor form"), created.text); + await flush(); + expect(root.querySelector(`[data-comment-id='${created.id}']`)).not.toBeNull(); + resolveReplies({ items: [previous], nextCursor: null }); + await flush(); + expect([...root.querySelectorAll(".ryd-discussion-reply")].map((row) => row.dataset.commentId)).toEqual([ + REPLY, + created.id, + ]); + expect(thread.replyCount).toBe(2); +}); + +test("a reply to an unopened thread remains visible and lets a failed history fetch be retried", async () => { + const root = await mount({ session }, [comment({ replyCount: 0 })]); + const previous = comment({ id: REPLY, parentId: ROOT, replyToId: ROOT, text: "Earlier reply" }); + const created = comment({ + id: "55555555-5555-4555-8555-555555555555", + parentId: ROOT, + replyToId: ROOT, + text: "New reply", + createdAt: "2026-09-01T12:01:00Z", + }); + request.mockResolvedValueOnce(created).mockRejectedValueOnce({ code: "unavailable" }); + root.querySelector("[data-action='reply']").click(); + submit(root.querySelector(".ryd-discussion-inline-editor form"), created.text); + await flush(); + expect(root.querySelector(`[data-comment-id='${created.id}']`)).not.toBeNull(); + const showReplies = root.querySelector("[data-action='load-replies']"); + expect(showReplies.hidden).toBe(false); + expect(showReplies.disabled).toBe(false); + request.mockResolvedValueOnce({ items: [previous, created], nextCursor: null }); + showReplies.click(); + await flush(); + expect([...root.querySelectorAll(".ryd-discussion-reply")].map((row) => row.dataset.commentId)).toEqual([ + REPLY, + created.id, + ]); + expect(showReplies.hidden).toBe(true); +}); + +test("posting fetches unopened history even when the root count predates another user's reply", async () => { + const thread = comment(); + const root = await mount({ session }, [thread]); + const previous = comment({ + id: "55555555-5555-4555-8555-555555555555", + parentId: ROOT, + replyToId: ROOT, + text: "Another user's reply", + createdAt: "2026-09-01T12:00:00Z", + }); + const created = comment({ id: REPLY, parentId: ROOT, replyToId: ROOT, text: "First reply" }); + request.mockResolvedValueOnce(created).mockResolvedValueOnce({ items: [previous, created], nextCursor: null }); + root.querySelector("[data-action='reply']").click(); + submit(root.querySelector(".ryd-discussion-inline-editor form"), created.text); + await flush(); + expect(root.querySelector(`[data-comment-id='${REPLY}']`)).not.toBeNull(); + expect(root.querySelector(`[data-comment-id='${previous.id}']`)).not.toBeNull(); + expect(request).toHaveBeenLastCalledWith("replies", { rootId: ROOT }); + expect(thread.replyCount).toBe(2); +}); + +test("top/new sorting discards the prior pagination cursor", async () => { + const root = await mount(); + const sort = root.querySelector("select"); + sort.value = "new"; + sort.dispatchEvent(new Event("change")); + await flush(); + expect(request).toHaveBeenLastCalledWith("comments", { videoId: VIDEO, sort: "new" }); +}); + +test("an obsolete response cannot render after panel destruction", async () => { + const root = await mount(); + let resolve; + request.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + const loading = panel.load(); + panel.destroy(); + resolve({ items: [comment({ text: "Late response" })] }); + await loading; + expect(root.textContent).not.toContain("Late response"); + expect(document.getElementById("ryd-discussion-panel")).toBeNull(); +}); + +test("moderators can review reports and issue explicit scoped actions", async () => { + const root = await mount({ session: { ...session, isModerator: true } }); + request.mockResolvedValueOnce({ + items: [{ id: REPLY, comment: comment(), reason: "spam", authorSuspended: false }], + nextCursor: null, + }); + root.querySelector("[data-action='moderation']").click(); + await flush(); + request.mockResolvedValueOnce(comment({ hidden: true })); + root.querySelector("[data-action='hide']").click(); + await flush(); + expect(request.mock.calls).toContainEqual(["moderateComment", { id: ROOT, hidden: true }]); +}); + +test("Russian UI follows the YouTube document language", async () => { + document.documentElement.lang = "ru-RU"; + const root = await mount({ session }); + expect(root.querySelector("h2").textContent).toBe("Комментарии RYD"); + expect(root.querySelector("[data-action='post']").textContent).toBe("Отправить"); + expect(root.querySelector("[data-reaction='1']").getAttribute("aria-label")).toContain("Нравится"); +}); + +test("moderators can restore comments and authoring from dismissed report history", async () => { + const root = await mount({ session: { ...session, isModerator: true } }); + request.mockImplementation(async (operation, payload) => { + if (operation === "reports" && payload.status === "resolved") + return { items: [{ id: REPLY, comment: comment({ hidden: true }), reason: "spam", authorSuspended: true }] }; + return { items: [] }; + }); + root.querySelector("[data-action='moderation']").click(); + await flush(); + const filter = root.querySelector("[data-action='report-status']"); + filter.value = "resolved"; + filter.dispatchEvent(new Event("change")); + await flush(); + expect(root.querySelector("[data-action='dismiss']")).toBeNull(); + expect(root.querySelector("[data-action='unban']")).not.toBeNull(); + root.querySelector("[data-action='restore']").click(); + await flush(); + expect(request.mock.calls).toContainEqual(["moderateComment", { id: ROOT, hidden: false }]); + expect(root.querySelector("[data-action='report-status']").value).toBe("resolved"); +}); diff --git a/Extensions/combined/src/discussion/popup.js b/Extensions/combined/src/discussion/popup.js new file mode 100644 index 0000000..4c3cf51 --- /dev/null +++ b/Extensions/combined/src/discussion/popup.js @@ -0,0 +1,54 @@ +import { requestDiscussion, subscribeDiscussionEvents } from "./client"; +import { createDiscussionInbox } from "./inbox"; +import { discussionText as t } from "./localize"; + +export function initDiscussionPopup() { + const root = document.getElementById("ryd-discussion-popup"); + if (!root) return; + if (!document.documentElement.lang && chrome.i18n?.getUILanguage) + document.documentElement.lang = chrome.i18n.getUILanguage(); + const toggle = document.getElementById("ryd-discussion-inbox-toggle"); + const inbox = createDiscussionInbox({ authenticated: false }); + root.append(inbox.node); + inbox.node.hidden = true; + toggle.textContent = t("notifications"); + toggle.addEventListener("click", () => { + inbox.node.hidden = !inbox.node.hidden; + toggle.setAttribute("aria-expanded", String(!inbox.node.hidden)); + if (!inbox.node.hidden) inbox.load(); + }); + let generation = 0; + const unsubscribe = subscribeDiscussionEvents((event) => { + if (event.type === "session") { + generation++; + inbox.setSession(event.data); + } + if (event.type === "inbox") { + inbox.update(event.data, { live: true }); + toggle.textContent = `${t("notifications")}${event.data?.unreadCount ? ` (${event.data.unreadCount})` : ""}`; + } + }); + const initial = generation; + requestDiscussion("session") + .then((session) => { + if (initial !== generation) return; + inbox.setSession(session); + if (session.authenticated) + requestDiscussion("notifications") + .then((value) => { + if (initial !== generation) return; + inbox.update(value); + toggle.textContent = `${t("notifications")}${value?.unreadCount ? ` (${value.unreadCount})` : ""}`; + }) + .catch(() => {}); + }) + .catch(() => {}); + window.addEventListener( + "unload", + () => { + inbox.destroy(); + unsubscribe(); + }, + { once: true }, + ); +} diff --git a/Extensions/common/vote-client.js b/Extensions/common/vote-client.js index fce7e2e..5a00811 100644 --- a/Extensions/common/vote-client.js +++ b/Extensions/common/vote-client.js @@ -311,7 +311,12 @@ function createVoteClient({ return current; } - return { ensureRegistered, submitVote }; + async function getRegisteredUser() { + const credential = await credentialStore.load(); + return isConfirmedCredential(credential) ? { userId: credential.userId } : null; + } + + return { ensureRegistered, getRegisteredUser, submitVote }; } export { DEFAULT_API_BASE_URL, VoteClientError, countLeadingZeroes, generateUserId, solvePuzzle, createVoteClient }; diff --git a/Extensions/common/vote-client.spec.js b/Extensions/common/vote-client.spec.js index 94a1e0a..b34a63b 100644 --- a/Extensions/common/vote-client.spec.js +++ b/Extensions/common/vote-client.spec.js @@ -8,6 +8,28 @@ const CHALLENGE = Buffer.alloc(16, 7).toString("base64"); const ZERO_SOLUTION = "AAAAAA=="; const GENERATED_USER_ID = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"; +test.each([null, { userId: GENERATED_USER_ID, registrationConfirmed: false }])( + "reading an existing voter does not create or register an identity: %s", + async (credentials) => { + const store = createCredentialStore(credentials); + const fetchImpl = jest.fn(); + const cryptoImpl = createCryptoImpl(); + const client = createClient({ credentialStore: store, fetchImpl, cryptoImpl }); + expect(await client.getRegisteredUser()).toBe(null); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(store.save).not.toHaveBeenCalled(); + expect(cryptoImpl.getRandomValues).not.toHaveBeenCalled(); + }, +); + +test("reads a confirmed existing voter without registration", async () => { + const store = createCredentialStore({ userId: GENERATED_USER_ID, registrationConfirmed: true }); + const fetchImpl = jest.fn(); + const client = createClient({ credentialStore: store, fetchImpl }); + expect(await client.getRegisteredUser()).toEqual({ userId: GENERATED_USER_ID }); + expect(fetchImpl).not.toHaveBeenCalled(); +}); + function jsonResponse(body, status = 200) { return { ok: status >= 200 && status < 300, diff --git a/Extensions/e2e/discussion/README.md b/Extensions/e2e/discussion/README.md new file mode 100644 index 0000000..1e11b5a --- /dev/null +++ b/Extensions/e2e/discussion/README.md @@ -0,0 +1,51 @@ +# Discussion browser validation + +`hermetic-discussion-smoke.js` loads the generated MV3 extension through the shared +`HermeticExtensionArtifactAdapter` in a new, disposable headless Chromium profile. +It serves fixture documents at YouTube URLs and uses the loopback discussion host +at `http://127.0.0.1:5099`. It never attaches to an existing browser profile. + +Start the repository's local discussion fixture host, then build a dedicated artifact: + +```powershell +node node_modules/webpack-cli/bin/cli.js --mode=production --env liveTest=true --env discussionTestOrigin=http://127.0.0.1:5099 --output-path test-results/discussion/generated +node Extensions/e2e/discussion/hermetic-discussion-smoke.js +``` + +Use Node 22.17.0. `RYD_DISCUSSION_ARTIFACT` can select another generated local-test +Chrome artifact; `RYD_DISCUSSION_OUTPUT` can select a fresh evidence directory. +The default output creates a new timestamped directory under `test-results/discussion`. +Set `RYD_DISCUSSION_NOTIFICATIONS_ONLY=true` for a focused notification/reconnect +run with only the required rendered-panel, nickname, and comment prerequisites. +Between reply and like delivery, that run closes the actual worker WebSocket, +asserts its native close event, and requires a new open connection with a different +server ticket. Only the ticket-change boolean is recorded; ticket values are never +returned from the worker or saved in evidence. + +The smoke asserts English and Russian watch and Shorts rendering, nickname updates, +comment and reply creation, public reactions through like/dislike/removal, author +controls after account switches, and premium/supporter/inactive posting gates. +It checks light and dark fallback styles without native YouTube color variables, +a 390-pixel viewport, Shorts Escape/Enter handling, enabled native comments exclusion, +explicit current-video kids exclusion, and continued rendering when only unrelated +recommendations or advertisements have kids flags. + +It also exercises report submission, moderator hide/ban/dismiss, resolved report +history, and restoration/unbanning. Incoming replies and likes must produce a real +worker WebSocket `NotificationsChanged` frame and a corresponding runtime inbox +event before the runner explicitly reads the inbox. A test-only WebSocket observer +forwards the native constructor unchanged and records only connection sequence, +event target, and revision. Inbox UI mark-read is verified against the local API. + +All account changes use seeded fixture identities in dedicated discussion test keys. +Created comments and replies are deleted after the attempt. The fixture host keeps +its local audit history and chosen fixture nicknames. The existing adapter redirects +legacy extension API traffic to its fake server, blocks external name resolution +before browser startup, and cleans up its temporary artifact and profile. The +compiled discussion runtime continues to use its explicitly configured local API. + +Each attempt records a sanitized JSON result, operation ledger, and 36 screenshots. +The report labels this evidence as hermetic. It does not prove behavior against live +YouTube, live OAuth accounts, actual YouTube language switching, cross-tab toast +deduplication, Firefox, or Safari. Use the separate `live-discussion-contract.js` +only for an authorized live session and its specified local mutation boundary. diff --git a/Extensions/e2e/discussion/hermetic-discussion-smoke.js b/Extensions/e2e/discussion/hermetic-discussion-smoke.js new file mode 100644 index 0000000..c3bbf1c --- /dev/null +++ b/Extensions/e2e/discussion/hermetic-discussion-smoke.js @@ -0,0 +1,712 @@ +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { expect } = require("@playwright/test"); +const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke"); +const { bounded, localClient, poll, solve, TIMEOUT_MS } = require("./live-discussion-contract"); + +const REPOSITORY = path.resolve(__dirname, "../../.."); +const ORIGIN = "http://127.0.0.1:5099"; +const LANGUAGES = ["en", "ru"]; +const SURFACES = ["watch", "shorts"]; + +function fixture({ language, surface, videoId, disabled = true, kids = false, dark = true }) { + assert.ok(LANGUAGES.includes(language) && SURFACES.includes(surface)); + assert.match(videoId, /^[A-Za-z0-9_-]{11}$/); + const options = JSON.stringify({ language, surface, videoId, disabled, kids }); + return ` + + Discussion artifact fixture
`; +} + +async function runHermeticDiscussionSmoke({ + artifactDirectory = process.env.RYD_DISCUSSION_ARTIFACT || + path.join(REPOSITORY, "test-results/discussion/generated/chrome"), + outputDirectory = process.env.RYD_DISCUSSION_OUTPUT || + path.join(REPOSITORY, `test-results/discussion/hermetic-smoke-${Date.now()}`), + notificationFocus = process.env.RYD_DISCUSSION_NOTIFICATIONS_ONLY === "true", +} = {}) { + const healthResponse = await fetch(`${ORIGIN}/api/discussion/local/health`, { + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + assert.equal(healthResponse.status, 200); + assert.equal((await healthResponse.json()).localTestMode, true, "Local discussion host is required"); + const marker = JSON.parse(await fs.readFile(path.join(artifactDirectory, "live-build.json"), "utf8")); + assert.match(marker.buildId, /^[a-f0-9]{32}$/); + const background = await fs.readFile(path.join(artifactDirectory, "ryd.background.js"), "utf8"); + assert.ok(background.includes(`const testOrigin = true ? "${ORIGIN}"`), "Requires an explicit local-test build"); + await fs.mkdir(outputDirectory, { recursive: true }); + const seedResponse = await fetch(`${ORIGIN}/api/discussion/local/seed`, { + method: "POST", + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + assert.equal(seedResponse.status, 200); + const { accounts } = await seedResponse.json(); + const ledger = []; + const request = localClient(ORIGIN, accounts, (value) => ledger.push(value)); + const results = []; + const screenshots = []; + const errors = []; + const created = []; + const profilesToRestore = new Set(); + const commentsToRestore = new Set(); + const record = (name, detail = {}) => { + results.push({ name, passed: true, ...detail }); + process.stdout.write(`PASS ${name}\n`); + }; + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ + apiServer, + artifactDirectory, + headless: true, + contextOptions: { viewport: { width: 1200, height: 900 } }, + }); + let currentFixture; + let popup; + let failure; + try { + await adapter.start(); + const { context, page, worker } = adapter; + context.setDefaultTimeout(TIMEOUT_MS); + context.setDefaultNavigationTimeout(TIMEOUT_MS); + page.on("pageerror", (error) => errors.push({ name: error.name, message: error.message.slice(0, 200) })); + await worker.evaluate(() => { + const NativeWebSocket = globalThis.WebSocket; + const probe = { + sequence: 0, + connection: 0, + ticketChanges: 0, + events: [], + closed: [], + ready: new Set(), + sockets: new Map(), + }; + let previousTicket = null; + globalThis.__rydDiscussionSocketProbe = probe; + globalThis.WebSocket = class extends NativeWebSocket { + constructor(...args) { + super(...args); + const url = new URL(args[0]); + if (url.origin !== "ws://127.0.0.1:5099" || url.pathname !== "/api/discussion/notifications/hub") return; + const connection = ++probe.connection; + const ticket = url.searchParams.get("access_token"); + if (previousTicket !== null && ticket && ticket !== previousTicket) probe.ticketChanges++; + previousTicket = ticket; + probe.sockets.set(connection, this); + this.addEventListener("message", (event) => { + for (const packet of String(event.data).split("\u001e")) { + let message; + try { + message = JSON.parse(packet); + } catch { + continue; + } + if (message && Object.keys(message).length === 0) probe.ready.add(connection); + if (message.target !== "NotificationsChanged" || !/^\d+$/.test(message.arguments?.[0]?.revision)) + continue; + probe.events.push({ + sequence: ++probe.sequence, + connection, + target: message.target, + revision: message.arguments[0].revision, + }); + if (probe.events.length > 100) probe.events.shift(); + } + }); + this.addEventListener("close", (event) => { + probe.closed.push({ connection, code: event.code }); + probe.ready.delete(connection); + probe.sockets.delete(connection); + }); + } + }; + }); + await context.route(`${ORIGIN}/**`, (route) => route.continue()); + await context.route("https://www.youtube.com/**", (route) => { + const url = new URL(route.request().url()); + if ( + route.request().resourceType() === "document" && + ["/watch", `/shorts/${currentFixture?.videoId}`].includes(url.pathname) + ) + return route.fulfill({ contentType: "text/html", body: fixture(currentFixture) }); + return route.fulfill({ status: 404, body: "Fixture resource absent" }); + }); + popup = await context.newPage(); + await popup.goto(`chrome-extension://${adapter.extensionId}/popup.html`, { waitUntil: "domcontentloaded" }); + await popup.evaluate(() => { + const probe = { sequence: 0, events: [] }; + globalThis.__rydDiscussionInboxProbe = probe; + chrome.runtime.onMessage.addListener((message, sender) => { + if (sender.id !== chrome.runtime.id || message.message !== "ryd_discussion_event" || message.type !== "inbox") + return; + probe.events.push({ + sequence: ++probe.sequence, + revision: message.data.revision, + items: (message.data.items || []).map((item) => ({ + id: item.id, + commentId: item.commentId, + type: item.type, + read: item.read, + revision: item.revision, + })), + }); + if (probe.events.length > 100) probe.events.shift(); + }); + }); + const operation = (name, payload = {}) => + bounded( + () => + popup.evaluate( + ({ name, payload }) => chrome.runtime.sendMessage({ message: "ryd_discussion", operation: name, payload }), + { name, payload }, + ), + `Worker ${name}`, + ); + async function setAccount(name) { + let expectedProfile = null; + if (name) { + const session = await request("/me", { account: name }); + expectedProfile = session.profile?.id || null; + } + await bounded( + () => + worker.evaluate( + ({ account, voter }) => + chrome.storage.local.set({ + rydDiscussionTestAuth: account ? { sessionToken: account.sessionToken } : null, + rydDiscussionTestCredentials: { userId: voter, registrationConfirmed: true }, + }), + { account: name ? accounts[name] : null, voter: accounts.commenter.voterId }, + ), + "Set fixture account", + ); + const result = await poll( + () => operation("session"), + (value) => + value.ok && + (name + ? value.data.authenticated && + (value.data.profile?.id || null) === expectedProfile && + value.data.membershipTier === + { + commenter: "commenter", + other: "commenter", + premium: "premium", + moderator: "premium", + supporter: "supporter", + inactive: "none", + }[name] + : !value.data.authenticated), + "Fixture session did not converge", + ); + const refresh = page.locator(".ryd-discussion-thread > .ryd-discussion-toolbar > button"); + if (await refresh.count()) await expect(refresh).toBeEnabled(); + return result; + } + const socketSnapshot = () => + bounded( + () => + worker.evaluate(() => { + const probe = globalThis.__rydDiscussionSocketProbe; + return { + connection: probe.connection, + ticketChanges: probe.ticketChanges, + closed: probe.closed, + ready: [...probe.ready], + events: probe.events, + open: [...probe.sockets].filter(([, socket]) => socket.readyState === 1).map(([id]) => id), + }; + }), + "Observe native SignalR frames", + ); + const inboxSnapshot = () => + bounded(() => popup.evaluate(() => globalThis.__rydDiscussionInboxProbe), "Observe runtime inbox events"); + const root = () => page.locator("#ryd-discussion-panel, #ryd-discussion-drawer").filter({ visible: true }); + const row = (id) => root().locator(`[data-comment-id="${id}"]`); + const composer = () => root().locator(".ryd-discussion-composer"); + async function open(options) { + currentFixture = options; + await page.goto( + `https://www.youtube.com/${options.surface === "watch" ? `watch?v=${options.videoId}` : `shorts/${options.videoId}`}`, + { waitUntil: "domcontentloaded" }, + ); + await expect(page.locator("html")).toHaveAttribute("data-ryd-extension-build", marker.buildId); + if (options.kids || options.disabled === false) return; + if (options.surface === "shorts") await page.locator("#ryd-discussion-launcher").click(); + await expect(root()).toHaveCount(1); + await expect(root()).toHaveAttribute("data-video-id", options.videoId); + } + async function screenshot(name) { + const file = `${name}.png`; + await page.screenshot({ path: path.join(outputDirectory, file), timeout: TIMEOUT_MS }); + screenshots.push(file); + } + async function narrowScreenshot(name) { + const viewport = page.viewportSize(); + try { + await page.setViewportSize({ width: 390, height: 844 }); + const box = await root().boundingBox(); + assert.ok(box && box.x >= -1 && box.x + box.width <= 391, "Narrow activity panel overflows viewport"); + await screenshot(`${name}-narrow`); + } finally { + await page.setViewportSize(viewport); + } + } + async function ownNickname(language, surface) { + if (await composer().locator(".ryd-discussion-account button").count()) + await composer().locator(".ryd-discussion-account button").click(); + const nickname = `Artifact ${language} ${surface} ${crypto.randomBytes(3).toString("hex")}`; + await composer().locator('input[name="nickname"]').fill(nickname); + await composer().locator('button[type="submit"]').click(); + await expect(composer().locator(".ryd-discussion-account")).toContainText(nickname); + assert.equal((await request("/me", { account: "commenter" })).profile.nickname, nickname); + return nickname; + } + async function createRoot(videoId, text) { + await composer().locator("textarea").fill(text); + await composer().locator('[data-action="post"]').click(); + const value = await poll( + () => request(`/videos/${videoId}/comments?sort=new`), + (list) => list.items.some((item) => item.text === text), + "Created comment absent from local API", + ); + const comment = value.items.find((item) => item.text === text); + created.push({ id: comment.id, account: "commenter" }); + await expect(row(comment.id)).toBeVisible(); + return comment; + } + async function assertFallback(dark) { + await page.evaluate((value) => { + document.documentElement.toggleAttribute("dark", value); + document.body.style.backgroundColor = value ? "#0f0f0f" : "#fff"; + document.body.style.color = value ? "#f1f1f1" : "#0f0f0f"; + }, dark); + const colors = await root().evaluate((node) => { + const input = node.querySelector("textarea"); + return { + text: getComputedStyle(node).color, + input: getComputedStyle(input).backgroundColor, + inputText: getComputedStyle(input).color, + nativeVariable: getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-primary"), + }; + }); + assert.equal(colors.nativeVariable, ""); + assert.equal(colors.text, dark ? "rgb(241, 241, 241)" : "rgb(15, 15, 15)"); + assert.notEqual(colors.input, colors.inputText); + return colors; + } + + async function ensureProfile(account) { + const session = await request("/me", { account }); + if (!session.profile?.nickname) + await request("/me/profile", { + account, + method: "PUT", + body: { nickname: `Artifact ${account} ${crypto.randomBytes(3).toString("hex")}` }, + }); + } + + async function moderation(comment, label) { + await ensureProfile("other"); + await ensureProfile("moderator"); + await setAccount("other"); + await row(comment.id).locator(':scope > .ryd-discussion-comment-actions [data-action="report"]').click(); + await row(comment.id) + .locator(":scope > .ryd-discussion-inline-editor textarea") + .fill(`Artifact moderation ${label}`); + await row(comment.id).locator(':scope > .ryd-discussion-inline-editor [data-action="send-report"]').click(); + const reports = await poll( + () => request("/moderation/reports", { account: "moderator" }), + (value) => value.items.some((item) => item.comment.id === comment.id), + "Report absent from moderator queue", + ); + const reportId = reports.items.find((item) => item.comment.id === comment.id).id; + await setAccount("moderator"); + await root().locator('[data-action="moderation"]').click(); + const report = () => root().locator(`[data-report-id="${reportId}"]`); + await expect(report()).toBeVisible(); + commentsToRestore.add(comment.id); + await report().locator('[data-action="hide"]').click(); + await expect(report().locator('[data-action="restore"]')).toBeVisible(); + assert.equal((await request(`/comments/${comment.id}`)).hidden, true); + profilesToRestore.add(comment.author.id); + await report().locator('[data-action="ban"]').click(); + await expect(report().locator('[data-action="unban"]')).toBeVisible(); + assert.equal((await request("/me", { account: "commenter" })).profile.suspended, true); + await report().locator('[data-action="dismiss"]').click(); + await expect(report()).toHaveCount(0); + await root().locator('[data-action="report-status"]').selectOption("resolved"); + await expect(report()).toBeVisible(); + await expect(report().locator('[data-action="dismiss"]')).toHaveCount(0); + await screenshot(`${label}-moderation-history`); + await narrowScreenshot(`${label}-moderation-history`); + await report().locator('[data-action="restore"]').click(); + await expect(report().locator('[data-action="hide"]')).toBeVisible(); + await report().locator('[data-action="unban"]').click(); + await expect(report().locator('[data-action="ban"]')).toBeVisible(); + assert.equal((await request(`/comments/${comment.id}`)).hidden, false); + assert.equal((await request("/me", { account: "commenter" })).profile.suspended, false); + commentsToRestore.delete(comment.id); + profilesToRestore.delete(comment.author.id); + record(`${label}-report-hide-ban-dismiss-history-restore-unban`, { reportId }); + } + + async function notifications(comment, label, catalog) { + const previousConnection = (await socketSnapshot()).connection; + await setAccount("commenter"); + await root().locator('[data-action="discussion"]').click(); + await poll( + socketSnapshot, + (value) => value.open.some((id) => id > previousConnection), + "Current fixture SignalR socket did not open", + ); + assert.equal((await operation("readNotifications", { all: true })).ok, true); + await page.bringToFront(); + for (const type of ["reply", "likes"]) { + const wireBaseline = await socketSnapshot(); + const inboxBaseline = await inboxSnapshot(); + let targetId = comment.id; + if (type === "reply") { + const reply = await request(`/videos/${comment.videoId}/comments`, { + account: "other", + method: "POST", + body: { + text: `Incoming artifact reply ${label} ${crypto.randomUUID()}`, + parentId: comment.id, + replyToId: comment.id, + requestId: crypto.randomUUID(), + }, + }); + targetId = reply.id; + created.push({ id: reply.id, account: "other" }); + } else { + const userId = accounts.other.voterId; + const challenge = await request(`/comments/${comment.id}/reaction/challenge`, { + method: "POST", + body: { userId, value: 1 }, + }); + const confirmed = await request(`/comments/${comment.id}/reaction/confirm`, { + account: "other", + method: "POST", + body: { + userId, + challengeId: challenge.challengeId, + solution: solve(challenge.challenge, challenge.difficulty), + }, + }); + assert.equal(confirmed.myReaction, 1); + } + const wireSequence = wireBaseline.events.at(-1)?.sequence || 0; + await poll( + socketSnapshot, + (value) => + value.events.some((event) => event.sequence > wireSequence && wireBaseline.open.includes(event.connection)), + "No incoming worker SignalR frame for fixture activity", + ); + const observed = await poll( + inboxSnapshot, + (value) => + value.events.some( + (event) => + event.sequence > inboxBaseline.sequence && + event.items.some((item) => item.commentId === targetId && item.type === type && !item.read), + ), + "No runtime inbox event for the new activity", + ); + const envelope = observed.events.find( + (event) => + event.sequence > inboxBaseline.sequence && + event.items.some((item) => item.commentId === targetId && item.type === type && !item.read), + ); + const notification = envelope.items.find( + (item) => item.commentId === targetId && item.type === type && !item.read, + ); + const wire = (await socketSnapshot()).events.find( + (event) => + event.sequence > wireSequence && + wireBaseline.open.includes(event.connection) && + BigInt(event.revision) >= BigInt(notification.revision), + ); + assert.ok(wire, "Runtime inbox revision has no matching observed SignalR frame"); + record(`${label}-${type}-native-signalr-and-runtime-inbox`, { + wire, + notificationId: notification.id, + revision: notification.revision, + }); + await root().locator('[data-action="inbox"]').click(); + const notificationRow = root().locator(`[data-notification-id="${notification.id}"]`); + await expect(notificationRow).toBeVisible(); + if (type === "likes") await expect(notificationRow).toContainText(catalog.discussion_notificationLike.message); + await screenshot(`${label}-${type}-inbox`); + await narrowScreenshot(`${label}-${type}-inbox`); + await notificationRow.locator("button").click(); + await expect(notificationRow).not.toHaveClass(/is-unread/); + await poll( + () => request("/notifications", { account: "commenter" }), + (value) => value.items.find((item) => item.id === notification.id)?.read === true, + "Inbox mark-read did not persist", + ); + record(`${label}-${type}-inbox-ui-and-mark-read`); + await root().locator('[data-action="discussion"]').click(); + if (notificationFocus && type === "reply") { + const before = await socketSnapshot(); + await bounded( + () => + worker.evaluate(() => { + for (const socket of globalThis.__rydDiscussionSocketProbe.sockets.values()) { + if (socket.readyState === 1) socket.close(4000, "Fixture reconnect"); + } + }), + "Close native fixture socket", + ); + const reconnected = await poll( + socketSnapshot, + (value) => + value.closed.some((entry) => before.open.includes(entry.connection)) && + value.open.some((id) => id > before.connection && value.ready.includes(id)) && + value.ticketChanges > before.ticketChanges, + "Worker did not reconnect with a fresh ticket after native socket closure", + ); + record(`${label}-native-socket-close-reconnect-fresh-ticket`, { + closedConnections: reconnected.closed.filter((entry) => before.open.includes(entry.connection)), + newConnections: reconnected.open.filter((id) => id > before.connection), + freshTicket: true, + }); + } + } + } + + await setAccount(null); + for (const language of LANGUAGES) { + const catalog = JSON.parse( + await fs.readFile(path.join(artifactDirectory, `_locales/${language}/messages.json`), "utf8"), + ); + for (const surface of SURFACES) { + const videoId = `H${crypto.randomBytes(8).toString("base64url").slice(0, 10)}`; + const label = `${language}-${surface}`; + await setAccount(null); + await open({ language, surface, videoId, disabled: true, kids: false, dark: true }); + await expect(root().locator("h2")).toHaveText(catalog.discussion_title.message); + await expect(composer().locator("textarea")).toHaveCount(0); + await expect(composer()).toContainText(catalog.discussion_gate.message); + record(`${label}-anonymous-unknown-kids-unrelated-kids-render`); + + await setAccount("commenter"); + const nickname = await ownNickname(language, surface); + const comment = await createRoot(videoId, `Artifact comment ${label} ${crypto.randomUUID()}`); + assert.equal(comment.author.nickname, nickname); + record(`${label}-nickname-and-create`); + + if (notificationFocus) { + await ensureProfile("other"); + await setAccount("other"); + await notifications(comment, label, catalog); + continue; + } + + await row(comment.id).locator(':scope > .ryd-discussion-comment-actions [data-action="reply"]').click(); + const replyText = `Artifact reply ${label} ${crypto.randomUUID()}`; + await row(comment.id).locator(":scope > .ryd-discussion-inline-editor textarea").fill(replyText); + await row(comment.id).locator(':scope > .ryd-discussion-inline-editor [data-action="post"]').click(); + const replies = await poll( + () => request(`/comments/${comment.id}/replies`), + (value) => value.items.some((item) => item.text === replyText), + "Reply absent from local API", + ); + const reply = replies.items.find((item) => item.text === replyText); + created.push({ id: reply.id, account: "commenter" }); + assert.equal(reply.parentId, comment.id); + await expect(row(reply.id)).toBeVisible(); + record(`${label}-reply`); + + const darkColors = await assertFallback(true); + await screenshot(`${label}-dark`); + const lightColors = await assertFallback(false); + await screenshot(`${label}-light`); + record(`${label}-theme-fallback`, { darkColors, lightColors }); + await assertFallback(true); + + for (const account of ["premium", "supporter", "inactive"]) { + await setAccount(account); + if (account === "premium") + await expect(composer().locator("textarea, input[name='nickname']")).toHaveCount(1); + else await expect(composer().locator("textarea, input[name='nickname']")).toHaveCount(0); + record(`${label}-${account}-gate`); + } + await setAccount(null); + await expect(composer().locator("textarea")).toHaveCount(0); + await expect( + row(comment.id).locator(':scope > .ryd-discussion-comment-actions [data-action="edit"]'), + ).toHaveCount(0); + for (const [value, expectedLike, expectedDislike] of [ + [1, 1, 0], + [-1, 0, 1], + [-1, 0, 0], + ]) { + const reaction = row(comment.id).locator( + `:scope > .ryd-discussion-comment-actions [data-reaction="${value}"]`, + ); + await expect(reaction).toBeEnabled(); + await reaction.click(); + await poll( + () => request(`/comments/${comment.id}`), + (item) => item.likes === expectedLike && item.dislikes === expectedDislike, + "Anonymous reaction count did not converge", + ); + await expect(reaction).toBeEnabled(); + await expect(reaction).toHaveAttribute( + "aria-pressed", + String((value === 1 ? expectedLike : expectedDislike) === 1), + ); + } + record(`${label}-anonymous-like-dislike-remove`); + + await setAccount("commenter"); + await expect(composer().locator("textarea")).toHaveCount(1); + await expect( + row(comment.id).locator(':scope > .ryd-discussion-comment-actions [data-action="edit"]'), + ).toHaveCount(1); + await page.setViewportSize({ width: 390, height: 844 }); + const box = await root().boundingBox(); + assert.ok(box && box.x >= -1 && box.x + box.width <= 391, "Panel overflows narrow viewport"); + await screenshot(`${label}-narrow`); + await page.setViewportSize({ width: 1200, height: 900 }); + if (surface === "shorts") { + await root().locator("textarea").first().focus(); + await page.keyboard.press("Escape"); + await expect(page.locator("#ryd-discussion-drawer")).toBeHidden(); + await expect(page.locator("#ryd-discussion-launcher")).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(root()).toBeVisible(); + } + record(`${label}-responsive-and-owner-restoration`); + + await moderation(comment, label); + await notifications(comment, label, catalog); + + await page.evaluate(() => window.__discussionFixture.setKids(true)); + await expect( + page.locator("#ryd-discussion-panel, #ryd-discussion-launcher, #ryd-discussion-drawer"), + ).toHaveCount(0); + await page.evaluate(() => window.__discussionFixture.setKids(false)); + if (surface === "shorts") + await expect(page.locator("#ryd-discussion-launcher")).toHaveAttribute("aria-expanded", "true"); + await expect(root()).toHaveCount(1); + record(`${label}-positive-kids-excluded-unknown-restored`); + + await open({ language, surface, videoId, disabled: false, kids: false, dark: true }); + await page.waitForTimeout(300); + await expect( + page.locator("#ryd-discussion-panel, #ryd-discussion-launcher, #ryd-discussion-drawer"), + ).toHaveCount(0); + record(`${label}-native-enabled-excluded`); + } + } + assert.equal(errors.length, 0, "Fixture page had uncaught errors"); + record("no-uncaught-fixture-page-errors"); + } catch (error) { + failure = { + name: error.name, + message: String(error.message) + .replace(/(access_token|sessionToken|token)=[^&\s]+/gi, "$1=[redacted]") + .slice(0, 2500), + }; + if (adapter.page) + await adapter.page + .screenshot({ path: path.join(outputDirectory, "failure.png"), timeout: TIMEOUT_MS }) + .catch(() => {}); + } finally { + for (const id of commentsToRestore) { + await request(`/moderation/comments/${id}`, { + account: "moderator", + method: "POST", + body: { hidden: false }, + }).catch(() => errors.push({ name: "RestoreCommentError", commentId: id })); + } + for (const id of profilesToRestore) { + await request(`/moderation/profiles/${id}/suspension`, { + account: "moderator", + method: "POST", + body: { suspended: false }, + }).catch(() => errors.push({ name: "RestoreProfileError", profileId: id })); + } + for (const { id, account } of created.reverse()) { + try { + await request(`/comments/${id}`, { account, method: "DELETE" }); + } catch { + errors.push({ name: "CleanupError", commentId: id }); + } + } + await bounded(() => adapter.close(), "Close disposable profile", 10000).catch(() => + errors.push({ name: "BrowserCleanupError" }), + ); + await bounded(() => apiServer.close(), "Close fixture server", TIMEOUT_MS).catch(() => + errors.push({ name: "ServerCleanupError" }), + ); + await fs.writeFile( + path.join(outputDirectory, "result.json"), + JSON.stringify( + { + kind: "hermetic-generated-artifact", + scope: notificationFocus ? "notifications-and-reconnect" : "discussion-smoke", + buildId: marker.buildId, + artifactDirectory, + results, + screenshots, + ledger, + errors, + failure: failure || null, + limitations: [ + "Fixture YouTube pages, not live YouTube", + "Headless disposable Chromium profile", + "No live account OAuth", + "No live YouTube toasts or cross-tab notification deduplication scenario", + "Native Firefox and Safari not exercised", + ], + }, + null, + 2, + ), + ); + } + assert.equal(failure, undefined, failure?.message); + assert.equal(errors.length, 0, "Smoke cleanup or diagnostics failed"); + return { + kind: "hermetic-generated-artifact", + scope: notificationFocus ? "notifications-and-reconnect" : "discussion-smoke", + buildId: marker.buildId, + assertions: results.length, + outputDirectory, + }; +} + +if (require.main === module) + runHermeticDiscussionSmoke() + .then((result) => process.stdout.write(`${JSON.stringify(result)}\n`)) + .catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); + +module.exports = { fixture, runHermeticDiscussionSmoke }; diff --git a/Extensions/e2e/discussion/live-discussion-contract.js b/Extensions/e2e/discussion/live-discussion-contract.js new file mode 100644 index 0000000..d1b1061 --- /dev/null +++ b/Extensions/e2e/discussion/live-discussion-contract.js @@ -0,0 +1,1047 @@ +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const TIMEOUT_MS = 5000; +const DEFAULT_STAGES = [ + "render", + "authoring", + "access", + "reactions", + "sorting", + "moderation", + "notifications", + "responsive", + "keyboard", + "cleanup", + "navigation", +]; + +function safeDiagnostic(value) { + return String(value) + .replace(/(access_token|sessionToken|token)=([^&\s]+)/gi, "$1=[redacted]") + .slice(0, 800); +} + +async function bounded(work, label, timeout = TIMEOUT_MS) { + let timer; + try { + return await Promise.race([ + Promise.resolve().then(work), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} exceeded ${timeout}ms`)), timeout); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +const DEFAULT_VIDEOS = { + watch: "6Rt2HtMsP6M", + shorts: "5QS-aIVIH9o", + enabled: "sYi2Sf_Bysg", +}; + +function localClient(origin, accounts, onResponse = () => {}) { + const url = new URL(origin); + assert.ok(["127.0.0.1", "localhost", "[::1]"].includes(url.hostname), "Mutation tests require loopback API"); + assert.ok( + ["http:", "https:"].includes(url.protocol) && !url.username && !url.password, + "Invalid loopback API origin", + ); + return async (route, { account, method = "GET", body, expected = 200 } = {}) => { + const token = account ? accounts[account]?.sessionToken || accounts[account]?.token : null; + if (account) assert.ok(token, `Missing local account: ${account}`); + const response = await fetch(`${url.origin}/api/discussion${route}`, { + method, + headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + const data = await response.json().catch(() => null); + onResponse({ + source: "loopback-client", + method, + path: `/api/discussion${route.split("?")[0]}`, + status: response.status, + }); + assert.equal( + response.status, + expected, + `${method} ${route.split("?")[0]} failed: ${data?.code || response.status}`, + ); + return data; + }; +} + +function assertLocalRuntime(origin, runtime, health) { + localClient(origin, {}); + assert.equal(health?.localTestMode, true, "Local discussion host marker is required"); + assert.match(runtime?.buildId || "", /^[a-f0-9]{32}$/, "Generated live build marker is required"); + assert.equal(runtime?.localOriginMatched, true, "Loaded extension must use the requested local discussion origin"); + assert.equal(runtime?.localHostPermission, true, "Loaded extension is missing its local test host permission"); + return { buildId: runtime.buildId, extensionId: runtime.extensionId, origin: new URL(origin).origin }; +} + +function createFixtureCleanup(request) { + const comments = new Map(); + const pending = []; + const hidden = new Set(); + const suspended = new Set(); + const reports = new Set(); + const remember = (comment, account) => { + if (comment?.id) { + comments.set(comment.id, { ...comment, account }); + for (const fixture of pending) + if ( + fixture.account === account && + fixture.text === comment.text && + (fixture.parentId || null) === (comment.parentId || null) + ) + fixture.resolvedId = comment.id; + } + return comment; + }; + return { + remember, + pending, + hidden, + suspended, + reports, + async run() { + const results = []; + async function attempt(name, work) { + try { + await bounded(work, name); + results.push({ name, passed: true }); + } catch (error) { + results.push({ name, passed: false, message: safeDiagnostic(error.message) }); + } + } + for (const id of hidden) + await attempt(`restore-comment:${id}`, () => + request(`/moderation/comments/${id}`, { account: "moderator", method: "POST", body: { hidden: false } }), + ); + for (const id of suspended) + await attempt(`restore-profile:${id}`, () => + request(`/moderation/profiles/${id}/suspension`, { + account: "moderator", + method: "POST", + body: { suspended: false }, + }), + ); + for (const fixture of pending.filter((item) => !item.resolvedId)) + await attempt(`discover-created:${fixture.label}`, async () => { + const route = fixture.parentId + ? `/comments/${fixture.parentId}/replies` + : `/videos/${fixture.videoId}/comments?sort=new`; + let cursor = null; + const deadline = Date.now() + TIMEOUT_MS; + do { + const data = await request( + `${route}${cursor ? `${route.includes("?") ? "&" : "?"}cursor=${encodeURIComponent(cursor)}` : ""}`, + { account: fixture.account }, + ); + const found = (data.items || []).filter((comment) => comment.text === fixture.text); + for (const comment of found) remember(comment, fixture.account); + if (found.length) break; + cursor = data.nextCursor; + if (cursor && Date.now() >= deadline) throw new Error("Fixture discovery exceeded its deadline"); + } while (cursor); + }); + for (const comment of [...comments.values()].sort((a, b) => Number(!!b.parentId) - Number(!!a.parentId))) + await attempt(`delete-created:${comment.id}`, async () => { + const current = await request(`/comments/${comment.id}`, { account: comment.account }); + if (!current.deleted) + await request(`/comments/${comment.id}`, { account: comment.account, method: "DELETE" }); + const verified = await request(`/comments/${comment.id}`, { account: comment.account }); + assert.equal(verified.deleted, true, "Fixture deletion did not persist"); + assert.equal(verified.text, "", "Fixture body remains public"); + }); + for (const id of reports) + await attempt(`dismiss-created:${id}`, () => + request(`/moderation/reports/${id}/dismiss`, { account: "moderator", method: "POST", body: {} }), + ); + return results; + }, + }; +} + +function solve(challenge, difficulty) { + assert.ok(Number.isInteger(difficulty) && difficulty >= 0 && difficulty <= 24, "Invalid local puzzle difficulty"); + const buffer = Buffer.alloc(20); + const decoded = Buffer.from(challenge, "base64"); + assert.equal(decoded.length, 16, "Invalid local puzzle challenge"); + decoded.copy(buffer, 4); + const deadline = Date.now() + TIMEOUT_MS; + for (let value = 0; value < 2 ** difficulty * 20; value++) { + if (value % 1024 === 0 && Date.now() >= deadline) throw new Error("Local puzzle exceeded 5000ms"); + buffer.writeUInt32LE(value, 0); + const digest = crypto.createHash("sha512").update(buffer).digest(); + let zeros = 0; + for (const byte of digest) { + if (byte === 0) zeros += 8; + else { + zeros += Math.clz32(byte) - 24; + break; + } + } + if (zeros >= difficulty) return buffer.subarray(0, 4).toString("base64"); + } + throw new Error("Local reaction challenge exhausted"); +} + +async function poll(probe, accept, message, timeout = TIMEOUT_MS) { + const deadline = Date.now() + timeout; + let value; + while (Date.now() < deadline) { + value = await bounded(probe, message, Math.max(1, deadline - Date.now())); + if (accept(value)) return value; + await new Promise((resolve) => setTimeout(resolve, Math.min(150, Math.max(0, deadline - Date.now())))); + } + throw new Error(message); +} + +/** + * Runs selected stages against the generated extension on native YouTube. + * Example: { surfaces: ["watch"], stages: ["authoring", "reactions"] }. + * A render-only stage does not create comments. Other stages seed missing prerequisites through the loopback API. + * The caller owns one persistent browser connection and switches YouTube UI language before each final run. + */ +async function runLiveDiscussionMatrix({ + page, + popup, + setAccount, + accounts, + origin, + language, + outputDirectory, + videos = DEFAULT_VIDEOS, + surfaces = ["watch", "shorts"], + stages = DEFAULT_STAGES, + checkpoint = () => {}, + notificationObserver, + captureScreenshot, +}) { + assert.ok(["en", "ru"].includes(language)); + assert.ok(surfaces.length && surfaces.every((value) => ["watch", "shorts"].includes(value)), "Invalid surfaces"); + assert.ok( + stages.every((value) => DEFAULT_STAGES.includes(value)), + "Invalid stages", + ); + const selected = new Set(stages.length ? stages : ["render"]); + page.setDefaultTimeout(TIMEOUT_MS); + page.setDefaultNavigationTimeout(TIMEOUT_MS); + popup.setDefaultTimeout(TIMEOUT_MS); + await fs.mkdir(outputDirectory, { recursive: true }); + const results = []; + const diagnostics = []; + const ledger = []; + const request = localClient(origin, accounts, (entry) => ledger.push(entry)); + const fixtures = createFixtureCleanup(request); + const cleanupResults = []; + const stageResults = []; + const omissions = [ + ...DEFAULT_STAGES.filter((stage) => !selected.has(stage)).map((stage) => ({ stage, reason: "Not selected" })), + ...[ + "native-language-menu", + "native-theme-switch", + "spa-and-recycled-shorts", + "explicit-native-kids", + "cross-tab-toasts", + "offline-and-worker-restart", + "cursor-pagination", + ].map((stage) => ({ stage, reason: "Caller-owned supplementary acceptance" })), + ...(!notificationObserver + ? [{ stage: "native-signalr-frames", reason: "No native socket observer supplied; runtime inbox delivery only" }] + : []), + ]; + let runtime = null; + const responseListener = (response) => { + const url = new URL(response.url()); + if (url.origin !== new URL(origin).origin || !url.pathname.startsWith("/api/discussion/")) return; + ledger.push({ + source: "browser", + method: response.request().method(), + path: url.pathname, + status: response.status(), + }); + }; + const errorListener = (error) => diagnostics.push({ kind: "page", message: safeDiagnostic(error.message) }); + page.context().on("response", responseListener); + page.on("pageerror", errorListener); + const record = (name, details = {}) => { + results.push({ name, passed: true, ...details }); + checkpoint(name); + }; + const message = (operation, payload = {}) => + bounded( + () => + popup.evaluate( + ({ operation, payload }) => chrome.runtime.sendMessage({ message: "ryd_discussion", operation, payload }), + { operation, payload }, + ), + `Worker ${operation}`, + ); + const root = () => page.locator("#ryd-discussion-panel, #ryd-discussion-drawer").filter({ visible: true }); + const row = (id) => root().locator(`[data-comment-id="${id}"]`); + const actions = (id) => row(id).locator(":scope > .ryd-discussion-comment-actions"); + const editor = (id) => row(id).locator(":scope > .ryd-discussion-inline-editor"); + const profiles = new Map(); + async function ensureProfile(name) { + let value = await request("/me", { account: name }); + if (!value.profile?.nickname) + value = await request("/me/profile", { + account: name, + method: "PUT", + body: { nickname: `Test ${name} ${crypto.randomBytes(3).toString("hex")}` }, + }); + profiles.set(name, value.profile?.id); + } + const checkLocale = async () => assert.ok((await page.locator("html").getAttribute("lang")).startsWith(language)); + async function account(name) { + if (name && !profiles.has(name)) await ensureProfile(name); + await bounded(() => setAccount(name), "Set local extension account"); + await poll( + () => message("session"), + (response) => + response.ok && + (name == null + ? !response.data.authenticated + : response.data.authenticated && response.data.profile?.id === profiles.get(name)), + "Extension session did not converge", + ); + } + async function open(surface) { + const deadline = Date.now() + 25000; + const remaining = () => Math.max(1, deadline - Date.now()); + await bounded( + async () => { + await page.goto( + `https://www.youtube.com/${surface === "watch" ? `watch?v=${videos.watch}` : `shorts/${videos.shorts}`}`, + { waitUntil: "domcontentloaded", timeout: Math.min(TIMEOUT_MS, remaining()) }, + ); + await checkLocale(); + const native = await poll( + () => + bounded( + () => + page.evaluate( + ({ surface, videoId }) => { + const data = (node) => node?.polymerController?.data || node?.data; + if (surface === "shorts") { + const player = document.querySelector("#shorts-player"); + let currentId; + try { + currentId = player?.getVideoData?.()?.video_id; + } catch { + return null; + } + const reel = player?.closest("ytd-reel-video-renderer"); + if (currentId !== videoId || !reel) return null; + const buttons = [ + ...(data(reel.querySelector("reel-action-bar-view-model"))?.buttonViewModels || []), + ...(data(reel)?.overlay?.reelPlayerOverlayRenderer?.buttonBar?.reelActionBarViewModel + ?.buttonViewModels || []), + ]; + return buttons.some( + ({ buttonViewModel: button }) => + button?.iconName === "SHORTS_COMMENT" && button.state === "BUTTON_VIEW_MODEL_STATE_DISABLED", + ) + ? { videoId, evidence: "current-shorts-disabled-action" } + : null; + } + const watch = [...document.querySelectorAll("ytd-watch-flexy, ytd-watch-grid")].find( + (node) => node.getAttribute("video-id") === videoId, + ); + if (!watch) return null; + const disabledLink = (value) => { + try { + const url = new URL(value, location.origin); + return url.hostname === "support.google.com" && url.pathname === "/youtube/answer/9706180"; + } catch { + return false; + } + }; + const hasNotice = (value, depth = 0) => + value && + typeof value === "object" && + depth <= 14 && + (value.messageRenderer?.text?.runs?.some((run) => + disabledLink(run.navigationEndpoint?.urlEndpoint?.url), + ) || + Object.values(value).some((child) => hasNotice(child, depth + 1))); + for (const comments of watch.querySelectorAll("ytd-comments")) { + comments.scrollIntoView({ block: "center" }); + if ( + comments.getBoundingClientRect().height > 0 && + (hasNotice(data(comments)) || + [...comments.querySelectorAll("ytd-message-renderer a[href]")].some((anchor) => + disabledLink(anchor.href), + )) + ) + return { videoId, evidence: "native-disabled-comments-notice" }; + } + return null; + }, + { surface, videoId: videos[surface] }, + ), + "Inspect native YouTube anchor", + Math.min(TIMEOUT_MS, remaining()), + ), + Boolean, + "Native YouTube disabled-comments anchor did not hydrate", + Math.min(20000, remaining()), + ); + record(`${surface}:native-anchor-ready`, native); + checkpoint(`${surface}:ryd-panel-wait`); + await bounded( + async () => { + await poll( + () => page.locator("html").getAttribute("data-ryd-extension-build"), + (buildId) => buildId === runtime.buildId, + "Content runtime does not match the guarded live build", + Math.min(TIMEOUT_MS, remaining()), + ); + if (surface === "shorts") { + await page + .locator("#ryd-discussion-launcher") + .waitFor({ state: "visible", timeout: Math.min(TIMEOUT_MS, remaining()) }); + await page.locator("#ryd-discussion-launcher").click({ timeout: Math.min(TIMEOUT_MS, remaining()) }); + } + await root().waitFor({ state: "visible", timeout: Math.min(TIMEOUT_MS, remaining()) }); + assert.equal(await root().count(), 1); + assert.equal(await root().getAttribute("data-video-id"), videos[surface]); + }, + "RYD panel readiness", + Math.min(TIMEOUT_MS, remaining()), + ); + record(`${surface}:ryd-panel-ready`); + }, + "Open native video and discussion", + 25000, + ); + } + async function capture(name) { + await poll( + () => + root() + .evaluate((node) => { + node.scrollIntoView({ block: "center" }); + return node.isConnected; + }) + .catch((error) => { + if (/not attached|Execution context was destroyed/.test(error.message)) return false; + throw error; + }), + Boolean, + "The discussion container changed during screenshot preparation", + ); + await bounded( + () => + captureScreenshot + ? captureScreenshot(page, path.join(outputDirectory, `${name}.png`)) + : page.screenshot({ + path: path.join(outputDirectory, `${name}.png`), + animations: "disabled", + mask: [page.locator("#avatar-btn")], + timeout: TIMEOUT_MS, + }), + `Screenshot ${name}`, + ); + } + async function newest(id) { + await root().locator(".ryd-discussion-thread > .ryd-discussion-toolbar select").selectOption("new"); + await row(id).waitFor({ state: "visible", timeout: TIMEOUT_MS }); + } + const inboxEvents = () => + bounded(() => popup.evaluate(() => globalThis.__rydDiscussionLiveInboxEvents || []), "Read incoming inbox events"); + let failure = null; + try { + const health = await request("/local/health"); + const metadata = await bounded( + () => + popup.evaluate(async (origin) => { + const manifest = chrome.runtime.getManifest(); + const read = (name) => fetch(chrome.runtime.getURL(name), { signal: AbortSignal.timeout(5000) }); + const [markerResponse, backgroundResponse] = await Promise.all([ + read("live-build.json"), + read("ryd.background.js"), + ]); + const marker = markerResponse.ok ? await markerResponse.json() : null; + const background = backgroundResponse.ok ? await backgroundResponse.text() : ""; + return { + extensionId: chrome.runtime.id, + buildId: marker?.buildId, + localOriginMatched: background.includes(`const testOrigin = true ? ${JSON.stringify(origin)}`), + localHostPermission: + manifest.host_permissions?.some( + (pattern) => + pattern === `${origin}/*` || pattern === `${new URL(origin).protocol}//${new URL(origin).hostname}/*`, + ) === true, + }; + }, new URL(origin).origin), + "Verify local extension runtime", + ); + runtime = assertLocalRuntime(origin, metadata, health); + record("local-runtime-and-host-guard", runtime); + await bounded( + () => + popup.evaluate(() => { + if (globalThis.__rydDiscussionLiveInboxListener) + chrome.runtime.onMessage.removeListener(globalThis.__rydDiscussionLiveInboxListener); + globalThis.__rydDiscussionLiveInboxEvents = []; + let sequence = 0; + globalThis.__rydDiscussionLiveInboxListener = (event) => { + if (event?.message !== "ryd_discussion_event" || event.type !== "inbox") return; + globalThis.__rydDiscussionLiveInboxEvents.push({ + sequence: ++sequence, + data: { + unreadCount: event.data?.unreadCount, + revision: event.data?.revision, + items: (event.data?.items || []).map(({ id, commentId, type, read, revision }) => ({ + id, + commentId, + type, + read, + revision, + })), + }, + }); + if (globalThis.__rydDiscussionLiveInboxEvents.length > 100) + globalThis.__rydDiscussionLiveInboxEvents.shift(); + }; + chrome.runtime.onMessage.addListener(globalThis.__rydDiscussionLiveInboxListener); + }), + "Install incoming inbox observer", + ); + for (const surface of surfaces) { + const videoId = videos[surface]; + await account("commenter"); + const session = await message("session"); + if (!session.data.profile?.nickname) { + const result = await message("profile", { + nickname: `Test Commenter ${crypto.randomBytes(3).toString("hex")}`, + }); + assert.equal(result.ok, true); + } + await open(surface); + const marker = `${language} ${surface} ${crypto.randomUUID()}`; + const text = `Local discussion check ${marker} plain text`; + const composer = root().locator(".ryd-discussion-composer textarea[name=comment]"); + let comment; + if (selected.has("authoring")) { + const accountControls = root().locator(".ryd-discussion-composer .ryd-discussion-account button"); + if (await accountControls.count()) await accountControls.click(); + const nickname = `Live ${language} ${surface} ${crypto.randomBytes(3).toString("hex")}`; + await root().locator('.ryd-discussion-composer input[name="nickname"]').fill(nickname); + await root().locator('.ryd-discussion-composer button[type="submit"]').click(); + await poll( + () => request("/me", { account: "commenter" }), + (data) => data.profile?.nickname === nickname, + "Nickname change did not persist", + ); + await composer.waitFor({ state: "visible" }); + record(`${surface}:nickname-ui-change`); + fixtures.pending.push({ label: `${surface}-root`, videoId, text, account: "commenter" }); + await composer.fill(text); + await root().locator(".ryd-discussion-composer [data-action=post]").click(); + const created = await poll( + () => request(`/videos/${videoId}/comments?sort=new`, { account: "commenter" }), + (result) => result.items.some((item) => item.text === text), + "Posted comment missing from authoritative list", + ); + comment = fixtures.remember( + created.items.find((item) => item.text === text), + "commenter", + ); + await newest(comment.id); + assert.equal(await row(comment.id).locator(".ryd-discussion-comment-text").first().textContent(), text); + assert.equal(await row(comment.id).locator(".ryd-discussion-comment-text b").count(), 0); + record(`${surface}:create-plain-text`); + + await actions(comment.id).locator("[data-action=edit]").click(); + await editor(comment.id).locator("textarea[name=comment]").fill(`Edited ${text}`); + await editor(comment.id).locator("[data-action=save]").click(); + await poll( + () => request(`/comments/${comment.id}`, { account: "commenter" }), + (data) => data.text === `Edited ${text}`, + "Edit was not persisted", + ); + record(`${surface}:edit`); + + await actions(comment.id).locator("[data-action=reply]").click(); + const replyText = `Reply ${marker}`; + fixtures.pending.push({ + label: `${surface}-reply`, + videoId, + parentId: comment.id, + text: replyText, + account: "commenter", + }); + await editor(comment.id).locator("textarea[name=comment]").fill(replyText); + await editor(comment.id).locator("[data-action=post]").click(); + const replies = await poll( + () => request(`/comments/${comment.id}/replies`, { account: "commenter" }), + (data) => data.items.some((item) => item.text === replyText), + "Reply was not persisted", + ); + fixtures.remember( + replies.items.find((item) => item.text === replyText), + "commenter", + ); + record(`${surface}:reply`); + await capture(`${surface}-authoring`); + } + if ( + !comment && + ["access", "reactions", "sorting", "moderation", "notifications", "cleanup"].some((stage) => + selected.has(stage), + ) + ) { + fixtures.pending.push({ label: `${surface}-root`, videoId, text, account: "commenter" }); + comment = await request(`/videos/${videoId}/comments`, { + account: "commenter", + method: "POST", + body: { text, requestId: crypto.randomUUID() }, + }); + fixtures.remember(comment, "commenter"); + await newest(comment.id); + record(`${surface}:local-fixture`, { commentId: comment.id }); + } + if (!stages.length || selected.has("render")) { + await capture(`${surface}-render`); + record(`${surface}:render`); + } + if (selected.has("access")) { + await account("premium"); + await composer.waitFor({ state: "visible" }); + assert.equal((await message("session")).data.canPost, true); + record(`${surface}:premium-posting-gate`); + for (const name of ["supporter", "inactive"]) { + await account(name); + await poll( + () => composer.count(), + (count) => count === 0, + `${name} account still has composer`, + ); + assert.equal((await message("session")).data.canPost, false); + record(`${surface}:${name}-posting-gate`); + } + await account(null); + await newest(comment.id); + assert.equal(await composer.count(), 0); + await poll( + () => actions(comment.id).locator("[data-action=edit]").count(), + (count) => count === 0, + "Signed-out author actions remain", + ); + record(`${surface}:signed-out-author-controls`); + await account("commenter"); + await newest(comment.id); + await actions(comment.id).locator("[data-action=edit]").waitFor({ state: "visible" }); + record(`${surface}:author-controls-restored`); + } + if (selected.has("reactions")) { + await account(null); + await newest(comment.id); + for (const value of [1, -1, -1]) { + const button = actions(comment.id).locator(`[data-reaction="${value}"]`); + await poll(() => button.isEnabled(), Boolean, "Reaction button not ready"); + const expected = (await button.getAttribute("aria-pressed")) === "true" ? 0 : value; + const beforeValue = + (await actions(comment.id).locator('[data-reaction="1"]').getAttribute("aria-pressed")) === "true" + ? 1 + : (await actions(comment.id).locator('[data-reaction="-1"]').getAttribute("aria-pressed")) === "true" + ? -1 + : 0; + const before = await request(`/comments/${comment.id}`); + await button.click(); + await poll( + async () => ({ + enabled: await button.isEnabled(), + like: await actions(comment.id).locator('[data-reaction="1"]').getAttribute("aria-pressed"), + dislike: await actions(comment.id).locator('[data-reaction="-1"]').getAttribute("aria-pressed"), + }), + (state) => + state.enabled && state.like === String(expected === 1) && state.dislike === String(expected === -1), + "Reaction did not match the requested transition", + ); + const after = await request(`/comments/${comment.id}`); + assert.equal(after.likes, before.likes - Number(beforeValue === 1) + Number(expected === 1)); + assert.equal(after.dislikes, before.dislikes - Number(beforeValue === -1) + Number(expected === -1)); + } + assert.equal(await actions(comment.id).locator('[data-reaction="-1"]').getAttribute("aria-pressed"), "false"); + record(`${surface}:anonymous-like-dislike-remove`); + } + if (selected.has("sorting")) { + for (const sort of ["new", "top"]) { + const expected = await request(`/videos/${videoId}/comments?sort=${sort}`); + await root().locator(".ryd-discussion-thread > .ryd-discussion-toolbar select").selectOption(sort); + await poll( + () => + root() + .locator(".ryd-discussion-comments > [data-comment-id]") + .evaluateAll((nodes) => nodes.map((node) => node.dataset.commentId)), + (ids) => JSON.stringify(ids) === JSON.stringify(expected.items.map((item) => item.id)), + `Rendered ${sort} order differs from API`, + ); + } + await newest(comment.id); + record(`${surface}:sorting`); + } + if (selected.has("moderation")) { + await ensureProfile("other"); + await ensureProfile("moderator"); + await account("other"); + await newest(comment.id); + await actions(comment.id).locator("[data-action=report]").click(); + await editor(comment.id).locator("[data-action=send-report]").click(); + const submitted = await poll( + () => request("/moderation/reports", { account: "moderator" }), + (data) => data.items.some((item) => item.comment.id === comment.id), + "Report not persisted", + ); + const reportId = submitted.items.find((item) => item.comment.id === comment.id).id; + fixtures.reports.add(reportId); + await account("moderator"); + await root().locator("[data-action=moderation]").click(); + const report = root().locator(`[data-report-id="${reportId}"]`); + await report.waitFor({ state: "visible" }); + fixtures.hidden.add(comment.id); + await report.locator("[data-action=hide]").click(); + await report.locator("[data-action=restore]").waitFor({ state: "visible" }); + assert.equal((await request(`/comments/${comment.id}`)).hidden, true); + fixtures.suspended.add(comment.author.id); + await report.locator("[data-action=ban]").click(); + await report.locator("[data-action=unban]").waitFor({ state: "visible" }); + assert.equal((await request("/me", { account: "commenter" })).profile.suspended, true); + await report.locator("[data-action=dismiss]").click(); + await report.waitFor({ state: "detached", timeout: TIMEOUT_MS }); + fixtures.reports.delete(reportId); + await root().locator("[data-action=report-status]").selectOption("resolved"); + await report.waitFor({ state: "visible" }); + assert.ok( + (await request("/moderation/reports?status=resolved", { account: "moderator" })).items.some( + (item) => item.id === reportId, + ), + ); + await capture(`${surface}-moderation-history`); + await report.locator("[data-action=restore]").click(); + await report.locator("[data-action=hide]").waitFor({ state: "visible" }); + assert.equal((await request(`/comments/${comment.id}`)).hidden, false); + fixtures.hidden.delete(comment.id); + await report.locator("[data-action=unban]").click(); + await report.locator("[data-action=ban]").waitFor({ state: "visible" }); + assert.equal((await request("/me", { account: "commenter" })).profile.suspended, false); + fixtures.suspended.delete(comment.author.id); + record(`${surface}:report-resolved-history-restore`); + } + if (selected.has("notifications")) { + await ensureProfile("other"); + await account("commenter"); + await root().locator("[data-action=discussion]").click(); + const cleared = await message("readNotifications", { all: true }); + assert.equal(cleared.ok, true); + for (const type of ["reply", "likes"]) { + const nativeBaseline = notificationObserver + ? await bounded( + () => notificationObserver.beforeActivity({ surface, type, commentId: comment.id }), + "Await native notification transport", + ) + : null; + const baseline = (await inboxEvents()).at(-1)?.sequence || 0; + let activityId = comment.id; + if (type === "reply") { + const replyText = `Notification reply ${marker}`; + fixtures.pending.push({ + label: `${surface}-notification-reply`, + videoId, + parentId: comment.id, + text: replyText, + account: "other", + }); + const incomingReply = fixtures.remember( + await request(`/videos/${videoId}/comments`, { + account: "other", + method: "POST", + body: { text: replyText, parentId: comment.id, replyToId: comment.id, requestId: crypto.randomUUID() }, + }), + "other", + ); + activityId = incomingReply.id; + } else { + const userId = accounts.other.voterId; + assert.ok(userId, "Missing separate local reaction identity"); + const challenge = await request(`/comments/${comment.id}/reaction/challenge`, { + method: "POST", + body: { userId, value: 1 }, + }); + await request(`/comments/${comment.id}/reaction/confirm`, { + method: "POST", + body: { + userId, + challengeId: challenge.challengeId, + solution: solve(challenge.challenge, challenge.difficulty), + }, + }); + } + if (notificationObserver) { + const proof = await bounded( + () => + notificationObserver.afterActivity({ surface, type, commentId: activityId, baseline: nativeBaseline }), + "Await native incoming notification frame", + ); + assert.equal(proof?.observed, true, "Native notification observer must attest a received frame"); + record(`${surface}:${type}-native-signalr-frame`, { + revision: proof.revision == null ? null : String(proof.revision), + connection: Number.isInteger(proof.connection) ? proof.connection : null, + }); + } + // Observe incoming delivery before explicitly reading the inbox or opening its UI. + const matches = (event) => + event.sequence > baseline && + event.data.items?.some((item) => item.commentId === activityId && item.type === type && !item.read); + const incomingEvents = await poll( + inboxEvents, + (events) => events.some(matches), + `Incoming runtime inbox event for ${type} did not arrive`, + ); + const incoming = incomingEvents.find(matches); + const notification = incoming.data.items.find( + (item) => item.commentId === activityId && item.type === type && !item.read, + ); + assert.ok(incoming.data.unreadCount > 0); + record(`${surface}:${type}-notification-runtime-event`, { + notificationId: notification.id, + revision: incoming.data.revision, + }); + await root().locator("[data-action=inbox]").click(); + const inboxRow = root().locator(`[data-notification-id="${notification.id}"]`); + await inboxRow.waitFor({ state: "visible" }); + await capture(`${surface}-${type}-notifications`); + await inboxRow.locator("button").click(); + await poll( + () => request("/notifications", { account: "commenter" }), + (data) => data.items.find((item) => item.id === notification.id)?.read === true, + "UI mark read was not persisted", + ); + record(`${surface}:${type}-inbox-ui-mark-read`); + await root().locator("[data-action=discussion]").click(); + } + } + if (selected.has("responsive")) { + const viewport = await bounded( + () => page.evaluate(() => ({ width: innerWidth, height: innerHeight })), + "Read original viewport", + ); + try { + await bounded(() => page.setViewportSize({ width: 390, height: 844 }), "Set narrow viewport"); + await poll( + () => root().boundingBox(), + (box) => box && box.x >= -1 && box.x + box.width <= 392, + "Discussion exceeds the narrow viewport", + ); + await capture(`${surface}-390px`); + record(`${surface}:responsive-390px`); + } finally { + await bounded(() => page.setViewportSize(viewport), "Restore viewport"); + } + let previousBox = null; + let stableSince = Date.now(); + await poll( + async () => { + const box = JSON.stringify(await root().boundingBox()); + if (box === "null" || box !== previousBox) stableSince = Date.now(); + previousBox = box; + return Date.now() - stableSince >= 500; + }, + Boolean, + "Discussion layout did not settle after restoring the viewport", + ); + } + if (selected.has("keyboard")) { + await account("commenter"); + await root().locator("[data-action=discussion]").click(); + await composer.focus(); + await bounded(() => page.keyboard.press("Tab"), "Keyboard Tab"); + assert.ok( + await bounded( + () => root().evaluate((node) => node.contains(document.activeElement)), + "Inspect keyboard focus", + ), + "Keyboard Tab left the discussion panel", + ); + if (surface === "shorts") { + const controls = root().locator( + "button:visible:not(:disabled), input:visible:not(:disabled), textarea:visible:not(:disabled), select:visible:not(:disabled), a[href]:visible", + ); + await controls.last().focus(); + await bounded(() => page.keyboard.press("Tab"), "Drawer focus wrap"); + assert.ok( + await bounded( + () => controls.first().evaluate((node) => node === document.activeElement), + "Inspect wrapped focus", + ), + "Drawer keyboard focus did not wrap to its first control", + ); + await bounded(() => page.keyboard.press("Escape"), "Close drawer with Escape"); + await page.locator("#ryd-discussion-drawer").waitFor({ state: "hidden", timeout: TIMEOUT_MS }); + assert.ok( + await bounded( + () => page.locator("#ryd-discussion-launcher").evaluate((node) => node === document.activeElement), + "Inspect launcher focus", + ), + "Closing the drawer did not return focus to its launcher", + ); + await page.locator("#ryd-discussion-launcher").press("Enter"); + await root().waitFor({ state: "visible", timeout: TIMEOUT_MS }); + } + const keyboardText = `Keyboard submission ${marker}`; + fixtures.pending.push({ label: `${surface}-keyboard`, videoId, text: keyboardText, account: "commenter" }); + await composer.fill(keyboardText); + await composer.press("Control+Enter"); + const created = await poll( + () => request(`/videos/${videoId}/comments?sort=new`, { account: "commenter" }), + (data) => data.items.some((item) => item.text === keyboardText), + "Keyboard submission was not persisted", + ); + const keyboardComment = fixtures.remember( + created.items.find((item) => item.text === keyboardText), + "commenter", + ); + await capture(`${surface}-keyboard`); + await request(`/comments/${keyboardComment.id}`, { account: "commenter", method: "DELETE" }); + record(`${surface}:keyboard-controls-and-submit`); + } + if (selected.has("cleanup") && comment) { + await account("commenter"); + await root().locator("[data-action=discussion]").click(); + await newest(comment.id); + const before = await request(`/comments/${comment.id}`); + await actions(comment.id).locator("[data-action=delete]").click(); + await editor(comment.id).locator("[data-action=confirm-delete]").click(); + const deleted = await poll( + () => request(`/comments/${comment.id}`), + (data) => data.deleted === true, + "Deleted comment did not become a tombstone", + ); + assert.equal(deleted.text, ""); + assert.equal(deleted.replyCount, before.replyCount); + record(`${surface}:delete-tombstone`); + } + } + if (selected.has("navigation")) { + await page.goto(`https://www.youtube.com/watch?v=${videos.enabled}`, { waitUntil: "domcontentloaded" }); + await page.locator("ytd-comments").first().scrollIntoViewIfNeeded({ timeout: TIMEOUT_MS }); + await page.locator("ytd-comments-header-renderer").first().waitFor({ state: "attached", timeout: TIMEOUT_MS }); + assert.equal(await page.locator("#ryd-discussion-panel,#ryd-discussion-launcher").count(), 0); + record("enabled-video-excluded"); + await page.goBack({ waitUntil: "domcontentloaded" }); + const previous = surfaces.at(-1); + await page + .locator(previous === "shorts" ? "#ryd-discussion-launcher" : "#ryd-discussion-panel") + .waitFor({ state: "visible", timeout: TIMEOUT_MS }); + record(`history-returns-to-${previous}`); + } + return { + language, + surfaces, + stages, + runtime, + results, + diagnostics, + ledger, + cleanupResults, + stageResults, + omissions, + }; + } catch (error) { + failure = { message: safeDiagnostic(error.message) }; + throw error; + } finally { + const completed = new Set(results.map((result) => result.name)); + const lastCheck = { + render: "render", + authoring: "reply", + access: "author-controls-restored", + reactions: "anonymous-like-dislike-remove", + sorting: "sorting", + moderation: "report-resolved-history-restore", + notifications: "likes-inbox-ui-mark-read", + responsive: "responsive-390px", + keyboard: "keyboard-controls-and-submit", + cleanup: "delete-tombstone", + }; + for (const surface of surfaces) + for (const [stage, check] of Object.entries(lastCheck)) + stageResults.push({ + surface, + stage, + status: !selected.has(stage) + ? "not-selected" + : completed.has(`${surface}:${check}`) + ? "passed" + : "not-completed", + }); + stageResults.push({ + stage: "navigation", + status: !selected.has("navigation") + ? "not-selected" + : completed.has(`history-returns-to-${surfaces.at(-1)}`) + ? "passed" + : "not-completed", + }); + cleanupResults.push(...(await fixtures.run())); + const cleanupFailed = cleanupResults.some((result) => !result.passed); + if (cleanupFailed && !failure) failure = { message: "Local fixture cleanup failed; inspect cleanupResults" }; + page.context().off("response", responseListener); + page.off("pageerror", errorListener); + await bounded( + () => + popup.evaluate(() => { + if (globalThis.__rydDiscussionLiveInboxListener) + chrome.runtime.onMessage.removeListener(globalThis.__rydDiscussionLiveInboxListener); + delete globalThis.__rydDiscussionLiveInboxListener; + delete globalThis.__rydDiscussionLiveInboxEvents; + }), + "Remove incoming inbox observer", + ).catch(() => {}); + await fs.writeFile( + path.join(outputDirectory, "results.json"), + JSON.stringify( + { + language, + surfaces, + stages, + runtime, + results, + diagnostics, + ledger, + cleanupResults, + stageResults, + omissions, + failure, + }, + null, + 2, + ), + ); + if (cleanupFailed && failure?.message === "Local fixture cleanup failed; inspect cleanupResults") + throw new Error(failure.message); + } +} + +module.exports = { + DEFAULT_VIDEOS, + DEFAULT_STAGES, + TIMEOUT_MS, + bounded, + assertLocalRuntime, + createFixtureCleanup, + localClient, + poll, + solve, + runLiveDiscussionMatrix, +}; diff --git a/Extensions/e2e/discussion/live-discussion-contract.spec.js b/Extensions/e2e/discussion/live-discussion-contract.spec.js new file mode 100644 index 0000000..807d0ed --- /dev/null +++ b/Extensions/e2e/discussion/live-discussion-contract.spec.js @@ -0,0 +1,210 @@ +const crypto = require("node:crypto"); +const fs = require("node:fs/promises"); +const { + bounded, + localClient, + poll, + solve, + runLiveDiscussionMatrix, + assertLocalRuntime, + createFixtureCleanup, +} = require("./live-discussion-contract"); + +test("the mutation client rejects non-loopback origins before doing any work", () => { + expect(() => localClient("https://returnyoutubedislikeapi.com", {})).toThrow(/loopback/); + expect(() => localClient("http://name:password@127.0.0.1:5099", {})).toThrow(/Invalid loopback/); +}); + +test("a hanging probe cannot exceed the finite poll budget", async () => { + const started = Date.now(); + await expect(poll(() => new Promise(() => {}), Boolean, "Probe stuck", 30)).rejects.toThrow(/exceeded/); + expect(Date.now() - started).toBeLessThan(1000); +}); + +test("bounded propagates real failures and clears its timeout after success", async () => { + await expect(bounded(() => 7, "Immediate", 30)).resolves.toBe(7); + await expect( + bounded( + () => { + throw new Error("Actual failure"); + }, + "Fail", + 30, + ), + ).rejects.toThrow("Actual failure"); +}); + +test("the local solver returns the four-byte SHA-512 proof format", () => { + const challenge = Buffer.alloc(16, 1).toString("base64"); + const solution = Buffer.from(solve(challenge, 4), "base64"); + expect(solution.length).toBe(4); + const digest = crypto + .createHash("sha512") + .update(Buffer.concat([solution, Buffer.from(challenge, "base64")])) + .digest(); + expect(digest[0] >>> 4).toBe(0); + expect(() => solve(challenge, 99)).toThrow(/difficulty/); +}); + +test("staged runs reject invalid surfaces before touching a browser", async () => { + await expect(runLiveDiscussionMatrix({ language: "en", surfaces: ["mobile"], page: {}, popup: {} })).rejects.toThrow( + /Invalid surfaces/, + ); + await expect( + runLiveDiscussionMatrix({ language: "en", surfaces: ["watch"], stages: ["unknown"], page: {}, popup: {} }), + ).rejects.toThrow(/Invalid stages/); +}); + +test("discussion create requests default to the implemented 200 response and ledger omits credentials", async () => { + const previousFetch = global.fetch; + const ledger = []; + global.fetch = jest.fn().mockResolvedValue({ status: 200, json: async () => ({ id: "created" }) }); + try { + const request = localClient("http://127.0.0.1:5099", { writer: { sessionToken: "test-session" } }, (entry) => + ledger.push(entry), + ); + await expect( + request("/videos/abcdefghijk/comments", { account: "writer", method: "POST", body: { text: "Local text" } }), + ).resolves.toEqual({ id: "created" }); + expect(global.fetch.mock.calls[0][0]).toBe("http://127.0.0.1:5099/api/discussion/videos/abcdefghijk/comments"); + expect(JSON.stringify(ledger)).not.toContain("test-session"); + expect(ledger).toEqual([ + { source: "loopback-client", method: "POST", path: "/api/discussion/videos/abcdefghijk/comments", status: 200 }, + ]); + } finally { + global.fetch = previousFetch; + } +}); + +test("the runtime guard requires both generated local artifact evidence and the local host marker", () => { + const runtime = { + buildId: "a".repeat(32), + extensionId: "fixture-extension", + localOriginMatched: true, + localHostPermission: true, + }; + expect(assertLocalRuntime("http://127.0.0.1:5099", runtime, { localTestMode: true })).toEqual({ + buildId: "a".repeat(32), + extensionId: "fixture-extension", + origin: "http://127.0.0.1:5099", + }); + expect(() => assertLocalRuntime("https://example.com", runtime, { localTestMode: true })).toThrow(/loopback/); + expect(() => assertLocalRuntime("http://127.0.0.1:5099", runtime, {})).toThrow(/host marker/); + for (const override of [{ buildId: null }, { localOriginMatched: false }, { localHostPermission: false }]) + expect(() => + assertLocalRuntime("http://127.0.0.1:5099", { ...runtime, ...override }, { localTestMode: true }), + ).toThrow(); +}); + +test("fixture cleanup restores moderation state and deletes replies before roots with their own authors", async () => { + const comments = new Map([ + ["root", { id: "root", text: "Root", parentId: null, deleted: false }], + ["reply", { id: "reply", text: "Reply", parentId: "root", deleted: false }], + ]); + const request = jest.fn(async (route, options = {}) => { + const id = route.split("/").at(-1); + if (route.startsWith("/comments/")) { + if (options.method === "DELETE") comments.set(id, { ...comments.get(id), text: "", deleted: true }); + return comments.get(id); + } + return {}; + }); + const cleanup = createFixtureCleanup(request); + cleanup.remember(comments.get("root"), "commenter"); + cleanup.remember(comments.get("reply"), "other"); + cleanup.hidden.add("root"); + cleanup.suspended.add("author"); + const results = await cleanup.run(); + expect(results.every((value) => value.passed)).toBe(true); + expect(request.mock.calls.slice(0, 2).map(([route]) => route)).toEqual([ + "/moderation/comments/root", + "/moderation/profiles/author/suspension", + ]); + expect( + request.mock.calls + .filter(([, options]) => options.method === "DELETE") + .map(([route, options]) => [route, options.account]), + ).toEqual([ + ["/comments/reply", "other"], + ["/comments/root", "commenter"], + ]); +}); + +test("fixture cleanup discovers a committed UI post whose response was lost, without deleting unrelated rows", async () => { + const created = { id: "lost-response", text: "Unique attempt marker", deleted: false }; + const request = jest.fn(async (route, options = {}) => { + if (route.startsWith("/videos/")) return { items: [created, { id: "unrelated", text: "Someone else" }] }; + if (options.method === "DELETE") Object.assign(created, { deleted: true, text: "" }); + return created; + }); + const cleanup = createFixtureCleanup(request); + cleanup.pending.push({ label: "pending-root", videoId: "abcdefghijk", text: created.text, account: "commenter" }); + expect((await cleanup.run()).every((value) => value.passed)).toBe(true); + expect(request.mock.calls.filter(([, options]) => options.method === "DELETE").map(([route]) => route)).toEqual([ + "/comments/lost-response", + ]); +}); + +test("fixture cleanup reports a failed deletion and still attempts the remaining comments", async () => { + const deleted = new Set(); + const request = jest.fn(async (route, options = {}) => { + const id = route.split("/").at(-1); + if (id === "bad") throw new Error("Local failure"); + if (options.method === "DELETE") deleted.add(id); + return { id, deleted: deleted.has(id), text: deleted.has(id) ? "" : "Body" }; + }); + const cleanup = createFixtureCleanup(request); + cleanup.remember({ id: "bad" }, "commenter"); + cleanup.remember({ id: "good" }, "commenter"); + const results = await cleanup.run(); + expect(results.find((item) => item.name === "delete-created:bad").passed).toBe(false); + expect(results.find((item) => item.name === "delete-created:good").passed).toBe(true); +}); + +test("a rejected runtime guard cannot switch accounts or make a mutation request", async () => { + const previousFetch = global.fetch; + const mkdir = jest.spyOn(fs, "mkdir").mockResolvedValue(undefined); + const writeFile = jest.spyOn(fs, "writeFile").mockResolvedValue(undefined); + global.fetch = jest.fn().mockResolvedValue({ status: 200, json: async () => ({ localTestMode: true }) }); + const context = { on: jest.fn(), off: jest.fn() }; + const page = { + setDefaultTimeout: jest.fn(), + setDefaultNavigationTimeout: jest.fn(), + context: () => context, + on: jest.fn(), + off: jest.fn(), + }; + const popup = { + setDefaultTimeout: jest.fn(), + evaluate: jest + .fn() + .mockResolvedValue({ buildId: "a".repeat(32), localOriginMatched: false, localHostPermission: true }), + }; + const setAccount = jest.fn(); + try { + await expect( + runLiveDiscussionMatrix({ + page, + popup, + setAccount, + accounts: {}, + origin: "http://127.0.0.1:5099", + language: "en", + outputDirectory: "unused", + stages: ["authoring"], + }), + ).rejects.toThrow(/requested local discussion origin/); + expect(setAccount).not.toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch.mock.calls[0][1].method).toBe("GET"); + const receipt = JSON.parse(writeFile.mock.calls[0][1]); + expect(receipt.cleanupResults).toEqual([]); + expect(receipt.stageResults.find((value) => value.surface === "watch" && value.stage === "authoring").status).toBe( + "not-completed", + ); + } finally { + global.fetch = previousFetch; + mkdir.mockRestore(); + writeFile.mockRestore(); + } +}); diff --git a/Extensions/e2e/discussion/watch-width-smoke.js b/Extensions/e2e/discussion/watch-width-smoke.js new file mode 100644 index 0000000..f32d52c --- /dev/null +++ b/Extensions/e2e/discussion/watch-width-smoke.js @@ -0,0 +1,87 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("@playwright/test"); + +async function runWatchWidthSmoke() { + const repository = path.resolve(__dirname, "../../.."); + const output = path.join(repository, "test-results/discussion", `watch-width-${Date.now()}`); + const css = await fs.readFile(path.join(repository, "Extensions/combined/content-style.css"), "utf8"); + await fs.mkdir(output, { recursive: true }); + const browser = await chromium.launch({ headless: true }); + const results = []; + try { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + await context.route("**/*", (route) => route.abort()); + const page = await context.newPage(); + page.setDefaultTimeout(5000); + for (const language of ["en", "ru"]) { + const ru = language === "ru"; + await page.setViewportSize({ width: 390, height: 844 }); + await page.setContent( + `
${ru ? "Комментарии отключены." : "Comments are turned off."}
+
+

${ru ? "Комментарии RYD" : "RYD comments"}

+
+

${ru ? "Обсуждение сообщества Return YouTube Dislike." : "A community discussion hosted by Return YouTube Dislike."}

+

${ru ? "Эти комментарии не связаны с комментариями YouTube." : "These comments are separate from YouTube."}

+ +
+
`, + { timeout: 5000 }, + ); + const panel = page.locator("#ryd-discussion-panel"); + const geometry = () => + page.evaluate(() => { + const rect = document.querySelector("#ryd-discussion-panel").getBoundingClientRect(); + return { + x: rect.x, + width: rect.width, + right: rect.right, + viewport: innerWidth, + nativeWidth: document.querySelector("#native-parent").getBoundingClientRect().width, + }; + }); + const oldSizing = await page.addStyleTag({ content: "#ryd-discussion-panel{max-inline-size:none}" }); + await panel.scrollIntoViewIfNeeded(); + const before = await geometry(); + assert.ok( + before.x < 0 || before.right > before.viewport, + "The native wide parent must reproduce the original overflow", + ); + await page.screenshot({ path: path.join(output, `${language}-before.png`), timeout: 5000 }); + await oldSizing.evaluate((node) => node.remove()); + await panel.scrollIntoViewIfNeeded(); + const after = await geometry(); + assert.equal(after.width, 342); + assert.ok(after.x >= 0 && after.right <= after.viewport, "The narrow watch panel must fit inside the viewport"); + assert.equal(after.nativeWidth, before.nativeWidth, "Native container geometry must remain unchanged"); + await page.screenshot({ path: path.join(output, `${language}-after.png`), timeout: 5000 }); + await page.setViewportSize({ width: 1536, height: 639 }); + const desktop = await geometry(); + assert.equal(desktop.width, desktop.nativeWidth, "Desktop watch panel must still fill its parent"); + results.push({ language, before, after, desktop, passed: true }); + } + await fs.writeFile( + path.join(output, "result.json"), + JSON.stringify({ kind: "hermetic-css-layout", results }, null, 2), + ); + return { output, results }; + } finally { + await browser.close(); + } +} + +if (require.main === module) + runWatchWidthSmoke() + .then((result) => process.stdout.write(`${JSON.stringify(result)}\n`)) + .catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); + +module.exports = { runWatchWidthSmoke }; diff --git a/Extensions/e2e/hermetic-api-contract.js b/Extensions/e2e/hermetic-api-contract.js index ac4e0ee..04a004b 100644 --- a/Extensions/e2e/hermetic-api-contract.js +++ b/Extensions/e2e/hermetic-api-contract.js @@ -1,6 +1,7 @@ const assert = require("node:assert/strict"); const API_METHODS_BY_PATH = Object.freeze({ + "/api/discussion/capabilities": Object.freeze(["GET"]), "/configs/selectors": Object.freeze(["GET"]), "/interact/confirmVote": Object.freeze(["POST"]), "/interact/vote": Object.freeze(["POST"]), diff --git a/Extensions/e2e/hermetic-artifact-smoke.js b/Extensions/e2e/hermetic-artifact-smoke.js index 717ed5e..f7ca855 100644 --- a/Extensions/e2e/hermetic-artifact-smoke.js +++ b/Extensions/e2e/hermetic-artifact-smoke.js @@ -619,6 +619,8 @@ async function startHermeticApiServer({ dislikes = 25, likes = 100 } = {}) { } let defaultBody; if (request.method === "GET" && url.pathname === "/configs/selectors") defaultBody = {}; + else if (request.method === "GET" && url.pathname === "/api/discussion/capabilities") + defaultBody = { enabled: false }; else if (request.method === "GET" && url.pathname === "/votes") { defaultBody = { dislikes, likes, rating: 4.5 }; } else if (request.method === "GET" && url.pathname === "/puzzle/registration") { diff --git a/Extensions/e2e/hermetic-artifact-smoke.spec.js b/Extensions/e2e/hermetic-artifact-smoke.spec.js index 2b1978c..abc45dc 100644 --- a/Extensions/e2e/hermetic-artifact-smoke.spec.js +++ b/Extensions/e2e/hermetic-artifact-smoke.spec.js @@ -277,6 +277,21 @@ test("the loopback backend records and rejects an OPTIONS request to an unknown } }); +test("legacy artifact fixtures explicitly disable discussion and still reject discussion writes", async () => { + const apiServer = await startHermeticApiServer(); + try { + const capabilities = await fetch(`${apiServer.origin}/api/discussion/capabilities`); + expect(capabilities.status).toBe(200); + expect(await capabilities.json()).toEqual({ enabled: false }); + expect(apiServer.unexpectedRequests).toHaveLength(0); + const mutation = await fetch(`${apiServer.origin}/api/discussion/videos/abcdefghijk/comments`, { method: "POST" }); + expect(mutation.status).toBe(404); + expect(apiServer.unexpectedRequests).toHaveLength(1); + } finally { + await apiServer.close(); + } +}); + function createPreflightRoute(pathname, requestedMethod) { const request = { headers: () => ({ "access-control-request-method": requestedMethod }), diff --git a/package-lock.json b/package-lock.json index efe2f73..5601771 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "npm": "10.8.2" }, "dependencies": { + "@microsoft/signalr": "9.0.6", "country-code-lookup": "^0.1.3", "echarts": "^5.5.0", "topojson-client": "^3.1.0", @@ -2502,6 +2503,40 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/signalr": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-9.0.6.tgz", + "integrity": "sha512-DrhgzFWI9JE4RPTsHYRxh4yr+OhnwKz8bnJe7eIi7mLLjqhJpEb62CiUy/YbFvLqLzcGzlzz1QWgVAW0zyipMQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@microsoft/signalr/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -2985,7 +3020,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, "license": "MIT", "dependencies": { "event-target-shim": "^5.0.0" @@ -4296,7 +4330,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4317,6 +4350,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "dev": true, @@ -4427,6 +4469,40 @@ "bser": "2.1.1" } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/fetch-cookie/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/fetch-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/filemanager-webpack-plugin": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/filemanager-webpack-plugin/-/filemanager-webpack-plugin-9.0.1.tgz", @@ -6437,6 +6513,48 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "dev": true, @@ -6802,16 +6920,33 @@ "node": ">= 6" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "dev": true, @@ -6977,6 +7112,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.20.0", "dev": true, @@ -7214,6 +7355,12 @@ "randombytes": "^2.1.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -7890,6 +8037,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/us-atlas": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/us-atlas/-/us-atlas-3.0.1.tgz", diff --git a/package.json b/package.json index 5b0df50..89160e5 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ }, "homepage": "https://github.com/Anarios/return-youtube-dislike#readme", "dependencies": { + "@microsoft/signalr": "9.0.6", "country-code-lookup": "^0.1.3", "echarts": "^5.5.0", "topojson-client": "^3.1.0", diff --git a/webpack.config.js b/webpack.config.js index 6975a51..c7d5935 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -20,7 +20,7 @@ const ignorePatterns = [ ...entries.map((entry) => `**/${entry}.js`), ]; -const manifestTransform = (content, filename) => { +const manifestTransform = (content, filename, discussionTestOrigin = "") => { const filteredContent = content .toString() .split("\n") @@ -29,6 +29,12 @@ const manifestTransform = (content, filename) => { const manifestData = JSON.parse(filteredContent); manifestData.version = extensionVersion; + if (discussionTestOrigin) { + const testUrl = new URL(discussionTestOrigin); + const permission = `${testUrl.protocol}//${testUrl.hostname}/*`; + const permissionField = manifestData.manifest_version === 3 ? "host_permissions" : "permissions"; + manifestData[permissionField] = [...new Set([...(manifestData[permissionField] || []), permission])]; + } return JSON.stringify(manifestData, null, 2); }; @@ -103,6 +109,22 @@ class ExtensionBuildReceiptPlugin { module.exports = (env = {}, argv = {}) => { const liveTestBuild = env.liveTest === true || env.liveTest === "true"; + let discussionTestOrigin = ""; + if (env.discussionTestOrigin) { + if (!liveTestBuild) throw new Error("A local discussion origin requires the explicit live-test build."); + const origin = new URL(env.discussionTestOrigin); + if ( + !["http:", "https:"].includes(origin.protocol) || + !["127.0.0.1", "localhost", "[::1]"].includes(origin.hostname) || + origin.username || + origin.password || + origin.pathname !== "/" || + origin.search || + origin.hash + ) + throw new Error("The discussion test origin must be a loopback HTTP origin without credentials or a path."); + discussionTestOrigin = origin.origin; + } const liveBuildId = createLiveBuildId(liveTestBuild); const buildMode = argv.mode || "production"; @@ -127,6 +149,7 @@ module.exports = (env = {}, argv = {}) => { new webpack.DefinePlugin({ __RYD_LIVE_BUILD_ID__: JSON.stringify(liveBuildId), __RYD_LIVE_TEST_BUILD__: JSON.stringify(liveTestBuild), + __RYD_DISCUSSION_TEST_ORIGIN__: JSON.stringify(discussionTestOrigin), }), ...(liveTestBuild ? [ @@ -151,7 +174,7 @@ module.exports = (env = {}, argv = {}) => { { from: "./Extensions/combined/manifest-chrome.json", to: "./chrome/manifest.json", - transform: manifestTransform, + transform: (content, filename) => manifestTransform(content, filename, discussionTestOrigin), }, { from: "./Extensions/combined", @@ -164,7 +187,7 @@ module.exports = (env = {}, argv = {}) => { { from: "./Extensions/combined/manifest-firefox.json", to: "./firefox/manifest.json", - transform: manifestTransform, + transform: (content, filename) => manifestTransform(content, filename, discussionTestOrigin), }, { from: "./Extensions/combined", @@ -177,7 +200,7 @@ module.exports = (env = {}, argv = {}) => { { from: "./Extensions/combined/manifest-safari.json", to: "./safari/manifest.json", - transform: manifestTransform, + transform: (content, filename) => manifestTransform(content, filename, discussionTestOrigin), }, ], }), diff --git a/webpack.discussion.spec.js b/webpack.discussion.spec.js new file mode 100644 index 0000000..0ad2119 --- /dev/null +++ b/webpack.discussion.spec.js @@ -0,0 +1,32 @@ +const fs = require("node:fs"); +const createBuild = require("./webpack.config"); + +function builtManifest(env) { + const build = createBuild(env, { mode: "production" }); + const copy = build.plugins.find((plugin) => plugin.constructor.name === "CopyPlugin"); + const transform = copy.patterns.find((pattern) => pattern.to === "./chrome/manifest.json").transform; + return JSON.parse(transform(fs.readFileSync("Extensions/combined/manifest-chrome.json"), "manifest-chrome.json")); +} + +test("production packaging never adds a local discussion host", () => { + const manifest = builtManifest({}); + expect(manifest.host_permissions).toEqual(["*://*.youtube.com/*", "*://returnyoutubedislikeapi.com/*"]); + expect(manifest.minimum_chrome_version).toBe("116"); + expect(manifest.permissions).toContain("alarms"); + expect(manifest.web_accessible_resources[0].resources).toContain("comments-bridge.js"); +}); + +test("local discussion host is available only in an explicit isolated test build", () => { + expect(() => createBuild({ discussionTestOrigin: "http://127.0.0.1:5099" })).toThrow(/explicit live-test/); + for (const origin of [ + "https://example.com", + "http://localhost/path", + "http://user@localhost", + "http://localhost?token=secret", + ]) { + expect(() => createBuild({ liveTest: true, discussionTestOrigin: origin })).toThrow(/loopback HTTP origin/); + } + expect(builtManifest({ liveTest: true, discussionTestOrigin: "http://127.0.0.1:5099" }).host_permissions).toContain( + "http://127.0.0.1/*", + ); +});