From 131396f7ac21cea7d6ebdb1217e3a0b2e280cd46 Mon Sep 17 00:00:00 2001 From: Omar Alshaker Date: Tue, 23 Jan 2024 16:21:17 +0100 Subject: [PATCH] Revert "Revert "Transfer Verbum app into into jetpack-mu-wpcom"" --- pnpm-lock.yaml | 97 ++- .../packages/jetpack-mu-wpcom/.eslintrc.js | 5 + .../add-verbum-comment-into-jetpack-mu-wpcom | 4 + .../packages/jetpack-mu-wpcom/composer.json | 2 +- .../packages/jetpack-mu-wpcom/package.json | 18 +- .../src/class-jetpack-mu-wpcom.php | 42 +- .../src/features/verbum-comments/README.md | 135 ++++ .../assets/class-verbum-gutenberg-editor.php | 130 ++++ .../class-wpcom-rest-api-v2-verbum-auth.php | 80 +++ .../class-wpcom-rest-api-v2-verbum-oembed.php | 77 +++ .../verbum-comments/assets/dynamic-loader.js | 24 + .../verbum-comments/class-verbum-comments.php | 594 ++++++++++++++++++ .../verbum-comments/playwright.config.ts | 81 +++ .../EmailForm/email-form-cookie-consent.tsx | 31 + .../src/components/EmailForm/index.tsx | 177 ++++++ .../src/components/EmailForm/style.scss | 71 +++ .../src/components/FrequencyToggle/index.tsx | 60 ++ .../src/components/FrequencyToggle/style.scss | 92 +++ .../components/SimpleSubscribeModal/index.tsx | 114 ++++ .../SimpleSubscribeModal/logged-in.tsx | 75 +++ .../SimpleSubscribeModal/logged-out.tsx | 110 ++++ .../SimpleSubscribeModal/style.scss | 151 +++++ .../subscription-modal.tsx | 67 ++ .../src/components/ToggleControl/index.tsx | 31 + .../src/components/ToggleControl/style.scss | 91 +++ .../src/components/comment-footer.tsx | 40 ++ .../src/components/comment-input-field.tsx | 117 ++++ .../src/components/comment-message.tsx | 21 + .../src/components/custom-loading-spinner.tsx | 3 + .../src/components/editor-placeholder.tsx | 36 ++ .../src/components/email-frequency-group.tsx | 81 +++ .../src/components/logged-in.tsx | 150 +++++ .../src/components/logged-out.tsx | 139 ++++ .../src/components/new-comment-email.tsx | 32 + .../src/components/new-posts-email.tsx | 55 ++ .../components/new-posts-notifications.tsx | 37 ++ .../src/components/settings-button.tsx | 69 ++ .../verbum-comments/src/components/types.d.ts | 24 + .../src/hooks/useSocialLogin.tsx | 115 ++++ .../src/hooks/useSubscriptionApi.tsx | 148 +++++ .../src/features/verbum-comments/src/i18n.ts | 13 + .../verbum-comments/src/images/facebook.tsx | 12 + .../verbum-comments/src/images/icons.tsx | 95 +++ .../verbum-comments/src/images/index.ts | 4 + .../verbum-comments/src/images/mail.tsx | 16 + .../verbum-comments/src/images/wordpress.tsx | 12 + .../features/verbum-comments/src/index.tsx | 250 ++++++++ .../features/verbum-comments/src/state.tsx | 99 +++ .../features/verbum-comments/src/style.scss | 562 +++++++++++++++++ .../features/verbum-comments/src/types.tsx | 104 +++ .../src/features/verbum-comments/src/utils.ts | 254 ++++++++ .../tests/00_confirm_sandboxed.test.ts | 7 + .../author_must_fill_name_and_email.test.ts | 33 + .../atomic/open_comments_for_everyone.test.ts | 32 + .../author_must_fill_name_and_email.test.ts | 28 + .../simple/open_comments_for_everyone.test.ts | 22 + ...egistered_and_logged_in_to_comment.test.ts | 40 ++ .../features/verbum-comments/tests/sites.ts | 18 + .../features/verbum-comments/tests/utils.ts | 86 +++ .../features/verbum-comments/tsconfig.json | 14 + .../jetpack-mu-wpcom/verbum.webpack.config.js | 99 +++ .../jetpack-mu-wpcom/webpack.config.js | 88 +-- .../add-verbum-comment-into-jetpack-mu-wpcom | 5 + .../plugins/mu-wpcom-plugin/composer.json | 2 +- .../plugins/mu-wpcom-plugin/composer.lock | 4 +- .../mu-wpcom-plugin/mu-wpcom-plugin.php | 2 +- projects/plugins/mu-wpcom-plugin/package.json | 2 +- 67 files changed, 5271 insertions(+), 58 deletions(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-verbum-comment-into-jetpack-mu-wpcom create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/README.md create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-verbum-gutenberg-editor.php create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-wpcom-rest-api-v2-verbum-auth.php create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-wpcom-rest-api-v2-verbum-oembed.php create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/dynamic-loader.js create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/class-verbum-comments.php create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/playwright.config.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/email-form-cookie-consent.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/index.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/style.scss create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/FrequencyToggle/index.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/FrequencyToggle/style.scss create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/index.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/logged-in.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/logged-out.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/style.scss create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/subscription-modal.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/ToggleControl/index.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/ToggleControl/style.scss create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-footer.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-input-field.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-message.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/custom-loading-spinner.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/editor-placeholder.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/email-frequency-group.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/logged-in.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/logged-out.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-comment-email.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-posts-email.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-posts-notifications.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/settings-button.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/types.d.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/hooks/useSocialLogin.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/hooks/useSubscriptionApi.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/i18n.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/facebook.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/icons.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/index.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/mail.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/wordpress.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/index.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/state.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/style.scss create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/types.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/utils.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/00_confirm_sandboxed.test.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/atomic/author_must_fill_name_and_email.test.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/atomic/open_comments_for_everyone.test.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/author_must_fill_name_and_email.test.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/open_comments_for_everyone.test.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/user_must_be_registered_and_logged_in_to_comment.test.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/sites.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/utils.ts create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tsconfig.json create mode 100644 projects/packages/jetpack-mu-wpcom/verbum.webpack.config.js create mode 100644 projects/plugins/mu-wpcom-plugin/changelog/add-verbum-comment-into-jetpack-mu-wpcom diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b7c2d9918e93..90afd1b2febdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1938,6 +1938,9 @@ importers: '@automattic/typography': specifier: 1.0.0 version: 1.0.0 + '@preact/signals': + specifier: ^1.2.2 + version: 1.2.2(preact@10.19.3) '@sentry/browser': specifier: 7.80.1 version: 7.80.1 @@ -1949,7 +1952,7 @@ importers: version: 4.39.0 '@wordpress/components': specifier: 25.14.0 - version: 25.14.0(react-dom@18.2.0)(react@18.2.0) + version: 25.14.0(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0) '@wordpress/data': specifier: 9.18.0 version: 9.18.0(react@18.2.0) @@ -1964,10 +1967,16 @@ importers: version: 4.48.0 '@wordpress/plugins': specifier: 6.16.0 - version: 6.16.0(react-dom@18.2.0)(react@18.2.0) + version: 6.16.0(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0) '@wordpress/url': specifier: 3.49.0 version: 3.49.0 + preact: + specifier: ^10.13.1 + version: 10.19.3 + wpcom-proxy-request: + specifier: ^7.0.3 + version: 7.0.5 optionalDependencies: react: specifier: ^18.2.0 @@ -1982,9 +1991,27 @@ importers: '@babel/core': specifier: 7.23.5 version: 7.23.5 + '@babel/plugin-transform-react-jsx': + specifier: 7.23.4 + version: 7.23.4(@babel/core@7.23.5) '@babel/preset-react': specifier: 7.23.3 version: 7.23.3(@babel/core@7.23.5) + '@types/node': + specifier: ^20.4.2 + version: 20.10.7 + '@types/react': + specifier: ^18.2.28 + version: 18.2.33 + '@types/react-dom': + specifier: 18.2.0 + version: 18.2.0 + babel-plugin-transform-rename-properties: + specifier: 0.1.0 + version: 0.1.0(@babel/core@7.23.5) + copy-webpack-plugin: + specifier: 11.0.0 + version: 11.0.0(webpack@5.76.0) sass: specifier: 1.64.1 version: 1.64.1 @@ -5631,6 +5658,12 @@ packages: dependencies: regenerator-runtime: 0.14.1 + /@babel/runtime@7.23.8: + resolution: {integrity: sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + /@babel/template@7.22.15: resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==} engines: {node: '>=6.9.0'} @@ -11326,6 +11359,12 @@ packages: resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} dev: true + /@types/react-dom@18.2.0: + resolution: {integrity: sha512-8yQrvS6sMpSwIovhPOwfyNf2Wz6v/B62LFSVYQ85+Rq3tLsBIG7rP5geMxaijTUxSkrO6RzN/IRuIAADYQsleA==} + dependencies: + '@types/react': 18.2.33 + dev: true + /@types/react-dom@18.2.14: resolution: {integrity: sha512-V835xgdSVmyQmI1KLV2BEIUgqEuinxp9O4G6g3FqO/SqLac049E53aysv0oEFD2kHfejeKU+ZqL2bcFWj9gLAQ==} dependencies: @@ -11888,7 +11927,7 @@ packages: resolution: {integrity: sha512-87GhllJcdlxqLugQUx/hL+PE4z7Aqf+AFs8CgzN5/V7INq9IFlIjcbm5TpI4WrGVDSa2puA0tMrjhR/FWXF3NQ==} engines: {node: '>=12'} dependencies: - '@babel/runtime': 7.23.5 + '@babel/runtime': 7.23.8 '@wordpress/i18n': 4.48.0 '@wordpress/url': 3.49.0 @@ -12990,7 +13029,7 @@ packages: resolution: {integrity: sha512-vFmjpq/XN2bYgz67BS2ZC0n4P1FZUi0UPv8PTMKK+dzCPhQRYrJb8DRhBafwu2mXRzw4rO7vmVTCNJQM6xVObQ==} engines: {node: '>=12'} dependencies: - '@babel/runtime': 7.23.5 + '@babel/runtime': 7.23.8 /@wordpress/html-entities@3.48.0: resolution: {integrity: sha512-La4ErNVZPV6kt1jj0Thay/YlR16GkAOYZlaFXtKeYgwiyOzMsIVlghpExk7rvEj2ygRMeWoMSU0+cbciv6QeBg==} @@ -13003,7 +13042,7 @@ packages: engines: {node: '>=12'} hasBin: true dependencies: - '@babel/runtime': 7.23.5 + '@babel/runtime': 7.23.8 '@wordpress/hooks': 3.48.0 gettext-parser: 1.4.0 memize: 2.1.0 @@ -13583,7 +13622,7 @@ packages: resolution: {integrity: sha512-AARE9FMGEf3bf/EKb+OhFivgps38s5fRGFMqeHImP8JvKAt6xc7Q6IrpFOTXkI2BOWA4ERK//PAygR8PR5bgVA==} engines: {node: '>=12'} dependencies: - '@babel/runtime': 7.23.5 + '@babel/runtime': 7.23.8 remove-accents: 0.5.0 /@wordpress/viewport@5.25.0(react@18.2.0): @@ -14318,6 +14357,14 @@ packages: - supports-color dev: true + /babel-plugin-transform-rename-properties@0.1.0(@babel/core@7.23.5): + resolution: {integrity: sha512-uBSvAC8qH81TsXsWYD20ME4qg9ICflMLjsNfeuSxrKkJkym4Riqne1BrCCW15lcM/t9lfEiz4FJbVeUoaOVGWA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.23.5 + dev: true + /babel-preset-current-node-syntax@1.0.1(@babel/core@7.23.5): resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} peerDependencies: @@ -14517,6 +14564,10 @@ packages: engines: {node: '>=6'} dev: true + /builtin-status-codes@2.0.0: + resolution: {integrity: sha512-8KPx+JfZWi0K8L5sycIOA6/ZFZbaFKXDeUIXaqwUnhed1Ge1cB0wyq+bNDjKnL9AR2Uj3m/khkF6CDolsyMitA==} + dev: false + /bytes-iec@3.1.1: resolution: {integrity: sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==} engines: {node: '>= 0.8'} @@ -14554,6 +14605,11 @@ packages: pascal-case: 3.1.2 tslib: 2.5.0 + /camelcase@1.2.1: + resolution: {integrity: sha512-wzLkDa4K/mzI1OSITC+DUyjgIl/ETNHE9QvYgy6J6Jvqyyz4C0Xfd+lQhb19sX2jMpZV4IssUn0VDVmglV+s4g==} + engines: {node: '>=0.10.0'} + dev: false + /camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -18780,6 +18836,7 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + requiresBuild: true /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} @@ -19281,6 +19338,7 @@ packages: /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + requiresBuild: true dependencies: js-tokens: 4.0.0 @@ -21409,6 +21467,7 @@ packages: /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + requiresBuild: true peerDependencies: react: ^18.2.0 dependencies: @@ -21842,6 +21901,7 @@ packages: /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} + requiresBuild: true dependencies: loose-envify: 1.4.0 @@ -22019,7 +22079,7 @@ packages: /regenerator-transform@0.15.2: resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==} dependencies: - '@babel/runtime': 7.23.5 + '@babel/runtime': 7.23.8 /regexp.prototype.flags@1.5.1: resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==} @@ -23923,6 +23983,12 @@ packages: dependencies: tslib: 2.5.0 + /uppercamelcase@1.1.0: + resolution: {integrity: sha512-C7YEMvhgrvTEKEEVqA7LXNID/1TvvIwYZqNIKLquS6y/MGSkRQAav9LnTTILlC1RqUM8eTVBOe1U/fnB652PRA==} + dependencies: + camelcase: 1.2.1 + dev: false + /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: @@ -24490,11 +24556,28 @@ packages: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} dev: true + /wp-error@1.3.0: + resolution: {integrity: sha512-6Mn8fIBgWYgKJveRpB5oR+T9JEXxUawq5Om35ZE0yvCh5p3SQ+2OCH+KH39k0ZMxvNh9CI7LyfihtQH6itHbdQ==} + dependencies: + builtin-status-codes: 2.0.0 + uppercamelcase: 1.1.0 + dev: false + /wp-prettier@3.0.3: resolution: {integrity: sha512-X4UlrxDTH8oom9qXlcjnydsjAOD2BmB6yFmvS4Z2zdTzqqpRWb+fbqrH412+l+OUXmbzJlSXjlMFYPgYG12IAA==} engines: {node: '>=14'} hasBin: true + /wpcom-proxy-request@7.0.5: + resolution: {integrity: sha512-IJ0AYAxA6zOaIt8mD3aKDcaTQrOQiaV41/AKU42fL+GkH2chV7MGYf02hQrtMifLAjdAg0Xwm7IlGCfv+cy9XQ==} + dependencies: + debug: 4.3.4 + uuid: 8.3.2 + wp-error: 1.3.0 + transitivePeerDependencies: + - supports-color + dev: false + /wrap-ansi@3.0.1: resolution: {integrity: sha512-iXR3tDXpbnTpzjKSylUJRkLuOrEC7hwEB221cgn6wtF8wpmz28puFXAEfPT5zrjM3wahygB//VuWEr1vTkDcNQ==} engines: {node: '>=4'} diff --git a/projects/packages/jetpack-mu-wpcom/.eslintrc.js b/projects/packages/jetpack-mu-wpcom/.eslintrc.js index f7f2667eff363..6fa5bb2795b96 100644 --- a/projects/packages/jetpack-mu-wpcom/.eslintrc.js +++ b/projects/packages/jetpack-mu-wpcom/.eslintrc.js @@ -14,5 +14,10 @@ module.exports = { allowedTextDomain: 'jetpack-mu-wpcom', }, ], + 'testing-library/prefer-screen-queries': 'off', + 'react/jsx-no-bind': 'off', + // Not needed for TypeScript. + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-returns-type': 'off', }, }; diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-verbum-comment-into-jetpack-mu-wpcom b/projects/packages/jetpack-mu-wpcom/changelog/add-verbum-comment-into-jetpack-mu-wpcom new file mode 100644 index 0000000000000..ba2b7abb8bfe0 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-verbum-comment-into-jetpack-mu-wpcom @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add Verbum Comments in jetpack-mu-wpcom plugin diff --git a/projects/packages/jetpack-mu-wpcom/composer.json b/projects/packages/jetpack-mu-wpcom/composer.json index a51d5caed9ed9..91ad6babc4e09 100644 --- a/projects/packages/jetpack-mu-wpcom/composer.json +++ b/projects/packages/jetpack-mu-wpcom/composer.json @@ -49,7 +49,7 @@ }, "autotagger": true, "branch-alias": { - "dev-trunk": "5.9.x-dev" + "dev-trunk": "5.10.x-dev" }, "textdomain": "jetpack-mu-wpcom", "version-constants": { diff --git a/projects/packages/jetpack-mu-wpcom/package.json b/projects/packages/jetpack-mu-wpcom/package.json index f0fb3c0fc04d9..bbc2b5d486fe6 100644 --- a/projects/packages/jetpack-mu-wpcom/package.json +++ b/projects/packages/jetpack-mu-wpcom/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@automattic/jetpack-mu-wpcom", - "version": "5.9.0", + "version": "5.10.0-alpha", "description": "Enhances your site with features powered by WordPress.com", "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/jetpack-mu-wpcom/#readme", "bugs": { @@ -19,6 +19,7 @@ "build-js": "pnpm clean && webpack", "build-production": "echo 'Not implemented.'", "build-production-js": "NODE_ENV=production BABEL_ENV=production pnpm build-js", + "lint": "eslint --ext .js,.jsx,.cjs,.mjs,.ts,.tsx .", "clean": "rm -rf src/build/" }, "optionalDependencies": { @@ -28,7 +29,13 @@ "devDependencies": { "@automattic/jetpack-webpack-config": "workspace:*", "@babel/core": "7.23.5", + "@babel/plugin-transform-react-jsx": "7.23.4", "@babel/preset-react": "7.23.3", + "@types/node": "^20.4.2", + "@types/react": "^18.2.28", + "@types/react-dom": "18.2.0", + "babel-plugin-transform-rename-properties": "0.1.0", + "copy-webpack-plugin": "11.0.0", "sass": "1.64.1", "sass-loader": "12.4.0", "typescript": "^5.0.4", @@ -37,15 +44,20 @@ }, "dependencies": { "@automattic/typography": "1.0.0", + "@preact/signals": "^1.2.2", "@sentry/browser": "7.80.1", "@wordpress/api-fetch": "6.45.0", "@wordpress/base-styles": "4.39.0", "@wordpress/components": "25.14.0", - "@wordpress/dom-ready": "^3.48.0", "@wordpress/data": "9.18.0", + "@wordpress/dom-ready": "^3.48.0", "@wordpress/hooks": "3.48.0", "@wordpress/i18n": "4.48.0", "@wordpress/plugins": "6.16.0", - "@wordpress/url": "3.49.0" + "@wordpress/url": "3.49.0", + "preact": "^10.13.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "wpcom-proxy-request": "^7.0.3" } } diff --git a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php index 5dfcdd7ea8cdc..a21279c60964f 100644 --- a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php +++ b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php @@ -13,7 +13,7 @@ * Jetpack_Mu_Wpcom main class. */ class Jetpack_Mu_Wpcom { - const PACKAGE_VERSION = '5.9.0'; + const PACKAGE_VERSION = '5.10.0-alpha'; const PKG_DIR = __DIR__ . '/../'; const BASE_DIR = __DIR__ . '/'; const BASE_FILE = __FILE__; @@ -42,6 +42,8 @@ public static function init() { add_action( 'plugins_loaded', array( __CLASS__, 'load_first_posts_stream_helpers' ) ); + add_action( 'plugins_loaded', array( __CLASS__, 'load_verbum_comments' ) ); + // Unified navigation fix for changes in WordPress 6.2. add_action( 'admin_enqueue_scripts', array( __CLASS__, 'unbind_focusout_on_wp_admin_bar_menu_toggle' ) ); @@ -218,4 +220,42 @@ public static function load_marketplace_products_updater() { public static function load_first_posts_stream_helpers() { require_once __DIR__ . '/features/first-posts-stream/first-posts-stream-helpers.php'; } + + /** + * Determine whether to disable the comment experience. + * + * @param int $blog_id The blog ID. + * @return boolean + */ + private function should_disable_comment_experience( $blog_id ) { + require_once WP_CONTENT_DIR . '/lib/wpforteams/functions.php'; + // This covers both P2 and P2020 themes. + $is_p2 = str_contains( get_stylesheet(), 'pub/p2' ) || function_exists( '\WPForTeams\is_wpforteams_site' ) && is_wpforteams_site( $blog_id ); + $is_forums = str_contains( get_stylesheet(), 'a8c/supportforums' ); // Not in /forums + + // Don't load any comment experience in the Reader, GlotPress, wp-admin, or P2. + return ( 1 === $blog_id || TRANSLATE_BLOG_ID === $blog_id || is_admin() || $is_p2 || $is_forums ); + } + + /** + * Load Verbum Comments. + */ + public static function load_verbum_comments() { + if ( class_exists( 'Verbum_Comments' ) ) { + return; + } else { + $blog_id = get_current_blog_id(); + // Jetpack loads Verbum though an iframe from jetpack.wordpress.com. + // So we need to check the GET request for the blogid. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['blogid'] ) ) { + $blog_id = intval( $_GET['blogid'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + if ( should_disable_comment_experience( $blog_id ) ) { + return false; + } + require_once __DIR__ . '/build/verbum-comments/class-verbum-comments.php'; + new \Automattic\Jetpack\Verbum_Comments(); + } + } } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/README.md b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/README.md new file mode 100644 index 0000000000000..b731ce4d71543 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/README.md @@ -0,0 +1,135 @@ +# Verbum Comments + +A word, discourse, or reason; connoting an appeal to rational discourse. + +## Description + +Verbum is the comment UX for WordPress.com and Jetpack and is the successor to [Highlander Comments](../highlander-comments/highlander-comments.php). It is built with [Preact](https://preactjs.com/) and uses [Vite](https://vitejs.dev/) for bundling. This was chosen to limit the size of the bundle and minimize impact on the page performance metrics. + +## Technical Details + +### Basics + +Page performance impact was highly considered in this plugin. For that reason Preact was chosen to minimize the bundle size as the basic ReactDOM modules needed for rendering were very large. Preact is best utilized with Vite which compiles and bundles everything into the `dist` directory. + +From a developer experience it functions the same as React with Webpack. You can use all the typical npm scripts like `start`, `build`, and `lint`. + +Because we are using a Preact based plugin to manage the user experience, we overwrite and remove any of the basic WordPress comment section hooks. We remove everything that is not needed and output our plugin in place of the submit button. This also means that we do not utilize any of the default settings for the comment section that would allow others to overwrite them. These are typically used to change the wording on buttons and headings or inject your own custom components. If someone were to selfishly overwrite the submit button it would prevent the entire comment form from loading. + +With the introduction of block based themes and the site editor, not all themes come with the comment section added to the single-post template. If the template does not add the comment form they will need to add the block in order for Verbum to load. + +### Dynamic Loading + +Verbum does not load any scripts until the comment section is visible on the screen. This is done from [dynamic-loader](./dynamic-loader.js) using `IntersectionObserver` and [`WP_Enqueue_Dynamic_Script`](../wp-enqueue-dynamic-script.php). In the index.php the main script (dist/index.js) for the plugin is registered and dynamically enqueued rather than enqueuing as normal. Then when we decide we are ready to load we call the `loadScript()` method to inject and run the script. + +### Handling Login + +When the user chooses to log in via WordPress.com or Facebook, a separate pop-up is opened to the remote login url (r-login.wordpress.com). After the user succeeds in authenticating a `wpc_` cookie is added and the window is closed. Before the login, a nonce token is created for posting a comment. Once the user successfully logs in, that nonce is no longer valid as well as the logout URL which includes a nonce. To capture this new nonce, there is a tiny bit of JS that exists in `public.api/connect/index.php` that updates the nonce in the hidden input field and sets the new logout URL in the VerbumComments object. + +### Jetpack + +Currently when a user has enhanced comments enabled on Jetpack they will also get Verbum Comments on their site. Rather than injecting the plugin, Jetpack adds an iframe to `jetpack.wordpress.com` where the plugin is loaded and handles everything from there. Because of this we cannot use typical functions like `get_current_blog_id()` because in Jetpack we will retrieve the details for `jetpack.wordpress.com`. Jetpack passes all the relevant data through the GET request so it can be retrieved from there if needed. + +Most of the Jetpack logic can be found in `wp-content/mu-plugins/jetpack/class.jetpack-renderer.php` + +### Managing State +Our global state is defined in state.jsx. Whenever we need to define new state it should be done there, with the appropriate comment to describe it. We use preact [signal api](https://preactjs.com/guide/v10/signals/) to do this. + +You can define a signal with the `signal()` function which takes the default value of the state as argument. To access it, import it from the state file and use `signal.value` in your components. This creates a subscription which will automatically update your components when the signal's value has changed. + +When a signal's value depends on other pieces of the global state you can use the `computed` function. +```js +const todos = signal([ + { text: "Buy groceries", completed: true }, + { text: "Walk the dog", completed: false }, +]); + +// create a signal computed from other signals +const completed = computed(() => { + // When `todos` changes, this re-runs automatically: + return todos.value.filter(todo => todo.completed).length; +}); + +``` +To run arbitrary code in response to signal changes, we can use effect(fn). Similar to computed signals, effects track which signals are accessed and re-run their callback when those signals change. +```js +const name = signal("Jane"); +const surname = signal("Doe"); +const fullName = computed(() => `${name.value} ${surname.value}`); + +// Logs name every time it changes: +effect(() => console.log(fullName.value)); +// Logs: "Jane Doe" + +// Updating `name` updates `fullName`, which triggers the effect again: +name.value = "John"; +// Logs: "John Doe" +``` +You can destroy an effect and unsubscribe from all signals it accessed by calling the returned function. +```js +const dispose = effect(() => console.log(fullName.value)); +// Logs: "Jane Doe" + +// Destroy effect and subscriptions: +dispose(); + +``` +In case you want to use the signal's value without subscribing to it, use signal.peek(). +```js +const delta = signal(0); + +const count = signal(0); + +effect(() => { +// Update `count` without subscribing to `count`: +count.value = count.peek() + delta.value; +}); +``` + +## Development + +Verbum is built and managed from this repository, but is then deployed to WPCOM inside `wp-content/mu-plugins/verbum`. To keep these both in sync it is important to deploy your changes to WPCOM after merging any changes. Here is the workflow you can use. + +### Commands + +**In order for Verbum to sync properly to your sandbox you need to have `wpcom` set as a host in your SSH config pointed to your sandbox** + +* `npm run build` - Build Verbum. +* `npm run build:sync` - Build Verbum and sync to sandbox. +* `npm run start` - Build and watch Verbum. +* `npm run start:sync` - Build and watch Verbum, while syncing to sandbox. +* `npm run lint` - Look for lint issues. +* `npm run lint:fix` - Look for lint issues and fix easily fixable issues. +* `npm run build:editor` - Build the editor app inside `/editor`, necessary when changing the editor files. + +### After Merge - Deploy process + +Since this is not deployed to WPCOM automatically you need to manually deploy your changes with the following steps: + +1. `git checkout trunk && git reset --hard && git clean -fd` - After your merge make sure you have the freshest version on trunk. +2. `git checkout trunk && git reset --hard && git clean -fd` - Log in to your sandbox and do the same thing. +3. `npm run build:editor && npm run build:sync` - From Verbum run the sync command to move the latest version of Verbum to your sandbox. You can also use only `npm run build:sync` for non editor changes (it will be faster for Intel CPUs) +4. `arc diff --only` - From your sandbox create a new diff for WordPress.com +5. Your changes should have already been tested on WordPress.com before merging but check for smoke and failing test. +6. If there are any LINT issues or errors when deploying please be sure to update the REPO as well to avoid future breaking. +7. `arc land` - Land your changes +8. `deploy wpcom` - Deploy and done! + +### Testing + +#### Setup + +1. Please sandbox the following sites before running the tests + - jetpack.wordpress.com + - e2esiteopencommentstoeveryone.wordpress.com + - e2ecommentauthormustfilloutnameandemail.wordpress.com + - e2eusersmustberegisteredandloggedintocomment.wordpress.com + +2. Run `npx playwright install` to install the browser. +3. Run `npm i` to install the pre-push git hook. + +Now the tests will run on every push. + +The tests live in /tests folder. To run them, you can run `npm run e2e-tests`. + +If you want to watch the tests unfold, you can run `npx playwright test --ui`. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-verbum-gutenberg-editor.php b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-verbum-gutenberg-editor.php new file mode 100644 index 0000000000000..57ab457e6f691 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-verbum-gutenberg-editor.php @@ -0,0 +1,130 @@ + 'core/quote', + 'h1' => 'core/heading', + 'h2' => 'core/heading', + 'h3' => 'core/heading', + 'img' => 'core/image', + 'ul' => 'core/list', + 'ol' => 'core/list', + 'pre' => 'core/code', + ); + + foreach ( array_keys( $allowedtags ) as $tag ) { + if ( isset( $convert[ $tag ] ) ) { + $allowed_blocks[] = $convert[ $tag ]; + } + } + + return $allowed_blocks; + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-wpcom-rest-api-v2-verbum-auth.php b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-wpcom-rest-api-v2-verbum-auth.php new file mode 100644 index 0000000000000..1c85e6c9f5f59 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-wpcom-rest-api-v2-verbum-auth.php @@ -0,0 +1,80 @@ +namespace = 'wpcom/v2'; + $this->rest_base = '/verbum/auth'; + $this->wpcom_is_wpcom_only_endpoint = false; + $this->wpcom_is_site_specific_endpoint = false; + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Register the routes for the objects of the controller. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + $this->rest_base, + array( + 'show_in_index' => false, + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_auth' ), + 'permission_callback' => '__return_true', + ) + ); + } + + /** + * Authorize user based on their WordPress credentials or Facebook cookies. + * + * @return array|WP_Error + */ + public function get_auth() { + $user = wp_get_current_user(); + if ( $user->ID ) { + list( $wordpress_avatar_url ) = wpcom_get_avatar_url( $user->user_email, 60, '', true ); + return array( + 'account' => $user->user_login, + 'avatar' => $wordpress_avatar_url, + 'email' => $user->user_email, + 'link' => ( ! empty( $user->user_url ) ? esc_url_raw( $user->user_url ) : esc_url_raw( 'http://gravatar.com/' . $user->user_login ) ), + 'name' => ( ! empty( $user->display_name ) ? $user->display_name : $user->user_login ), + 'uid' => $user->ID, + 'service' => 'wordpress', + ); + } else { + $fb = \Automattic\Jetpack\Verbum_Comments::verify_facebook_identity(); + if ( ! is_wp_error( $fb ) ) { + return array( + 'account' => $fb->name, + 'avatar' => $fb->picture->data->url, + 'email' => $fb->email, + 'link' => esc_url_raw( 'http://gravatar.com/' . $fb->email ), + 'name' => $fb->name, + 'uid' => $user->id, + 'service' => 'facebook', + ); + } + } + return new \WP_Error( '403', 'Not Authorized', array( 'status' => 403 ) ); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Verbum_Auth' ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-wpcom-rest-api-v2-verbum-oembed.php b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-wpcom-rest-api-v2-verbum-oembed.php new file mode 100644 index 0000000000000..44a9ac0423e19 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/class-wpcom-rest-api-v2-verbum-oembed.php @@ -0,0 +1,77 @@ +namespace = 'wpcom/v2'; + $this->rest_base = '/verbum/embed'; + $this->wpcom_is_wpcom_only_endpoint = false; + $this->wpcom_is_site_specific_endpoint = false; + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Register the routes for the objects of the controller. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + $this->rest_base, + array( + 'show_in_index' => false, + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_embed_data' ), + 'permission_callback' => array( $this, 'permission_callback' ), + ) + ); + } + + /** + * Check if the user is authenticated. + * + * @param WP_REST_Request $request The request object. + * @return bool + */ + public function permission_callback( WP_REST_Request $request ) { + $nonce = $request->get_param( 'embed_nonce' ); + + return wp_verify_nonce( $nonce, 'embed_nonce' ); + } + + /** + * Get the embed data for the embed block. + * + * @param WP_REST_Request $request The request object. + * @return array|\WP_Error + */ + public function get_embed_data( WP_REST_Request $request ) { + $url = sanitize_url( $request->get_param( 'embed_url' ) ); + $instance = new WP_oEmbed(); + $embed_data = $instance->get_data( $url, array() ); + + // Return error if the embed data is empty. + // This matches the core response. + if ( false === $embed_data ) { + return new \WP_Error( 'oembed_invalid_url', get_status_header_desc( 404 ), array( 'status' => 404 ) ); + } + + return $embed_data; + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Verbum_oEmbed' ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/dynamic-loader.js b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/dynamic-loader.js new file mode 100644 index 0000000000000..4f123fd34001a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/assets/dynamic-loader.js @@ -0,0 +1,24 @@ +/* global WP_Enqueue_Dynamic_Script VerbumComments */ +window.addEventListener( 'DOMContentLoaded', function () { + // Lazy load the comment form when clicking the comment field + const commentForm = document.querySelector( '#commentform' ); + if ( commentForm ) { + // only load Verbum if the comment field is visible or the browser doesn't support IntersectionObserver + if ( window.IntersectionObserver ) { + new IntersectionObserver( function ( entries ) { + if ( entries.some( el => el.isIntersecting ) ) { + const startedLoadingAt = Date.now(); + + WP_Enqueue_Dynamic_Script.loadScript( 'verbum' ).then( () => { + const finishedLoadingAt = Date.now(); + + VerbumComments.fullyLoadedTime = finishedLoadingAt - startedLoadingAt; + } ); + this.disconnect(); + } + } ).observe( commentForm ); + } else { + WP_Enqueue_Dynamic_Script.loadScript( 'verbum' ); + } + } +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/class-verbum-comments.php b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/class-verbum-comments.php new file mode 100644 index 0000000000000..e335e0cfdb39d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/class-verbum-comments.php @@ -0,0 +1,594 @@ +blog_id = get_current_blog_id(); + + // Jetpack loads the app via an iframe, so we need to get the blog id from the query string. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['blogid'] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $this->blog_id = intval( $_GET['blogid'] ); + } + + // Selfishly remove everything from the existing comment form + add_filter( 'comment_form_field_comment', '__return_false', 11 ); + add_filter( 'comment_form_logged_in', '__return_empty_string' ); + add_filter( 'comment_form_defaults', array( $this, 'comment_form_defaults' ), 20 ); + remove_action( 'comment_form', 'subscription_comment_form' ); + remove_all_filters( 'comment_form_default_fields' ); + add_filter( 'comment_form_default_fields', array( $this, 'comment_form_default_fields' ) ); + add_action( 'clear_auth_cookie', array( $this, 'clear_fb_cookies' ) ); + + // Fix comment reply link when `comment_registration` is required. + add_filter( 'comment_reply_link', array( $this, 'comment_reply_link' ), 10, 4 ); + + // Add Verbum. + add_action( 'comment_form_must_log_in_after', array( $this, 'verbum_render_element' ) ); + add_filter( 'comment_form_submit_field', array( $this, 'verbum_render_element' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + + // Do things before the comment is accepted. + add_action( 'pre_comment_on_post', array( $this, 'check_comment_allowed' ) ); + add_action( 'pre_comment_on_post', array( $this, 'allow_logged_out_user_to_comment_as_external' ), 100 ); // Set priority high to run after check to make sure they are logged in to the external service. + add_filter( 'preprocess_comment', array( $this, 'verify_external_account' ), 0 ); + + // After the comment is saved, we add meta data to the comment. + add_action( 'comment_post', array( $this, 'add_verbum_meta_data' ) ); + + // Load the Gutenberg editor for comments. + if ( + $this->should_load_gutenberg_comments() + ) { + new \Verbum_Gutenberg_Editor(); + } + } + + /** + * Load the div where Verbum app is rendered. + */ + public function verbum_render_element() { + $color_scheme = get_blog_option( $this->blog_id, 'jetpack_comment_form_color_scheme' ); + $comment_url = site_url( '/wp-comments-post.php' ); + + if ( ! $color_scheme || '' === $color_scheme ) { + // Default to transparent because it is more adaptable than white or dark. + $color_scheme = 'transparent'; + } + + $verbum = '
' . $this->hidden_fields(); + + // If the blog requires login, Verbum need to be wrapped in a
to work. + // Verbum is given `mustLogIn` to handle the login flow. + if ( get_option( 'comment_registration' ) && ! is_user_logged_in() ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo "$verbum
"; + } else { + return $verbum; + } + } + + /** + * Enqueue Assets + */ + public function enqueue_assets() { + if ( + ! ( is_singular() && comments_open() ) + && ! ( is_front_page() && is_page() && comments_open() ) + ) { + return; + } + + $version_js = filemtime( __DIR__ . '/verbum-comments.js' ); + $version_css = filemtime( __DIR__ . '/verbum-comments.css' ); + $connect_url = site_url( '/public.api/connect/?action=request' ); + $primary_redirect = get_primary_redirect(); + + if ( strpos( $primary_redirect, '.wordpress.com' ) === false ) { + $connect_url = add_query_arg( 'domain', $primary_redirect, $connect_url ); + } + + // Enqueue styles + wp_enqueue_style( 'verbum', plugins_url( '/verbum-comments.css', __FILE__ ), array(), $version_css ); + + // Enqueue scripts + wp_register_script( + 'verbum', + plugins_url( 'verbum-comments.js', __FILE__ ), + array(), + $version_js, + array( + 'strategy' => 'defer', + 'in_footer' => true, + ) + ); + \WP_Enqueue_Dynamic_Script::enqueue_script( 'verbum' ); + + // Enqueue settings separately since the main script is dynamic. + // We need the VerbumComments object to be available before the main script is loaded. + wp_register_script( + 'verbum-settings', + false, + array(), + $version_js, + array( + 'strategy' => 'defer', + 'in_footer' => true, + ) + ); + + $blog_details = get_blog_details( $this->blog_id ); + $is_blog_atomic = is_blog_atomic( $blog_details ); + $is_blog_jetpack = is_blog_jetpack( $blog_details ); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $subscribe_to_blog = isset( $_GET['stb_enabled'] ) ? boolval( $_GET['stb_enabled'] ) : false; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $subscribe_to_comment = isset( $_GET['stc_enabled'] ) ? boolval( $_GET['stc_enabled'] ) : false; + + // If it is simple, we set it to true. Simple sites return inconsistent results. + if ( ! $is_blog_atomic && ! $is_blog_jetpack ) { + $subscribe_to_blog = true; + $subscribe_to_comment = true; + } + + // Jetpack Comments client side logged in user data + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $__get = stripslashes_deep( $_GET ); + $email_hash = isset( $__get['hc_useremail'] ) && is_string( $__get['hc_useremail'] ) ? $__get['hc_useremail'] : ''; + $jetpack_username = isset( $__get['hc_username'] ) && is_string( $__get['hc_username'] ) ? $__get['hc_username'] : ''; + $jetpack_user_id = isset( $__get['hc_userid'] ) && is_numeric( $__get['hc_userid'] ) ? (int) $__get['hc_userid'] : 0; + $jetpack_signature = isset( $__get['sig'] ) && is_string( $__get['sig'] ) ? $__get['sig'] : ''; + list( $jetpack_avatar ) = wpcom_get_avatar_url( "$email_hash@md5.gravatar.com" ); + $comment_registration_enabled = boolval( get_blog_option( $this->blog_id, 'comment_registration' ) ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $post_id = isset( $_GET['postid'] ) ? intval( $_GET['postid'] ) : get_queried_object_id(); + $locale = get_locale(); + + $vbe_cache_buster = filemtime( ABSPATH . '/widgets.wp.com/verbum-block-editor/build_meta.json' ); + + wp_add_inline_script( + 'verbum-settings', + 'window.VerbumComments = ' . wp_json_encode( + array( + 'Log in or provide your name and email to leave a reply.' => __( 'Log in or provide your name and email to leave a reply.', 'jetpack-mu-wpcom' ), + 'Receive web and mobile notifications for posts on this site.' => __( 'Receive web and mobile notifications for posts on this site.', 'jetpack-mu-wpcom' ), + 'Name' => __( 'Name', 'jetpack-mu-wpcom' ), + 'Email (address never made public)' => __( 'Email (address never made public)', 'jetpack-mu-wpcom' ), + 'Website (optional)' => __( 'Website (optional)', 'jetpack-mu-wpcom' ), + 'Leave a reply. (log in optional)' => __( 'Leave a reply. (log in optional)', 'jetpack-mu-wpcom' ), + 'Log in to leave a reply.' => __( 'Log in to leave a reply.', 'jetpack-mu-wpcom' ), + /* translators: %s is the name of the provider (WordPress, Facebook, Twitter) */ + 'Logged in via %s' => __( 'Logged in via %s', 'jetpack-mu-wpcom' ), + 'Log out' => __( 'Log out', 'jetpack-mu-wpcom' ), + 'Email' => __( 'Email', 'jetpack-mu-wpcom' ), + '(Address never made public)' => __( '(Address never made public)', 'jetpack-mu-wpcom'), // phpcs:ignore PEAR.Functions.FunctionCallSignature.SpaceBeforeCloseBracket + 'Instantly' => __( 'Instantly', 'jetpack-mu-wpcom' ), + 'Daily' => __( 'Daily', 'jetpack-mu-wpcom' ), + 'Reply' => __( 'Reply', 'jetpack-mu-wpcom' ), + 'WordPress' => __( 'WordPress', 'jetpack-mu-wpcom' ), + 'Weekly' => __( 'Weekly', 'jetpack-mu-wpcom' ), + 'Notify me of new posts' => __( 'Notify me of new posts', 'jetpack-mu-wpcom' ), + 'Email me new posts' => __( 'Email me new posts', 'jetpack-mu-wpcom' ), + 'Email me new comments' => __( 'Email me new comments', 'jetpack-mu-wpcom' ), + 'Cancel' => __( 'Cancel', 'jetpack-mu-wpcom' ), + 'Write a comment...' => __( 'Write a Comment...', 'jetpack-mu-wpcom' ), + 'Website' => __( 'Website', 'jetpack-mu-wpcom' ), + 'Optional' => __( 'Optional', 'jetpack-mu-wpcom' ), + /* translators: Success message of a modal when user subscribes */ + 'We\'ll keep you in the loop!' => __( 'We\'ll keep you in the loop!', 'jetpack-mu-wpcom' ), + 'Loading your comment...' => __( 'Loading your comment...', 'jetpack-mu-wpcom' ), + /* translators: %s is the name of the site */ + 'Discover more from' => sprintf( __( 'Discover more from %s', 'jetpack-mu-wpcom' ), get_bloginfo( 'name' ) ), + 'Subscribe now to keep reading and get access to the full archive.' => __( 'Subscribe now to keep reading and get access to the full archive.', 'jetpack-mu-wpcom' ), + 'Continue reading' => __( 'Continue reading', 'jetpack-mu-wpcom' ), + 'Never miss a beat!' => __( 'Never miss a beat!', 'jetpack-mu-wpcom' ), + 'Interested in getting blog post updates? Simply click the button below to stay in the loop!' => __( 'Interested in getting blog post updates? Simply click the button below to stay in the loop!', 'jetpack-mu-wpcom' ), + 'Enter your email address' => __( 'Enter your email address', 'jetpack-mu-wpcom' ), + 'Subscribe' => __( 'Subscribe', 'jetpack-mu-wpcom' ), + 'Comment sent successfully' => __( 'Comment sent successfully', 'jetpack-mu-wpcom' ), + 'Save my name, email, and website in this browser for the next time I comment.' => __( 'Save my name, email, and website in this browser for the next time I comment.', 'jetpack-mu-wpcom' ), + 'siteId' => $this->blog_id, + 'postId' => $post_id, + 'mustLogIn' => $comment_registration_enabled && ! is_user_logged_in(), + 'requireNameEmail' => boolval( get_blog_option( $this->blog_id, 'require_name_email' ) ), + 'commentRegistration' => $comment_registration_enabled, + 'connectURL' => $connect_url, + 'logoutURL' => html_entity_decode( wp_logout_url(), ENT_COMPAT ), + 'homeURL' => home_url( '/' ), + 'subscribeToBlog' => $subscribe_to_blog, + 'subscribeToComment' => $subscribe_to_comment, + 'isJetpackCommentsLoggedIn' => is_jetpack_comments() && is_jetpack_comments_user_logged_in(), + 'jetpackUsername' => $jetpack_username, + 'jetpackUserId' => $jetpack_user_id, + 'jetpackSignature' => $jetpack_signature, + 'jetpackAvatar' => $jetpack_avatar, + 'enableBlocks' => boolval( $this->should_load_gutenberg_comments() ), + 'enableSubscriptionModal' => boolval( $this->should_show_subscription_modal() ), + 'currentLocale' => $locale, + 'isJetpackComments' => is_jetpack_comments(), + 'allowedBlocks' => \Verbum_Gutenberg_Editor::get_allowed_blocks(), + 'embedNonce' => wp_create_nonce( 'embed_nonce' ), + 'verbumBundleUrl' => plugins_url( 'dist/index.js', __FILE__ ), + 'isRTL' => is_rtl( $locale ), + 'vbeCacheBuster' => $vbe_cache_buster, + ) + ), + 'before' + ); + + wp_enqueue_script( 'verbum-settings' ); + + wp_enqueue_script( + 'verbum-dynamic-loader', + plugins_url( 'assets/dynamic-loader.js', __FILE__ ), + array(), + $version_js, + array( + 'strategy' => 'defer', + 'in_footer' => true, + ) + ); + } + + /** + * Remove some of the default comment_form args because they are not needed. + * + * @param array $args - The default comment form arguments. + */ + public function comment_form_defaults( $args ) { + return array_merge( + $args, + array( + 'comment_field' => '', + 'must_log_in' => '', + 'logged_in_as' => '', + 'comment_notes_before' => '', + 'comment_notes_after' => '', + 'title_reply' => __( 'Leave a Reply', 'jetpack-mu-wpcom' ), + /* translators: % is the original posters name */ + 'title_reply_to' => __( 'Leave a Reply to %s', 'jetpack-mu-wpcom' ), + 'cancel_reply_link' => __( 'Cancel reply', 'jetpack-mu-wpcom' ), + ) + ); + } + + /** + * Set comment reply link. + * This is to fix the reply link when comment registration is required. + * + * @param string $reply_link - HTML for reply link. + * @param array $args - Default options for reply link. + * @param object $comment - Comment being replied to. + * @param object $post - PostID or WP_Post object comment is going to be displayed on. + */ + public function comment_reply_link( $reply_link, $args, $comment, $post ) { + // This is only necessary if comment_registration is required to post comments + if ( ! get_option( 'comment_registration' ) ) { + return $reply_link; + } + + $comment = get_comment( $comment ); + $respond_id = esc_attr( $args['respond_id'] ); + $add_below = esc_attr( $args['add_below'] ); + /* This is to accommodate some themes that add an SVG to the Reply link like twenty-seventeen. */ + $reply_text = wp_kses( + $args['reply_text'], + array( + 'svg' => array( + 'class' => true, + 'aria-hidden' => true, + 'aria-labelledby' => true, + 'role' => true, + 'xmlns' => true, + 'width' => true, + 'height' => true, + 'viewbox' => true, + ), + 'use' => array( + 'href' => true, + 'xlink:href' => true, + ), + ) + ); + $before_link = wp_kses( $args['before'], wp_kses_allowed_html( 'post' ) ); + $after_link = wp_kses( $args['after'], wp_kses_allowed_html( 'post' ) ); + + $reply_url = esc_url( add_query_arg( 'replytocom', $comment->comment_ID . '#' . $respond_id ) ); + + $link = <<$reply_text + $after_link +HTML; + + return $link; + } + + /** + * Loop through all available fields and remove them. + * + * @param array $fields - Default comment fields. + * @return array $fields with no HTML. + */ + public function comment_form_default_fields( $fields ) { + foreach ( $fields as $field => $html ) { + remove_all_filters( "comment_form_field_{$field}" ); + add_filter( "comment_form_field_{$field}", '__return_false', 100 ); + } + + return $fields; + } + + /** + * Clear FB comments on logout. wp-login.php doesn't clear these by default. + * + * @return void + */ + public function clear_fb_cookies() { + setcookie( 'wpc_fbc', ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, false, true ); + } + + /** + * Check Facebook token and return the user data. + */ + public static function verify_facebook_identity() { + $data = isset( $_COOKIE['wpc_fbc'] ) ? wp_parse_args( sanitize_text_field( wp_unslash( $_COOKIE['wpc_fbc'] ) ) ) : array(); + + if ( empty( $data['access_token'] ) ) { + return new \WP_Error( 'facebook', __( 'Error: your Facebook login has expired.', 'jetpack-mu-wpcom' ) ); + } + + // Make a new request using the access token we were given. + $request = wp_remote_get( 'https://graph.facebook.com/v6.0/me?fields=name,email,picture,id&access_token=' . rawurlencode( $data['access_token'] ) ); + if ( 200 !== wp_remote_retrieve_response_code( $request ) ) { + return new \WP_Error( 'facebook', __( 'Error: your Facebook login has expired.', 'jetpack-mu-wpcom' ) ); + } + + $body = wp_remote_retrieve_body( $request ); + $json = json_decode( $body ); + + if ( ! $body || ! $json ) { + return new \WP_Error( 'facebook', __( 'Error: your Facebook login has expired.', 'jetpack-mu-wpcom' ) ); + } + + return $json; + } + + /** + * Allows a logged out user to leave a comment as a facebook credentialed user. + * Overrides WordPress' core comment_registration option to treat the commenter as "registered" (verified) users. + */ + public function allow_logged_out_user_to_comment_as_external() { + $service = isset( $_POST['hc_post_as'] ) ? sanitize_text_field( wp_unslash( $_POST['hc_post_as'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked before saving comment + + if ( $service !== 'facebook' ) { + return; + } + + add_filter( 'pre_option_comment_registration', '__return_zero' ); + add_filter( 'pre_option_require_name_email', '__return_zero' ); + } + + /** + * Check if the comment is allowed by verifying the Facebook token. + * + * @param array $comment_data - The comment data. + * @return WP_Error|comment_data The comment data if the comment is allowed, or a WP_Error if not. + */ + public function verify_external_account( $comment_data ) { + $service = isset( $_POST['hc_post_as'] ) ? sanitize_text_field( wp_unslash( $_POST['hc_post_as'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked before saving comment + + if ( $service === 'facebook' ) { + $fb_comment_data = self::verify_facebook_identity(); + + if ( is_wp_error( $fb_comment_data ) ) { + wp_die( esc_html( $fb_comment_data->get_error_message() ) ); + } + + $comment_data['highlander'] = 'facebook'; + } + + return $comment_data; + } + + /** + * Verify nonce before accepting comment. + * + * @return WP_Error|void + */ + public function check_comment_allowed() { + // Don't check if we're using Jetpack Comments. + if ( is_jetpack_comments() ) { + return; + } + + // Check for Highlander Nonce. + if ( + isset( $_POST['highlander_comment_nonce'] ) && + wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['highlander_comment_nonce'] ), 'highlander_comment' ) ) + ) { + return; + } + + return new \WP_Error( 'verbum', __( 'Error: please try commenting again.', 'jetpack-mu-wpcom' ) ); + } + + /** + * Add all our custom fields to the comment meta after it is saved. + * + * @param int $comment_id The comment ID. + */ + public function add_verbum_meta_data( $comment_id ) { + $comment_meta = array(); + // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked before saving comment + $allowed_subscription_modal_statuses = array( 'showed', 'hidden_is_blog_member', 'hidden_jetpack', 'hidden_disabled', 'hidden_cookies_disabled', 'hidden_subscribe_not_enabled', 'hidden_already_subscribed', 'hidden_views_limit' ); + $hc_avatar = isset( $_POST['hc_avatar'] ) ? esc_url_raw( wp_unslash( $_POST['hc_avatar'] ) ) : ''; + $hc_userid = isset( $_POST['hc_foreign_user_id'] ) ? sanitize_text_field( wp_unslash( $_POST['hc_foreign_user_id'] ) ) : ''; + $service = isset( $_POST['hc_post_as'] ) ? sanitize_text_field( wp_unslash( $_POST['hc_post_as'] ) ) : ''; + $verbum_loaded_editor = isset( $_POST['verbum_loaded_editor'] ) ? sanitize_text_field( wp_unslash( $_POST['verbum_loaded_editor'] ) ) : ''; + $verbum_subscription_modal_show = isset( $_POST['verbum_show_subscription_modal'] ) && in_array( $_POST['verbum_show_subscription_modal'], $allowed_subscription_modal_statuses, true ) ? sanitize_text_field( wp_unslash( $_POST['verbum_show_subscription_modal'] ) ) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Missing -- nonce checked before saving comment + $allowed_comments_sources = array( 'gutenberg', 'textarea', 'textarea-slow-connection' ); + if ( in_array( $verbum_loaded_editor, $allowed_comments_sources, true ) ) { + bump_stats_extras( 'verbum-comment-editor', $verbum_loaded_editor ); + } + if ( $verbum_subscription_modal_show ) { + bump_stats_extras( 'verbum-subscription-modal', $verbum_subscription_modal_show ); + } + switch ( $service ) { + case 'facebook': + $comment_meta['hc_post_as'] = 'facebook'; + $comment_meta['hc_avatar'] = $hc_avatar; + $comment_meta['hc_foreign_user_id'] = $hc_userid; + + bump_stats_extras( 'verbum-comment-posted', 'facebook' ); + break; + + case 'wordpress': // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText + if ( 'wpcom' === wpcom_blog_site_id_label() ) { + do_action( 'highlander_wpcom_post_comment_bump_stat', $comment_id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + } + bump_stats_extras( 'verbum-comment-posted', 'wordpress' ); // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText + break; + + case 'jetpack': + if ( is_jetpack_comments() && is_jetpack_comments_user_logged_in() ) { + $comment_meta['hc_post_as'] = 'jetpack'; + $comment_meta['hc_avatar'] = check_and_return_post_string( 'hc_avatar' ); + $comment_meta['hc_foreign_user_id'] = check_and_return_post_string( 'hc_userid' ); + + bump_stats_extras( 'verbum-comment-posted', 'jetpack' ); + } else { + jetpack_comments_die( 'JPC_HIGHLANDER_ADD_COMMENT_META' ); + } + + break; + default: + if ( is_user_logged_in() ) { + bump_stats_extras( 'verbum-comment-posted', 'guest-logged-in' ); + } else { + bump_stats_extras( 'verbum-comment-posted', 'guest' ); + } + break; + } + + foreach ( $comment_meta as $key => $value ) { + add_comment_meta( $comment_id, $key, $value, true ); + } + } + + /** + * Get the hidden fields for the comment form. + */ + public function hidden_fields() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $post_id = isset( $_GET['postid'] ) ? intval( $_GET['postid'] ) : get_queried_object_id(); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $is_current_user_subscribed = isset( $_GET['is_current_user_subscribed'] ) ? intval( $_GET['is_current_user_subscribed'] ) : 0; + $nonce = wp_create_nonce( 'highlander_comment' ); + $hidden_fields = get_comment_id_fields( $post_id ) . ' + + '; + + if ( is_jetpack_comments() ) { + $hidden_fields .= ' + + + '; + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $jetpack_nonce = isset( $_GET['jetpack_comments_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['jetpack_comments_nonce'] ) ) : false; + if ( $jetpack_nonce ) { + $hidden_fields .= ''; + } + } + + return '
' . $hidden_fields . '
'; + } + + /*** + * Check if we should load the Gutenberg comments. + * + * Block should be carefully loaded to avoid Forums, P2, etc. + */ + public function should_load_gutenberg_comments() { + // Don't load when jetpack or atomic for now, it does not look cool on dark themes. + $is_jetpack_site = 522232 === get_current_blog_id(); + if ( $is_jetpack_site ) { + return false; + } + + $blog_id = verbum_get_blog_id(); + $e2e_tests = has_blog_sticker( 'a8c-e2e-test-blog', $blog_id ); + $has_blocks_flag = has_blog_sticker( 'verbum-block-comments', $blog_id ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $gutenberg_query_param = isset( $_GET['verbum_gutenberg'] ) ? intval( $_GET['verbum_gutenberg'] ) : null; + // This will release to 10% of sites. + $blog_in_10_percent = $blog_id % 100 >= 90; + // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $is_proxied = isset( $_SERVER['A8C_PROXIED_REQUEST'] ) + ? sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) ) + : defined( 'A8C_PROXIED_REQUEST' ) && A8C_PROXIED_REQUEST; + + // Check if the parameter is set and its value is either 0 or 1, if any random value is passed, it is ignored. + if ( $gutenberg_query_param !== null ) { + return $gutenberg_query_param === 1; + } + + return $has_blocks_flag || $e2e_tests || $blog_in_10_percent; + } + + /** + * Check if we should show the subscription modal. + */ + public function should_show_subscription_modal() { + $modal_enabled = get_option( 'jetpack_verbum_subscription_modal', true ); + return ! is_user_member_of_blog( '', $this->blog_id ) && $modal_enabled; + } + + /** + * Get the status of the subscription modal. + */ + public function subscription_modal_status() { + if ( is_user_member_of_blog( '', $this->blog_id ) ) { + return 'hidden_is_blog_member'; + } + if ( is_jetpack_comments() ) { + return 'hidden_jetpack'; + } + if ( ! get_option( 'jetpack_verbum_subscription_modal', true ) ) { + return 'hidden_disabled'; + } + return ''; + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/playwright.config.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/playwright.config.ts new file mode 100644 index 0000000000000..039b3fe251970 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/playwright.config.ts @@ -0,0 +1,81 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig( { + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !! process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + expect: { + timeout: 30000, + }, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + // Use FF because it respects the hosts file. + name: 'firefox', + use: { ...devices[ 'Desktop Firefox' ] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/email-form-cookie-consent.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/email-form-cookie-consent.tsx new file mode 100644 index 0000000000000..751f5daa6438e --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/email-form-cookie-consent.tsx @@ -0,0 +1,31 @@ +import { translate } from '../../i18n'; +import { shouldStoreEmailData } from '../../state'; +import { ToggleControl } from '../ToggleControl'; + +const handleChange = ( e: boolean ) => { + shouldStoreEmailData.value = e; +}; + +export const EmailFormCookieConsent = () => { + const label = ( +
+

+ { translate( + 'Save my name, email, and website in this browser for the next time I comment.' + ) } +

+
+ ); + + return ( +
+ +
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/index.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/index.tsx new file mode 100644 index 0000000000000..caf4bf3da46d7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/index.tsx @@ -0,0 +1,177 @@ +import { signal, effect, batch, computed } from '@preact/signals'; +import { useState, useEffect } from 'preact/hooks'; +import { translate } from '../../i18n'; +import { Name, Website, Email } from '../../images'; +import { mailLoginData, isMailFormInvalid, shouldStoreEmailData } from '../../state'; +import { classNames, getUserInfoCookie, isAuthRequired } from '../../utils'; +import { NewCommentEmail } from '../new-comment-email'; +import { NewPostsEmail } from '../new-posts-email'; +import { EmailFormCookieConsent } from './email-form-cookie-consent'; +import type { ChangeEvent } from 'preact/compat'; +import './style.scss'; + +interface EmailFormProps { + shouldShowEmailForm: boolean; +} + +const isValidEmail = signal( true ); +const isValidAuthor = signal( true ); +const userEmail = computed( () => mailLoginData.value.email || '' ); +const userName = computed( () => mailLoginData.value.author || '' ); +const userUrl = computed( () => mailLoginData.value.url || '' ); + +const setFormData = ( event: ChangeEvent< HTMLInputElement > ) => { + mailLoginData.value = { + ...mailLoginData.peek(), + [ event.currentTarget.name ]: event.currentTarget.value, + }; +}; + +const validateFormData = () => { + const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; + batch( () => { + isValidEmail.value = + Boolean( userEmail.value ) && Boolean( emailRegex.test( userEmail.value ) ); + isValidAuthor.value = Boolean( userName.value.length > 0 ); + } ); +}; + +export const EmailForm = ( { shouldShowEmailForm }: EmailFormProps ) => { + const { subscribeToComment, subscribeToBlog } = VerbumComments; + const [ emailNewComment, setEmailNewComment ] = useState( false ); + const [ emailNewPosts, setEmailNewPosts ] = useState( false ); + const [ deliveryFrequency, setDeliveryFrequency ] = useState( 'instantly' ); + const authRequired = isAuthRequired(); + const dispose = effect( () => { + const isValid = authRequired && isValidEmail.value && isValidAuthor.value; + isMailFormInvalid.value = ! isValid; + } ); + + useEffect( () => { + const userCookie = getUserInfoCookie(); + + if ( userCookie?.service === 'guest' ) { + mailLoginData.value = { + ...( userCookie?.email && { email: userCookie?.email } ), + ...( userCookie?.author && { + author: userCookie?.author, + } ), + ...( userCookie?.url && { url: userCookie?.url } ), + }; + + if ( userCookie?.email ) { + validateFormData(); + shouldStoreEmailData.value = true; + } + } + + return () => { + dispose(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + + return ( +
+ { shouldShowEmailForm && ( +
+
+ + + + + + { ( subscribeToComment || subscribeToBlog ) && ( +
+ { subscribeToBlog && ( + { + if ( change.type === 'frequency' ) { + setDeliveryFrequency( change.value ); + } else if ( change.type === 'subscribe' ) { + setEmailNewPosts( change.value ); + } + } } + isChecked={ emailNewPosts } + selectedOption={ deliveryFrequency } + /> + ) } + { subscribeToComment && ( + setEmailNewComment( ! emailNewComment ) } + isChecked={ emailNewComment } + disabled={ false } + /> + ) } +
+ ) } + +
+ + { emailNewComment && } + { emailNewPosts && ( + <> + + + + + ) } +
+
+
+ ) } +
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/style.scss new file mode 100644 index 0000000000000..4ab825605564c --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/EmailForm/style.scss @@ -0,0 +1,71 @@ +#comment-form__verbum .verbum-subscriptions .verbum-form +{ + .verbum-form__content { + // protect the button from style leaks from the site; reset all. + all: unset; + background-color: var(--verbum-content-background-color); + display: flex; + flex-direction: column; + border: solid 1px var(--verbum-border-color); + border-radius: 4px; + + .verbum__label { + all: unset; + position: relative; + height: 45px; + border-bottom: 1px solid var(--verbum-border-color); + + &:last-of-type { + border-bottom: none; + } + + svg { + position: absolute; + top: 0; + bottom: 0; + left: 22px; + margin: auto 0; + } + + input { + color: var(--verbum-font-color); + background-color: transparent; + width: 100%; + height: 100%; + padding-left: 48px; + border: none; + font-size: 14px; + box-sizing: border-box; + + &::placeholder { + color: var(--verbum-font-color); + opacity: .7; + } + + &.invalid-form-data { + border: solid 1px #e65054; + } + + } + } + + .verbum-subscriptions__options { + border-top: 1px solid var(--verbum-border-color); + padding: 16px 21px; + } + } + + .verbum-mail-form-cookie-consent { + margin-bottom: 16px; + + .verbum-toggle-control { + padding: 0 21px; + } + + .verbum-toggle-control__text { + font-size: 0.875rem; + font-weight: 500; + display: block; + } + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/FrequencyToggle/index.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/FrequencyToggle/index.tsx new file mode 100644 index 0000000000000..21fa457afc39a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/FrequencyToggle/index.tsx @@ -0,0 +1,60 @@ +import { Fragment } from 'preact'; +import './style.scss'; + +type FrequencyToggleProps = { + initialOptions: Option[]; + onChange?: ( deliveryFrequency: string ) => void; + selectedOption: string; + disabled?: boolean; + name: string; +}; + +type Option = { + value: string; + checked: boolean; + label: string; +}; + +/** + * Frequency toggle component. + * @param {FrequencyToggleProps} props - props + * @param {string} props.name - name of the radio group + * @param {Option[]} props.initialOptions - the options to pick one from + * @param {Function} props.onChange - callback when the selected option changes + * @param {Option[]} props.selectedOption - the currently selected option + * @param {boolean} props.disabled - whether the toggle is disabled + */ +export function FrequencyToggle( { + name = 'frequency-toggle', + initialOptions, + onChange, + selectedOption, + disabled, +}: FrequencyToggleProps ) { + return ( +
+
+ { initialOptions.map( ( option, index ) => ( + + onChange( option.value ) } + disabled={ disabled } + /> + + + ) ) } +
+
+ ); +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/FrequencyToggle/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/FrequencyToggle/style.scss new file mode 100644 index 0000000000000..03ec8366157c4 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/FrequencyToggle/style.scss @@ -0,0 +1,92 @@ +#respond .verbum-frequency-toggle { + margin-left: 46px; + width: max-content; + + fieldset.fieldset { + display: flex; + border: 1px solid transparent; + min-height: 36px; + color: #101517; + margin: 0; + padding: 0; + height: 36px; + border-radius: 8px; + + &:focus-within { + display: flex; + border: 1px solid #80bedd; + color: #101517; + margin: 0; + padding: 0; + height: 36px; + } + + label.label-wrapper { + // protect the button from style leaks from the site; reset all. + all: unset; + } + + input { + // protect the button from style leaks from the site; reset all. + all: unset; + width: 0; + height: 0; + position: absolute; + + &+label span { + border: 1px solid transparent; + box-shadow: none; + padding: 0 12px; + } + + &:not([disabled]) { + &:checked+label span { + border: 1px solid rgba(0, 0, 0, 0.08); + } + } + + &:checked+label span { + background-color: var(--verbum-content-background-color); + backdrop-filter: brightness(1.2); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1), 0 1px 1px rgba(0, 0, 0, 0.04); + } + } + + &:disabled input+label span { + background-color: transparent; + backdrop-filter: none; + color: var(--verbum-border-color); + box-shadow: none; + } + + label.label-wrapper { + background-color: var(--verbum-border-color); + padding: 2px 0; + cursor: pointer; + display: inline-flex; + border: 1px solid transparent; + + &:first-of-type { + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + padding-left: 2px; + } + + &:last-of-type { + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + padding-right: 2px; + } + } + + span { + display: flex; + align-items: center; + font-weight: 500; + font-size: 13px; + line-height: 18px; + border-radius: 4px; + transition: background-color 160ms linear 0s, color 160ms linear 0s, font-weight 60ms linear 0s; + } + } +} \ No newline at end of file diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/index.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/index.tsx new file mode 100644 index 0000000000000..27860a801456d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/index.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState, useRef } from 'preact/hooks'; +import { translate } from '../../i18n'; +import { userInfo, userLoggedIn, commentUrl } from '../../state'; +import { SimpleSubscribeModalProps } from '../../types'; +import { + getSubscriptionModalViewCount, + setSubscriptionModalViewCount, + shouldShowSubscriptionModal, + classNames, +} from '../../utils'; +import { SimpleSubscribeModalLoggedIn, SimpleSubscribeSetModalShowLoggedIn } from './logged-in'; +import { SimpleSubscribeModalLoggedOut } from './logged-out'; +import './style.scss'; + +export const SimpleSubscribeModal = ( { + setSubscribeModalStatus, + subscribeModalStatus, + closeModalHandler, + email, +}: SimpleSubscribeModalProps ) => { + const [ subscribeState, setSubscribeState ] = useState< + 'SUBSCRIBING' | 'LOADING' | 'SUBSCRIBED' + >(); + + const [ hasIframe, setHasIframe ] = useState( false ); + + const SimpleSubscribeModalComponent = ! userLoggedIn.value + ? SimpleSubscribeModalLoggedOut + : SimpleSubscribeModalLoggedIn; + + const modalContainerRef = useRef( null ); + + const closeModalStateHandler = () => { + setSubscribeState( 'LOADING' ); + closeModalHandler(); + }; + + const handleClose = ( event: MouseEvent ) => { + event.preventDefault(); + closeModalStateHandler(); + }; + + const handleOutsideClick = ( event: MouseEvent ) => { + // Check if the clicked element is the modal container itself + if ( modalContainerRef.current && modalContainerRef.current === event.target ) { + handleClose( event ); + } + }; + + useEffect( () => { + document.addEventListener( 'click', handleOutsideClick ); + + return () => { + document.removeEventListener( 'click', handleOutsideClick ); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + + if ( ! commentUrl.value ) { + // When not showing the modal, we check for modal conditions to show it. + // This is done to avoid subscriptionApi calls for logged out users. + if ( userLoggedIn.value ) { + return ( + + ); + } + + // If the user is logged out, we don't need to check is already subscribed. + setSubscribeModalStatus( shouldShowSubscriptionModal( false, 0 ) ); + return null; + } + + // We use the same logic as in the comment footer to know if the user is already subscribed. + if ( subscribeModalStatus !== 'showed' && commentUrl.value ) { + closeModalHandler(); + return null; + } + + // This is used to track how many times the modal was shown to the user. + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect( () => { + const userId = userInfo.value?.uid || 0; + const currentViewCount = getSubscriptionModalViewCount( userId ); + setSubscriptionModalViewCount( currentViewCount + 1, userId ); + }, [] ); + + if ( subscribeState === 'LOADING' ) { + return ( +
+
+

{ translate( 'Loading your comment...' ) }

+
+
+ ); + } + + return ( +
+
+ +
+
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/logged-in.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/logged-in.tsx new file mode 100644 index 0000000000000..bbb6a51ae64bf --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/logged-in.tsx @@ -0,0 +1,75 @@ +import useSubscriptionApi from '../../hooks/useSubscriptionApi'; +import { translate } from '../../i18n'; +import { subscriptionSettings, userInfo, commentUrl } from '../../state'; +import { SimpleSubscribeModalProps } from '../../types'; +import { shouldShowSubscriptionModal } from '../../utils'; +import SubscriptionModal from './subscription-modal'; + +// This determines if the modal should be shown to the user. +// It's called before the modal is rendered. +export const SimpleSubscribeSetModalShowLoggedIn = ( { + setSubscribeModalStatus, +}: { + setSubscribeModalStatus: ( value: string ) => void; +} ) => { + const { email } = subscriptionSettings.value ?? { + email: { + send_posts: false, + }, + }; + setSubscribeModalStatus( shouldShowSubscriptionModal( email?.send_posts, userInfo.value?.uid ) ); + + return null; +}; + +// Subscription modal for logged in users. +export const SimpleSubscribeModalLoggedIn = ( { + subscribeState, + setSubscribeState, + closeModalHandler, +}: SimpleSubscribeModalProps ) => { + const { setEmailPostsSubscription } = useSubscriptionApi(); + + /** + * Handle the subscribe button click. + */ + async function handleOnSubscribeClick() { + setSubscribeState( 'SUBSCRIBING' ); + await setEmailPostsSubscription( { + type: 'subscribe', + value: true, + trackSource: 'verbum-subscription-modal', + } ); + setSubscribeState( 'SUBSCRIBED' ); + } + + if ( ! commentUrl.value ) { + return; + } + + return ( + <> + { subscribeState === 'SUBSCRIBED' ? ( + <> +

{ translate( "We'll keep you in the loop!" ) }

+
+ +
+ + ) : ( + + ) } + + ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/logged-out.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/logged-out.tsx new file mode 100644 index 0000000000000..9763ff0bf0b1f --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/logged-out.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from 'preact/hooks'; +import { translate } from '../../i18n'; +import { commentUrl } from '../../state'; +import { SimpleSubscribeModalProps } from '../../types'; +import SubscriptionModal from './subscription-modal'; +import type { ChangeEvent } from 'preact/compat'; + +// Subscription modal for logged-out users. +export const SimpleSubscribeModalLoggedOut = ( { + subscribeState, + setSubscribeState, + closeModalHandler, + email, + setHasIframe, +}: SimpleSubscribeModalProps ) => { + const [ userEmail, setUserEmail ] = useState( '' ); + const [ iframeUrl, setIframeUrl ] = useState( '' ); + const [ subscribeDisabled, setSubscribeDisabled ] = useState( false ); + + // Only want this to run once, when email is set for the first time + useEffect( () => { + setUserEmail( email ); + }, [ email ] ); + + const setSubscriptionEmail = ( event: ChangeEvent< HTMLInputElement > ) => { + const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; + setUserEmail( event.currentTarget.value ); + if ( Boolean( emailRegex.test( event.currentTarget.value ) ) === false ) { + setSubscribeDisabled( true ); + return; + } + setSubscribeDisabled( false ); + }; + + /** + * Handle the iframe result. + * @param eventFromIframe - the event from the iframe + */ + function handleIframeResult( eventFromIframe: MessageEvent ) { + if ( eventFromIframe.origin === 'https://subscribe.wordpress.com' && eventFromIframe.data ) { + const data = JSON.parse( eventFromIframe.data ); + if ( data && data.action === 'close' ) { + window.removeEventListener( 'message', handleIframeResult ); + closeModalHandler(); + setHasIframe( false ); + setIframeUrl( '' ); + } + } + } + + /** + * Handle the subscribe button click. + */ + async function handleOnSubscribeClick() { + const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; + if ( Boolean( emailRegex.test( userEmail ) ) === false ) { + return; + } + + setSubscribeState( 'SUBSCRIBING' ); + setHasIframe( true ); + const subscribeData = { + email: userEmail, + post_id: VerbumComments.postId.toString(), + plan: 'newsletter', + blog: VerbumComments.siteId.toString(), + source: 'jetpack_subscribe', + display: 'alternate', + app_source: 'verbum-subscription-modal', + locale: VerbumComments.currentLocale ?? 'en', + }; + const params = new URLSearchParams( subscribeData ); + + setIframeUrl( 'https://subscribe.wordpress.com/memberships/?' + params.toString() ); + + window.addEventListener( 'message', handleIframeResult, false ); + } + + if ( ! commentUrl.value ) { + return; + } + + if ( subscribeState === 'SUBSCRIBING' ) { + return ( +
+ { iframeUrl && ( + + ) } +
+ ); + } + return ( + + ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/style.scss new file mode 100644 index 0000000000000..41938d073dc1e --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/style.scss @@ -0,0 +1,151 @@ +#respond .verbum-simple-subscribe-modal { + position: fixed; + z-index: 50000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: #00000057; + transition: all 0.4s; + display: flex; + justify-content: space-evenly; + align-content: center; + flex-wrap: wrap; + + .verbum-simple-subscribe-modal__content { + text-align: center; + background-color: #fefefe; + width: 100%; + max-width: 650px; + box-sizing: border-box; + transition: all .4s; + padding: 32px; + display: flex; + flex-direction: column; + gap: 5px; + justify-content: center; + + &.has-iframe { + background-color: transparent; + + .verbum-simple-subscribe-modal__close-button-container { + display: none !important; + } + } + + .verbum-simple-subscribe-modal__iframe-container { + background-image: url( 'https://s0.wp.com/i/loading/dark-200.gif' ); + background-size: 50px; + background-repeat: no-repeat; + background-position: center 150px; + margin: 0 !important; + box-shadow: none; + -webkit-box-shadow: none; + -moz-box-shadow: none; + border: none; + bottom: 0; + left: 0; + right: 0; + top: 0; + width: 100% !important; + height: 100%; + position: absolute; + } + } + + h2 { + margin-top: 4px; + margin-bottom: 10px; + font-size: clamp(16.834px, 1.052rem + ((1vw - 3.2px) * 1.348), 26px); + font-style: normal; + font-weight: 600; + color: #333; + } + + p { + margin-top: 4px; + margin-bottom: 0px; + padding: 0 60px; + font-size: clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.147), 15px); + color: #333; + } + + .verbum-simple-subscribe-modal__action { + margin-top: 20px; + align-items: flex-start; + display: flex; + + input { + flex: 1; + font-size: 16px; + line-height: 28px; + padding: 15px 23px 15px 23px; + background-color: #fefefe; + border-radius: 50px; + border-width: 1px !important; + border-color: var(--wp--preset--color--primary) !important; + border-end-end-radius: 0; + border-start-end-radius: 0; + border-style: solid !important; + outline: none; + color: var(--wp--preset--color--primary) !important; + + &::placeholder { + color: currentColor; + opacity: .5; + } + } + + button { + font-size: 16px; + line-height: 28px; + padding: 15px 23px 15px 23px; + margin: 0px; + margin-left: 10px; + border-radius: 50px; + border-width: 1px !important; + border-end-start-radius: 0; + border-start-start-radius: 0; + margin-inline-start: 0; + background-color: var(--wp--preset--color--primary); + border-color: var(--wp--preset--color--primary); + border-style: solid; + color: var(--verbum-content-background-color); + + &:hover { + opacity: .9; + cursor: pointer; + } + &:disabled { + opacity: .7 !important; + cursor: not-allowed; + } + } + } + + .verbum-simple-subscribe-modal__close-button-container { + display: block; + text-align: center; + margin-top: 20px; + color: var(--wp--preset--color--primary); + + .verbum-simple-subscribe-modal__close-button { + background-color: transparent; + cursor: pointer; + border: none; + text-decoration: underline; + } + } +} + +.verbum-simple-subscribe-modal__iframe { + margin: 0 !important; + height: 100% !important; + width: 100% !important; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/subscription-modal.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/subscription-modal.tsx new file mode 100644 index 0000000000000..172c0ee6d8bff --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/SimpleSubscribeModal/subscription-modal.tsx @@ -0,0 +1,67 @@ +import { translate } from '../../i18n'; +import type { ChangeEvent } from 'preact/compat'; + +interface SubscriptionModalProps { + userEmail: string; + subscribeState: string; + handleOnSubscribeClick: () => void; + onInput?: ( event: ChangeEvent< HTMLInputElement > ) => void; + disabled?: boolean; + subscribeDisabled?: boolean; + closeModalHandler: () => void; +} + +export const SubscriptionModal = ( { + userEmail, + handleOnSubscribeClick, + subscribeState, + onInput, + disabled, + subscribeDisabled, + closeModalHandler, +}: SubscriptionModalProps ) => { + return ( + <> +

{ translate( 'Discover more from' ) }

+

{ translate( 'Subscribe now to keep reading and get access to the full archive.' ) }

+
+ { + if ( onInput ) { + onInput( event ); + } + } } + /> + +
+
+ +
+ + ); +}; + +export default SubscriptionModal; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/ToggleControl/index.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/ToggleControl/index.tsx new file mode 100644 index 0000000000000..fd74def6a2c4d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/ToggleControl/index.tsx @@ -0,0 +1,31 @@ +import type { ComponentChildren, FunctionComponent } from 'preact'; +import './style.scss'; + +type Props = { + id: string; + checked: boolean; + label: ComponentChildren; + onChange: ( checked: boolean ) => void; + disabled: boolean; +}; +export const ToggleControl: FunctionComponent< Props > = ( { + id, + checked, + onChange, + label, + disabled, +} ) => { + return ( + + ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/ToggleControl/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/ToggleControl/style.scss new file mode 100644 index 0000000000000..8606fde685571 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/ToggleControl/style.scss @@ -0,0 +1,91 @@ +#respond .verbum-toggle-control { + // protect the button from style leaks from the site; reset all. + all: unset; + display: inline-flex; + gap: 10px; + align-items: flex-start; + padding: 5px 0; + cursor: pointer; + + .verbum-toggle-control__label { + p { + // protect the button from style leaks from the site; reset all. + all: unset; + margin: 0; + line-height: 22px; + } + + .primary { + font-size: 14px; + font-weight: 500; + color: #2C3338; + display: block; + } + + .secondary { + font-size: 13px; + color: #50575e; + display: block; + } + } + + input[type=checkbox] { + // protect the button from style leaks from the site; reset all. + all: unset; + opacity: 0; + position: absolute; + transform: scale(0); + + &:checked+.verbum-toggle-control__button { + background: var(--wp-components-color-accent, var(--wp-admin-theme-color, #0675c4)); + border-color: transparent; + } + + &:focus+.verbum-toggle-control__button { + outline: 1px solid var(--wp-components-color-accent, var(--wp-admin-theme-color, #0675c4)); + } + + &:checked+.verbum-toggle-control__button:after { + left: calc(100% - 2px); + transform: translateX(-100%); + background: white; + } + } + + .verbum-toggle-control__text { + display: block; + flex: 1; + } + + .verbum-toggle-control__button { + // protect the button from style leaks from the site; reset all. + all: unset; + cursor: pointer; + width: 36px; + min-width: 36px; + height: 18px; + background: #fff; + display: block; + margin-top: 4px; + border-radius: 9px; + position: relative; + border: 1px solid black; + opacity: var(--verbum-element-opacity); + + &:after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + background: black; + border-radius: 50%; + transition: 0.3s; + } + + &:active:after { + width: 14px; + } + } +} \ No newline at end of file diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-footer.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-footer.tsx new file mode 100644 index 0000000000000..6f49c91ca68f7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-footer.tsx @@ -0,0 +1,40 @@ +import { translate } from '../i18n'; +import { isReplyDisabled, isSavingComment, isTrayOpen, userLoggedIn } from '../state'; +import { classNames } from '../utils'; +import { SettingsButton } from './settings-button'; + +interface CommentFooterProps { + toggleTray: ( event: MouseEvent ) => void; + handleOnSubmitClick: ( event: MouseEvent ) => void; +} + +export const CommentFooter = ( { toggleTray, handleOnSubmitClick }: CommentFooterProps ) => { + return ( +
+ { userLoggedIn.value && ( +
+ +
+ ) } +
+ +
+
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-input-field.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-input-field.tsx new file mode 100644 index 0000000000000..398b49d81e4df --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-input-field.tsx @@ -0,0 +1,117 @@ +import { forwardRef, type TargetedEvent } from 'preact/compat'; +import { useEffect, useState } from 'preact/hooks'; +import { translate } from '../i18n'; +import { commentValue } from '../state'; +import { classNames, isFastConnection } from '../utils'; +import { EditorPlaceholder } from './editor-placeholder'; + +type CommentInputFieldProps = { + handleOnKeyUp: () => void; +}; + +/** + * Resize the textarea to fit the content. + * + * @param event - Event object. + */ +const resizeTextarea = ( event: TargetedEvent< HTMLTextAreaElement > ) => { + event.currentTarget.style.height = 'auto'; + event.currentTarget.style.height = event.currentTarget.scrollHeight + 'px'; +}; + +const embedContentCallback = ( embedUrl: string ) => { + return { + path: '/verbum/embed', + query: `embed_url=${ encodeURIComponent( embedUrl ) }&embed_nonce=${ encodeURIComponent( + VerbumComments.embedNonce + ) }`, + apiNamespace: 'wpcom/v2', + }; +}; + +export const CommentInputField = forwardRef( + ( + { handleOnKeyUp }: CommentInputFieldProps, + ref: React.MutableRefObject< HTMLTextAreaElement | null > + ) => { + const [ editorState, setEditorState ] = useState< 'LOADING' | 'LOADED' | 'ERROR' >( null ); + const [ isGBEditorEnabled, setIsGBEditorEnabled ] = useState( false ); + + useEffect( () => { + setTimeout( () => { + setIsGBEditorEnabled( VerbumComments.enableBlocks && isFastConnection() ); + } ); + }, [] ); + + /** + * Download the block editor. + */ + async function downloadEditor() { + if ( editorState ) { + return; + } + + setEditorState( 'LOADING' ); + + try { + // Dynamically load the editor. + // import requires an absolute URL when fetching from a CDN (cross origin fetch). + await import( + /* webpackIgnore: true */ + 'https://widgets.wp.com/verbum-block-editor/block-editor.min.js?from=jetpack&ver=' + + VerbumComments.vbeCacheBuster + ); + verbumBlockEditor.attachGutenberg( + ref.current, + content => { + commentValue.value = content; + handleOnKeyUp(); + }, + VerbumComments.isRTL, + embedContentCallback + ); + // Wait fro the block editor to render. + setTimeout( () => setEditorState( 'LOADED' ), 100 ); + } catch ( error ) { + // Switch to the textarea if the editor fails to load. + setEditorState( 'ERROR' ); + setIsGBEditorEnabled( false ); + } + } + + return ( +
+
+ <> + { isGBEditorEnabled && editorState !== 'LOADED' && ( + + ) } + + +
+
+ ); + } +); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-message.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-message.tsx new file mode 100644 index 0000000000000..96534eb39433a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/comment-message.tsx @@ -0,0 +1,21 @@ +import { classNames } from '../utils'; + +interface ErrorMessageProps { + message: string | null; + isError?: boolean; +} + +export const CommentMessage = ( { message, isError }: ErrorMessageProps ) => { + if ( ! message ) { + return null; + } + return ( +
+

{ message }

+
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/custom-loading-spinner.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/custom-loading-spinner.tsx new file mode 100644 index 0000000000000..15427a3efb7fa --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/custom-loading-spinner.tsx @@ -0,0 +1,3 @@ +export const CustomLoadingSpinner = () => { + return
; +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/editor-placeholder.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/editor-placeholder.tsx new file mode 100644 index 0000000000000..a80853d5f0b68 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/editor-placeholder.tsx @@ -0,0 +1,36 @@ +import { translate } from '../i18n'; +import { classNames } from '../utils'; +import { CustomLoadingSpinner } from './custom-loading-spinner'; + +export const EditorPlaceholder = ( { onClick, loading } ) => { + return ( +
+
+
+ { loading ? ( + + ) : ( +

+ { translate( 'Leave a comment' ) } +

+ ) } +
+
+
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/email-frequency-group.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/email-frequency-group.tsx new file mode 100644 index 0000000000000..d612f43431620 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/email-frequency-group.tsx @@ -0,0 +1,81 @@ +import { useState, useEffect } from 'preact/hooks'; +import { translate } from '../i18n'; +import { FrequencyToggle } from './FrequencyToggle'; + +const options = [ + { value: 'instantly', label: translate( 'Instantly' ), checked: true }, + { value: 'daily', label: translate( 'Daily' ), checked: false }, + { value: 'weekly', label: translate( 'Weekly' ), checked: false }, +]; + +interface EmailFrequencyGroupProps { + isChecked: boolean; + onChange: ( value: 'instantly' | 'daily' | 'weekly' ) => void; + selectedOption: string; + label: string; + disabled: boolean; +} + +/** + * Runs a media query and returns its value when it changes. + * + * @param query - the media query to run. + * @returns return value of the media query. + */ +export default function useMediaQuery( query: string ) { + const [ match, setMatch ] = useState( window.matchMedia( query ).matches ); + + useEffect( () => { + const updateMatch = () => setMatch( window.matchMedia( query ).matches ); + const list = window.matchMedia( query ); + list.addEventListener( 'change', updateMatch ); + return () => { + list.addEventListener( 'change', updateMatch ); + }; + }, [ query ] ); + + return match; +} + +export const EmailFrequencyGroup = ( { + selectedOption, + isChecked, + onChange, + disabled, +}: EmailFrequencyGroupProps ) => { + const isMobile = useMediaQuery( '(max-width: 400px)' ); + + if ( isMobile ) { + return ( + <> + + + ); + } + + return ( +
+ +
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/logged-in.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/logged-in.tsx new file mode 100644 index 0000000000000..2c46b0fb8e5a7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/logged-in.tsx @@ -0,0 +1,150 @@ +import useSubscriptionApi from '../hooks/useSubscriptionApi'; +import { translate } from '../i18n'; +import { Close } from '../images'; +import { isTrayOpen, subscriptionSettings, userInfo } from '../state'; +import { serviceData, classNames, isFastConnection } from '../utils'; +import { NewCommentEmail } from './new-comment-email'; +import { NewPostsEmail } from './new-posts-email'; +import { NewPostsNotifications } from './new-posts-notifications'; + +/** + * Replace the first occurrence of %s in a string with a parameter. + * @param s - string to replace + * @param param - parameter to replace with + */ +function sprintf( s: string, param: string ) { + return s.replace( '%s', param ); +} + +interface LoggedInProps { + siteId: number; + toggleTray: () => void; + logout: () => void; +} + +export const LoggedIn = ( { toggleTray, logout }: LoggedInProps ) => { + const { setEmailPostsSubscription, setCommentSubscription, setNotificationSubscription } = + useSubscriptionApi(); + const { subscribeToComment, subscribeToBlog } = VerbumComments; + const { email, notification } = subscriptionSettings.value ?? {}; + const hasSubOptions = subscribeToComment || subscribeToBlog; + + let verbumLoadedEditor = 'textarea'; + + if ( VerbumComments.enableBlocks ) { + verbumLoadedEditor = isFastConnection() ? 'gutenberg' : 'textarea-slow-connection'; + } + + const handleClose = ( event: MouseEvent ) => { + event.preventDefault(); + toggleTray(); + }; + + const getUsername = () => { + if ( VerbumComments.isJetpackCommentsLoggedIn ) { + return `${ sprintf( translate( 'Logged in as %s' ), userInfo.value?.name ) }`; + } + return ( + <> + { userInfo.value.name } + { ` - ${ sprintf( + translate( 'Logged in via %s' ), + serviceData[ userInfo.value.service ]?.name + ) } - ` } + + ); + }; + + const logoutProps = { + href: '', + target: '', + onClick: logout, + }; + + // We need to use the userinfo logout URL, because it's fresh (can change after logging in mid-session). + const baseLogoutUrl = userInfo.value.logout_url || VerbumComments.logoutURL; + + // Atomic logging out + if ( window.location.host === 'jetpack.wordpress.com' ) { + logoutProps.href = + baseLogoutUrl + '&redirect_to=' + window.location.hash.match( /#parent=(.*)/ )[ 1 ]; + logoutProps.target = '_parent'; + } else { + logoutProps.href = baseLogoutUrl + '&redirect_to=' + encodeURIComponent( window.location.href ); + } + + return ( +
+
+
+
+
+ { getUsername() } + { ! VerbumComments.isJetpackCommentsLoggedIn ? ( + + { translate( 'Log out' ) } + + ) : null } +
+ +
+ { hasSubOptions && ( +
+ { subscribeToBlog && ( + <> + { userInfo.value.service === 'wordpress' && ( + + ) } + + + ) } + { subscribeToComment && ( + + ) } +
+ ) } + +
+ + + + + + + + +
+
+
+
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/logged-out.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/logged-out.tsx new file mode 100644 index 0000000000000..0d956ec4b3329 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/logged-out.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from 'preact/hooks'; +import { translate } from '../i18n'; +import { classNames, serviceData } from '../utils'; +import { EmailForm } from './EmailForm'; + +const { mustLogIn, requireNameEmail, commentRegistration } = VerbumComments; +interface LoggedOutProps { + login: ( service: string ) => void; + canWeAccessCookies: boolean; + loginWindow: Window | null; +} + +const getLoginCommentText = () => { + const defaultText = translate( 'Log in to leave a reply.' ); + const optionalText = translate( 'Leave a reply. (log in optional)' ); + const nameAndEmailRequired = translate( + 'Log in or provide your name and email to leave a reply.' + ); + const allowCommentsWithoutLogin = ! requireNameEmail && ! commentRegistration; + const requiresEmailandNameToComment = requireNameEmail && ! commentRegistration; + + if ( requiresEmailandNameToComment ) { + return { nameAndEmailRequired }; + } + if ( allowCommentsWithoutLogin ) { + return { optionalText }; + } + + return { defaultText }; +}; + +export const LoggedOut = ( { login, canWeAccessCookies, loginWindow }: LoggedOutProps ) => { + const [ activeService, setActiveService ] = useState( '' ); + const closeLoginPopupService = requireNameEmail && ! mustLogIn ? 'mail' : ''; + + // Handle window closing without login + useEffect( () => { + if ( ! loginWindow && activeService && activeService !== 'mail' ) { + setActiveService( closeLoginPopupService ); + } + }, [ loginWindow, activeService, closeLoginPopupService ] ); + + useEffect( () => { + // Handle cases when name and email are required but without login. + if ( requireNameEmail && ! commentRegistration ) { + setActiveService( 'mail' ); + } + }, [ setActiveService ] ); + + const handleClick = ( event: MouseEvent, service: string ) => { + event.preventDefault(); + + if ( activeService === service ) { + setActiveService( '' ); + loginWindow?.close(); + return; + } + + switch ( service ) { + case 'wordpress': + case 'facebook': + login( service ); + break; + case 'guest': + if ( [ 'wordpress', 'facebook' ].includes( activeService ) ) { + loginWindow?.close(); + } + break; + } + + setActiveService( service ); + }; + + return ( +
+
+
+ { canWeAccessCookies && ( + <> +
{ getLoginCommentText() }
+
+
+ { Object.entries( serviceData ).map( ( [ service, value ] ) => { + // Don't show mail login if "Users must be registered and logged in to comment" enabled. + if ( mustLogIn && service === 'mail' ) { + // eslint-disable-next-line array-callback-return + return; + } + + return ( + + ); + } ) } +
+ { [ 'wordpress', 'facebook' ].includes( activeService ) && ( +
+

+ +
+ ) } +
+ + ) } + +
+
+
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-comment-email.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-comment-email.tsx new file mode 100644 index 0000000000000..bc26dd11b3055 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-comment-email.tsx @@ -0,0 +1,32 @@ +import { translate } from '../i18n'; +import { ToggleControl } from './ToggleControl'; + +interface NewCommentEmailProps { + isChecked: boolean; + handleOnChange: ( event: boolean ) => void; + disabled: boolean; +} + +export const NewCommentEmail = ( { + isChecked, + handleOnChange, + disabled, +}: NewCommentEmailProps ) => { + const label = ( +
+

{ translate( 'Email me new comments' ) }

+
+ ); + + return ( +
+ +
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-posts-email.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-posts-email.tsx new file mode 100644 index 0000000000000..8134b86578aee --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-posts-email.tsx @@ -0,0 +1,55 @@ +import { translate } from '../i18n'; +import { EmailPostsChange } from '../types'; +import { ToggleControl } from './ToggleControl'; +import { EmailFrequencyGroup } from './email-frequency-group'; + +interface NewPostsEmailProps { + handleOnChange: ( props: EmailPostsChange ) => void; + isChecked: boolean; + selectedOption: string; + disabled?: boolean; +} + +export const NewPostsEmail = ( { + handleOnChange, + isChecked, + selectedOption, + disabled = false, +}: NewPostsEmailProps ) => { + const label = ( +
+

{ translate( 'Email me new posts' ) }

+
+ ); + + return ( +
+ { + handleOnChange( { + type: 'subscribe', + value: e, + trackSource: 'verbum-toggle', + } ); + } } + /> + { + handleOnChange( { + type: 'frequency', + value: e, + trackSource: 'verbum-toggle', + } ); + } } + label={ null } + disabled={ disabled } + /> +
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-posts-notifications.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-posts-notifications.tsx new file mode 100644 index 0000000000000..831cb54fa3aaf --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/new-posts-notifications.tsx @@ -0,0 +1,37 @@ +import { translate } from '../i18n'; +import { ToggleControl } from './ToggleControl'; + +interface NewPostsNotificationsProps { + isChecked: boolean; + handleOnChange: ( event: boolean ) => void; + disabled?: boolean; +} + +export const NewPostsNotifications = ( { + isChecked, + handleOnChange, + disabled = false, +}: NewPostsNotificationsProps ) => { + const label = ( +
+

{ translate( 'Notify me of new posts' ) }

+

+ { translate( 'Receive web and mobile notifications for posts on this site.' ) } +

+
+ ); + + return ( +
+ { + handleOnChange( e ); + } } + /> +
+ ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/settings-button.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/settings-button.tsx new file mode 100644 index 0000000000000..2d4170e256331 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/settings-button.tsx @@ -0,0 +1,69 @@ +import { userInfo } from '../state'; +import { classNames, hasSubscriptionOptionsVisible } from '../utils'; + +interface SettingsButtonProps { + expanded: boolean; + toggleSubscriptionTray: ( event: MouseEvent ) => void; +} + +export const SettingsButton = ( { expanded, toggleSubscriptionTray }: SettingsButtonProps ) => { + const subscriptionOptionsVisible = hasSubscriptionOptionsVisible(); + + const handleOnClick = ( event: MouseEvent ) => { + if ( subscriptionOptionsVisible ) { + toggleSubscriptionTray( event ); + } + }; + + return ( + + ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/types.d.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/types.d.ts new file mode 100644 index 0000000000000..5483f4cbf3797 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/components/types.d.ts @@ -0,0 +1,24 @@ +import type { VerbumComments as VerbumCommentsType } from '../types'; + +type ScriptLoader = { + loadScript: ( url: string ) => Promise< void >; +}; + +declare global { + const VerbumComments: VerbumCommentsType; + const vbeCacheBuster: string; + const WP_Enqueue_Dynamic_Script: ScriptLoader; + const wp: Record< string, unknown >; + + interface Window { + wpApiSettings: { + root?: string; + }; + } + + /** + * Contains the current app's bundle size in bytes. Populated in vite.config.ts. + * Useful to determine the connection speed. + */ + const verbumBundleSize: number; +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/hooks/useSocialLogin.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/hooks/useSocialLogin.tsx new file mode 100644 index 0000000000000..c67baec62586d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/hooks/useSocialLogin.tsx @@ -0,0 +1,115 @@ +import { useState, useEffect } from 'preact/hooks'; +import wpcomRequest from 'wpcom-proxy-request'; +import { userInfo } from '../state'; +import { UserInfo } from '../types'; +import { serviceData, setUserInfoCookie } from '../utils'; + +export const addIframe = ( src: string ) => { + const iframe = document.createElement( 'iframe' ); + iframe.height = '1'; + iframe.width = '1'; + iframe.style.display = 'none'; + iframe.src = src; + document.body.appendChild( iframe ); + return new Promise< void >( resolve => { + iframe.onload = () => { + resolve(); + iframe.remove(); + }; + } ); +}; + +const addWordPressDomain = window.location.hostname.endsWith( '.wordpress.com' ) + ? ' Domain=.wordpress.com' + : ''; + +/** + * Hook to retrieve user info from server, handle social login, and logout functionality. + * + * @returns {object} login, loginWindowRef, logout - login is a function to open the social login popup, loginWindowRef is a reference to the login popup window, and logout is a function to logout the user. + */ +export default function useSocialLogin() { + const [ loginWindowRef, setLoginWindowRef ] = useState< Window >(); + + useEffect( () => { + wpcomRequest< UserInfo >( { + path: '/verbum/auth', + apiNamespace: 'wpcom/v2', + } ).then( res => { + userInfo.value = res; + } ); + }, [] ); + + if ( VerbumComments.isJetpackCommentsLoggedIn ) { + userInfo.value = { + avatar: VerbumComments.jetpackAvatar, + name: VerbumComments.jetpackUsername, + access_token: VerbumComments.jetpackSignature, + uid: VerbumComments.jetpackUserId, + service: 'jetpack', + }; + + return { + login: null, + loginWindowRef, + logout: null, + }; + } + + const logout = () => { + const serviceName = userInfo.value?.service; + const cookieName = serviceData[ serviceName ].cookieName; + + // Firefox: Logout from Verbum UI and clear cookies + document.cookie = `${ cookieName }=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure=True;${ addWordPressDomain }`; + }; + + const login = async ( service: string ) => { + const { connectURL } = VerbumComments; + + const loginWindow = window.open( + `${ connectURL }&service=${ service }`, + 'VerbumCommentsLogin', + `status=0,toolbar=0,location=1,menubar=0,directories=0,resizable=1,scrollbars=0${ serviceData[ service ].popup }` + ); + + const waitForLogin = event => { + if ( + event.origin !== document.location.origin && + ! event.origin.endsWith( '.wordpress.com' ) + ) { + return; + } + + if ( event.data.service === service && event.data.access_token ) { + userInfo.value = event.data; + + setUserInfoCookie( event.data ); + + const highlanderNonce = document.getElementById( + 'highlander_comment_nonce' + ) as HTMLInputElement; + if ( highlanderNonce ) { + highlanderNonce.value = event.data.nonce; + } + window.removeEventListener( 'message', waitForLogin ); + } + }; + + // Listen for login data + window.addEventListener( 'message', waitForLogin ); + + // Clean up loginWindow to reset activeService + const loginClosed = setInterval( () => { + if ( loginWindow?.closed ) { + clearInterval( loginClosed ); + setLoginWindowRef( undefined ); + window.removeEventListener( 'message', waitForLogin ); + } + }, 100 ); + + setLoginWindowRef( loginWindow ); + }; + + return { login, loginWindowRef, logout }; +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/hooks/useSubscriptionApi.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/hooks/useSubscriptionApi.tsx new file mode 100644 index 0000000000000..5e9de6bcd8b7b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/hooks/useSubscriptionApi.tsx @@ -0,0 +1,148 @@ +import { useState, useEffect } from 'preact/hooks'; +import wpcomRequest from 'wpcom-proxy-request'; +import { subscriptionSettings } from '../state'; +import { SubscriptionDetails, EmailPostsChange, EmailSubscriptionResponse } from '../types'; + +const getSubscriptionDetails = async () => { + const { siteId } = VerbumComments; + + if ( ! siteId ) { + return false; + } + + return await wpcomRequest( { + path: `/read/sites/${ siteId }/subscription-details?post_id=${ encodeURIComponent( + VerbumComments.postId + ) }`, + apiNamespace: 'wpcom/v2', + apiVersion: '2', + } ); +}; + +/** + * Hook to handle subscription API calls. + */ +export default function useSubscriptionApi() { + const { siteId } = VerbumComments; + const [ subscriptionSettingsIsLoading, setSubscriptionSettingsIsLoading ] = useState( true ); + + const setDefaultSubscriptionSettings = () => { + subscriptionSettings.value = { + email: { + send_posts: false, + send_comments: false, + post_delivery_frequency: 'daily', + }, + } as SubscriptionDetails; + }; + + useEffect( () => { + setSubscriptionSettingsIsLoading( true ); + getSubscriptionDetails() + .then( ( data: Record< string, string | SubscriptionDetails > ) => { + setSubscriptionSettingsIsLoading( false ); + // When a Facebook user doesn't have a subscription, it does not return delivery_methods object. + // We set the default values for the subscription settings. + if ( ! data.delivery_methods ) { + setDefaultSubscriptionSettings(); + return; + } + subscriptionSettings.value = data.delivery_methods as SubscriptionDetails; + } ) + .catch( err => { + if ( err.message === 'Blog subscription not found' ) { + // The user isn't subscribed to the blog, don't escalate the error to console. + // We set the default values for the subscription settings. + setDefaultSubscriptionSettings(); + } + } ) + .finally( () => { + setSubscriptionSettingsIsLoading( false ); + } ); + }, [] ); + + const setEmailPostsSubscription = async function ( change: EmailPostsChange ) { + let response: EmailSubscriptionResponse; + if ( change.type === 'frequency' ) { + response = await wpcomRequest< EmailSubscriptionResponse >( { + path: `/read/site/${ siteId }/post_email_subscriptions/update`, + apiVersion: '1.2', + method: 'POST', + body: { + delivery_frequency: change.value, + track_source: change.trackSource, + }, + } ); + } else if ( change.type === 'subscribe' ) { + response = await wpcomRequest< EmailSubscriptionResponse >( { + path: `/read/site/${ siteId }/post_email_subscriptions/${ + change.value ? 'new' : 'delete' + }/`, + apiVersion: '1.2', + method: 'POST', + body: { + track_source: change.trackSource, + }, + } ); + } + + const subscriptionSettingsValue = subscriptionSettings.peek(); + if ( response.success ) { + subscriptionSettings.value = { + ...subscriptionSettingsValue, + email: { + ...subscriptionSettingsValue.email, + send_posts: response.subscribed, + post_delivery_frequency: response.subscription?.delivery_frequency ?? 'instantly', + }, + }; + } + }; + + const setCommentSubscription = async ( subscribe: boolean ) => { + const comments = await wpcomRequest< Record< string, boolean > >( { + path: `/read/site/${ siteId }/comment_email_subscriptions/${ + subscribe ? 'new' : 'delete' + }/?post_id=${ encodeURIComponent( VerbumComments.postId ) }`, + apiVersion: '1.2', + method: 'POST', + } ); + + const subscriptionSettingsValue = subscriptionSettings.peek(); + if ( comments.success ) { + subscriptionSettings.value = { + ...subscriptionSettingsValue, + email: { + ...subscriptionSettingsValue.email, + send_comments: comments.subscribed, + }, + }; + } + }; + + const setNotificationSubscription = async ( subscribe: boolean ) => { + const notifications = await wpcomRequest< Record< string, boolean > >( { + path: `/read/sites/${ siteId }/notification-subscriptions/${ subscribe ? 'new' : 'delete' }`, + apiVersion: '2', + apiNamespace: 'wpcom/v2', + method: 'POST', + } ); + + const subscriptionSettingsValue = subscriptionSettings.peek(); + if ( notifications.success ) { + subscriptionSettings.value = { + ...subscriptionSettingsValue, + notification: { + send_posts: notifications.subscribed, + }, + }; + } + }; + + return { + subscriptionSettingsIsLoading, + setEmailPostsSubscription, + setCommentSubscription, + setNotificationSubscription, + }; +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/i18n.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/i18n.ts new file mode 100644 index 0000000000000..52820f6096925 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/i18n.ts @@ -0,0 +1,13 @@ +declare global { + interface Window { + VerbumComments: Record< string, string >; + } +} + +/** + * Translates a string. + * @param string - The string to translate. + */ +export function translate( string: string ) { + return window.VerbumComments?.[ string ] ?? string; +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/facebook.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/facebook.tsx new file mode 100644 index 0000000000000..bf862ba7a77b1 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/facebook.tsx @@ -0,0 +1,12 @@ +export const Facebook = () => { + return ( + + + + ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/icons.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/icons.tsx new file mode 100644 index 0000000000000..c4840762bc7c7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/icons.tsx @@ -0,0 +1,95 @@ +export const Close = () => { + return ( + + ); +}; + +export const Website = () => { + return ( + + + + + + + + ); +}; + +export const Name = () => { + return ( + + + + + + + ); +}; + +export const Email = () => { + return ( + + + + + + + + + + + + ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/index.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/index.ts new file mode 100644 index 0000000000000..59b22f696f471 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/index.ts @@ -0,0 +1,4 @@ +export { Facebook } from './facebook'; +export { WordPress } from './wordpress'; +export { Mail } from './mail'; +export { Close, Name, Email, Website } from './icons'; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/mail.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/mail.tsx new file mode 100644 index 0000000000000..ec86fbffecb2d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/mail.tsx @@ -0,0 +1,16 @@ +export const Mail = () => { + return ( + + + + + ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/wordpress.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/wordpress.tsx new file mode 100644 index 0000000000000..d753edd72cc0f --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/images/wordpress.tsx @@ -0,0 +1,12 @@ +export const WordPress = () => { + return ( + + + + ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/index.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/index.tsx new file mode 100644 index 0000000000000..1bda17fd3f758 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/index.tsx @@ -0,0 +1,250 @@ +import { effect } from '@preact/signals'; +import { render } from 'preact'; +import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; +import { SimpleSubscribeModal } from './components/SimpleSubscribeModal'; +import { CommentFooter } from './components/comment-footer'; +import { CommentInputField } from './components/comment-input-field'; +import { CommentMessage } from './components/comment-message'; +import { LoggedIn } from './components/logged-in'; +import { LoggedOut } from './components/logged-out'; +import useSocialLogin from './hooks/useSocialLogin'; +import { translate } from './i18n'; +import { + hasOpenedTrayOnce, + isEmptyComment, + isSavingComment, + isTrayOpen, + mailLoginData, + shouldStoreEmailData, + userInfo, + userLoggedIn, + commentUrl, +} from './state'; +import { + classNames, + canWeAccessCookies, + setUserInfoCookie, + addWordPressDomain, + hasSubscriptionOptionsVisible, +} from './utils'; +import type { VerbumComments } from './types'; + +import './style.scss'; + +const Verbum = ( { siteId }: VerbumComments ) => { + const [ showMessage, setShowMessage ] = useState( '' ); + const [ isErrorMessage, setIsErrorMessage ] = useState( false ); + const [ subscribeModalStatus, setSubscribeModalStatus ] = useState< + | 'showed' + | 'hidden_cookies_disabled' + | 'hidden_subscribe_not_enabled' + | 'hidden_views_limit' + | 'hidden_already_subscribed' + >(); + + const commentTextarea = useRef< HTMLTextAreaElement >(); + const [ email, setEmail ] = useState( '' ); + const [ ignoreSubscriptionModal, setIgnoreSubscriptionModal ] = useState( false ); + const { login, loginWindowRef, logout } = useSocialLogin(); + + const dispose = effect( () => { + // The tray, when there is no sub options, is pretty minimal. + // It's also needed to log out. Without this, the user will have to type to reveal the tray and they won't guess they need to type to logout. + if ( ! hasSubscriptionOptionsVisible() && userLoggedIn.value ) { + isTrayOpen.value = true; + } + } ); + + const handleBeforeUnload = useCallback( ( event: BeforeUnloadEvent ) => { + event.preventDefault(); + event.returnValue = ''; + }, [] ); + + useEffect( () => { + if ( ! isEmptyComment.value ) { + window.addEventListener( 'beforeunload', handleBeforeUnload ); + return () => { + dispose(); + window.removeEventListener( 'beforeunload', handleBeforeUnload ); + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ isEmptyComment.value ] ); + + const subscriptionTraySeen = () => { + try { + return window.localStorage.getItem( + `${ userInfo.value?.uid }-verbum-settings-open-${ siteId }` + ); + } catch ( e ) { + return false; + } + }; + + const setSubscriptionTraySeen = () => { + try { + localStorage.setItem( `${ userInfo.value?.uid }-verbum-settings-open-${ siteId }`, '1' ); + hasOpenedTrayOnce.value = true; + } catch ( e ) { + // Do nothing. + } + }; + + const showTrayIfNewUser = () => { + if ( ! userLoggedIn.value ) { + isTrayOpen.value = true; + return; + } + // I check the localStorage, to see if they have submitted a comment before on this site. + if ( ! subscriptionTraySeen && ! hasOpenedTrayOnce.value ) { + // If they have not, we open the tray for them. Once. + isTrayOpen.value = true; + hasOpenedTrayOnce.value = true; + } + }; + + const handleSubscriptionModal = async event => { + event.preventDefault(); + setShowMessage( '' ); + + const formAction = document.querySelector( '#commentform' ).getAttribute( 'action' ); + + const formElement = document.querySelector( '#commentform' ) as HTMLFormElement; + + const formData = new FormData( formElement ); + + // if formData email address is set, set the newUserEmail state + if ( formData.get( 'email' ) ) { + setEmail( formData.get( 'email' ) as string ); + } + + // We get the parent comment id to scroll on page reload. + // If the user is not replying any comment, we scroll to the comment form. + const parentCommentId = Number( formData.get( 'comment_parent' ) ); + + formData.set( 'verbum_show_subscription_modal', subscribeModalStatus ); + + const response = await fetch( formAction, { + method: 'POST', + body: formData, + } ); + + if ( response.redirected ) { + commentUrl.value = + response.url + ( parentCommentId > 0 ? '#comment-' + parentCommentId : '#respond' ); + setShowMessage( translate( 'Comment sent successfully' ) ); + setIsErrorMessage( false ); + return; + } + + const text = await response.text(); + const doc = new DOMParser().parseFromString( text, 'text/html' ); + const errorMessageElement = doc.querySelector( '.wp-die-message p' ); + + // Show error message + if ( errorMessageElement !== null ) { + setShowMessage( errorMessageElement.innerHTML ); + setIsErrorMessage( true ); + isSavingComment.value = false; + } + + // If no error message and not redirect, we re-submit the form as usual instead of using fetch. + setIgnoreSubscriptionModal( true ); + isSavingComment.value = false; + const submitFormFunction = Object.getPrototypeOf( formElement ).submit; + submitFormFunction.call( formElement ); + }; + + const handleCommentSubmit = async event => { + window.removeEventListener( 'beforeunload', handleBeforeUnload ); + if ( userInfo.value?.service === 'guest' ) { + if ( shouldStoreEmailData.value ) { + const mailLoginDataValue = mailLoginData.value; + setUserInfoCookie( { + service: 'guest', + ...( mailLoginDataValue?.email && { email: mailLoginDataValue?.email } ), + ...( mailLoginDataValue?.author && { author: mailLoginDataValue?.author } ), + ...( mailLoginDataValue?.url && { url: mailLoginDataValue?.url } ), + } ); + } else { + // Clear mail form cookie data + document.cookie = `wpc_guest=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure=True;${ addWordPressDomain }`; + } + } + + if ( ! subscriptionTraySeen && userLoggedIn.value ) { + setSubscriptionTraySeen(); + } + + setTimeout( () => ( isSavingComment.value = true ), 0 ); + + if ( ! VerbumComments.isJetpackComments ) { + if ( VerbumComments.enableSubscriptionModal && ! ignoreSubscriptionModal ) { + isSavingComment.value = true; + await handleSubscriptionModal( event ); + } + } + }; + + const handleTrayToggle = () => { + commentTextarea.current.focus(); + + if ( isTrayOpen.value && ! subscriptionTraySeen && userLoggedIn.value ) { + setSubscriptionTraySeen(); + } + + isTrayOpen.value = ! isTrayOpen.value; + }; + + const closeModalHandler = () => { + const destinationUrl = new URL( commentUrl.value ); + + // current URL without hash + const currentUrlWithoutHash = location.href.replace( location.hash, '' ); + // destination URL without hash + const destinationUrlWithoutHash = destinationUrl.href.replace( destinationUrl.hash, '' ); + window.location.href = commentUrl.value; + + // reload the page if the user is already on the comment page + if ( currentUrlWithoutHash === destinationUrlWithoutHash ) { + window.location.reload(); + } + }; + + return ( + <> + +
+ { userLoggedIn.value ? ( + + ) : ( + + ) } +
+ + + { VerbumComments.enableSubscriptionModal && ( + + ) } + + ); +}; + +const { siteId } = { + ...VerbumComments, +}; + +render( , document.getElementById( 'comment-form__verbum' ) ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/state.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/state.tsx new file mode 100644 index 0000000000000..99e90c6d60ab7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/state.tsx @@ -0,0 +1,99 @@ +import { signal, computed } from '@preact/signals'; +import { canWeAccessCookies, getUserInfoCookie, isAuthRequired, isEmptyEditor } from './utils'; +import type { UserInfo, SubscriptionDetails } from './types'; +import type { Signal } from '@preact/signals'; + +/* + * In userInfo we store the user data for logged-in users. + */ +export const userInfo: Signal< UserInfo > = signal( getUserInfoCookie() ); + +/* + * Calculate if user is logged in. For self-hosted sites this check is based only on VerbumComments.isJetpackCommentsLoggedIn. + * Here we also check if cookies are accessible, userInfo is set and the service is different from 'guest' or 'jetpack'. + */ +export const userLoggedIn = computed( () => { + return ( + VerbumComments.isJetpackCommentsLoggedIn || + ( canWeAccessCookies() && + userInfo.value && + userInfo.value?.service !== 'guest' && + userInfo.value?.service !== 'jetpack' ) + ); +} ); + +/* + * Store user input: email, author and url from email form. + */ +export const mailLoginData = signal( { + email: '', + author: '', + url: '', +} ); + +/* + * Indicate whether the tray showing the subscription options is open. + */ +export const isTrayOpen = signal( false ); + +/* + * Indicate whether the subscription option tray has been opened once. + */ +export const hasOpenedTrayOnce = signal( false ); + +/* + * Store the value of the comment input field. + */ +export const commentValue = signal( '' ); + +/* + * Calculate if the comment value is empty. + */ +export const isEmptyComment = computed( () => { + return isEmptyEditor( commentValue.value ); +} ); + +/* + * Indicate whether we are saving the comment. + */ +export const isSavingComment = signal( false ); + +/* + * isMailFormInvalid is used to if the required email form data was not properly filled. + */ +export const isMailFormInvalid = signal( false ); + +/* + * isMailFormMissingInput is used to determine if the mail input is not set. + */ +const isMailFormMissingInput = computed( () => { + return ! mailLoginData.value.email || ! mailLoginData.value.author; +} ); + +/* + * Calculate if the reply button should be disabled. When we have no user data we check the shouldDisableReply value, + * otherwise we check if the comment is empty or saving. + */ +export const isReplyDisabled = computed( () => { + return ( + ( isAuthRequired() && + ! userLoggedIn.value && + ( isMailFormMissingInput.value || isMailFormInvalid.value ) ) || + isEmptyComment.value || + isSavingComment.value + ); +} ); + +/* + * commentUrl is used to store the url of the comment page. + * This is used to redirect the user to the comment page after the comment is saved. + */ +export const commentUrl = signal( '' ); + +/* + * Indicate whether we need to store the email data. If set we use this to store the user info cookie. + */ +export const shouldStoreEmailData = signal( false ); + +// +export const subscriptionSettings: Signal< SubscriptionDetails > = signal( undefined ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/style.scss new file mode 100644 index 0000000000000..3bfea7e206e0a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/style.scss @@ -0,0 +1,562 @@ +@use "sass:meta"; + +// Expanding Animation +@mixin sliding-animation { + display: grid; + grid-template-rows: 0fr; + transition: all 0.3s ease-in-out; + + &.open { + grid-template-rows: 1fr; + transition: all 0.3s ease-in-out; + } + + >div { + overflow: hidden; + } +} + +// Color Schemes +@mixin color-schemes { + &.dark { + --verbum-font-color: rgba(255, 255, 255, 0.8); + --verbum-border-color: rgba(255, 255, 255, 0.2); + --verbum-background-color: rgba(0, 0, 0, 0.7); + --verbum-wrapper-background-color: rgba(255, 255, 255, 0.1); + --verbum-content-background-color: rgba(255, 255, 255, 0.1); + --verbum-element-opacity: 1; + --wp--preset--color--primary: var(--verbum-font-color); + } + + &.transparent { + --verbum-font-color: rgba(0, 0, 0, 0.8); + --verbum-border-color: #DCDCDE; + --verbum-background-color: rgba(255, 255, 255, 0.6); + --verbum-wrapper-background-color: rgba(255, 255, 255, 0.6); + --verbum-content-background-color: rgba(255, 255, 255, 0.6); + --verbum-element-opacity: .9; + --wp--preset--color--primary: var(--verbum-font-color); + } + + &.light { + --verbum-font-color: #3C434A; + --verbum-border-color: #DCDCDE; + --verbum-background-color: #FFFFFF; + --verbum-wrapper-background-color: #F9F9F9; + --verbum-content-background-color: #FFFFFF; + --verbum-element-opacity: 1; + --wp--preset--color--primary: var(--verbum-font-color); + } + +} + +/* This prevents images from overflowing the comment area. */ +.comment img { + max-width: 100%; +} + +#respond { + + + &.comment-respond.wp-block-post-comments-form h3#reply-title.comment-reply-title { + margin-bottom: 0.5em; + } + + h3 { + padding-top: 0; + padding-bottom: 0; + margin-top: 15px; + margin-bottom: 15px; + display: block; + } + + &.comment-respond form#commentform { + background-color: transparent; + + >p { + display: none; + } + + #comment-form__verbum { + @include color-schemes; + + background-color: var(--verbum-background-color); + border: 1px solid var(--verbum-border-color); + font-family: var(--wp--preset--font-family--system-font, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif); + + // Fixes the alt text for the close button + .screen-reader-text { + clip: rect(1px 1px 1px 1px); + /* IE6, IE7 */ + clip: rect(1px, 1px, 1px, 1px); + position: absolute !important; + } + + /** + * Comment Form Textarea + */ + #comment-form-comment { + all: unset; + box-shadow: none; + border: none; + padding: 0; + display: grid; + + &.verbum-text-area { + + button, + form, + input { + all: unset; + } + } + + textarea { + grid-area: 1/1; + all: initial; + color: var(--verbum-font-color); + display: block; + appearance: none; + border: none !important; + background: transparent !important; + font-size: 16px; + font-family: var(--jetpack-comments-font); + width: 100%; + max-width: 100%; + resize: none; + outline: none; + padding: 16px; + min-height: 96px; + box-sizing: border-box; + margin: 0; + box-shadow: none; + float: none; + line-height: 1.4; + border-radius: unset; + font-weight: 400; + white-space: pre-wrap; + + &::placeholder { + color: var(--verbum-font-color); + opacity: 0.7; + } + + &.editor-enabled { + display: none; + } + } + + .verbum-editor-wrapper { + grid-area: 1/1; + } + } + + .comment-form__subscription-options { + @include sliding-animation; + + border-bottom: 1px solid var(--verbum-border-color); + + .verbum-subscriptions { + .logout-link { + // protect the button from style leaks from the site; reset all. + all: unset; + cursor: pointer; + background: none; + border: 0; + border-radius: 0; + box-shadow: none; + color: var(--verbum-font-color); + margin: 0; + outline: none; + padding: 0; + text-align: left; + transition-duration: 0.05s; + transition-property: border, background, color; + transition-timing-function: ease-in-out; + font-size: 14px; + + &:active, + &:focus { + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-components-color-accent, + var(--wp-admin-theme-color, rgb(0, 124, 186))); + } + } + + .verbum-email-frequency-select { + width: calc(100% - 50px); + padding: 3px; + border: 1px solid var(--verbum-border-color); + margin: 5px 0; + margin-left: 46px; + } + + .verbum-subscriptions__wrapper { + color: var(--verbum-font-color); + background: var(--verbum-wrapper-background-color); + border-top: 1px solid var(--verbum-border-color); + padding: 16px; + + p, + span { + color: var(--verbum-font-color); + } + } + + /** + * Logged In + */ + &.logged-in { + .verbum-subscriptions__wrapper { + .verbum-subscriptions__content { + background-color: var(--verbum-content-background-color); + border: 1px solid var(--verbum-border-color); + border-radius: 4px; + + .verbum-subscriptions__heading, + .verbum-subscriptions__options { + padding: 16px 21px; + } + + .verbum-subscriptions__heading { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 16px; + padding-bottom: 13px; + font-size: 14px; + + .verbum__user-name { + font-weight: 600; + } + + .close-button { + all: unset; + background-color: transparent; + cursor: pointer; + border: none; + padding: 0; + height: 24px; + width: 24px; + + svg { + fill: var(--verbum-font-color); + } + + &:focus { + outline: 2px solid var(--wp-components-color-accent, var(--wp-admin-theme-color, #0675c4)); + } + } + } + + .verbum-subscriptions__options { + border-top: 1px solid var(--verbum-border-color); + } + } + } + + // Styling when there are no subscription options visible. + &.no-options { + .verbum-subscriptions__wrapper { + border-top: 1px solid var(--verbum-border-color); + width: 100%; + padding: 0; + + .verbum-subscriptions__content { + border: none; + border-radius: 0; + background-color: var(--verbum-wrapper-background-color); + + .close-button { + display: none; + } + } + } + } + } + + /** + * Logged Out + */ + &.logged-out { + .verbum-subscriptions__login-header { + font-size: 18px; + font-weight: 400; + padding-bottom: 16px; + } + + .verbum-logins { + display: flex; + align-items: center; + + &.logging-in { + .verbum-logins__social-buttons.show-form-content { + margin-bottom: 25px; + } + + button.social-button { + &:not(.active) { + background-color: #5C5C5C; + opacity: .6; + + svg { + opacity: .8; + } + } + } + } + + button.social-button { + all: unset; + height: 36px; + width: 36px; + padding: 8px; + border-radius: 50%; + margin-right: 20px; + border: none; + cursor: pointer; + background: none; + font-size: 13px; + line-height: normal; + box-sizing: border-box; + + &.wordpress { + background-color: #0675C4; + } + + &.facebook { + background-color: #0F6FEA; + } + + &.mail { + background-color: #3C97CE; + } + } + + .verbum-login__social-loading { + display: flex; + align-self: center; + align-items: center; + margin-bottom: 25px; + + &.must-login { + margin-bottom: 0; + } + + p { + all: reset; + background: transparent; + border: 2px solid var(--verbum-border-color); + border-left-color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #0675c4)); + border-radius: 50%; + width: 15px; + height: 15px; + margin: 0 10px; + animation: spin 1s linear infinite; + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + } + + button { + font-size: 14px; + text-decoration: none; + } + } + } + + .verbum-form { + @include sliding-animation; + } + } + } + } + + /** + * Comment form footer + */ + .verbum-footer { + display: flex; + padding: 16px; + + &:not(.logged-in) { + justify-content: flex-end; + } + + .verbum-footer__user { + display: flex; + justify-content: space-between; + width: 100%; + + .user-settings-button { + all: unset; + align-items: center; + padding: 0; + display: flex; + height: 37px; + width: 74px; + border: 1px solid var(--verbum-border-color); + border-radius: 4px; + overflow: hidden; + background-color: var(--verbum-content-background-color); + + img, + svg { + flex: 1; + height: 16px; + } + + img { + height: 100%; + } + + justify-content: center; + cursor: pointer; + + svg path { + transition: transform 0.5s ease-in-out 0.2s; + transform-origin: center center; + } + + &.no-subscriptions { + width: 37px; + } + + &:focus { + outline: 2px solid var(--wp-components-color-accent, var(--wp-admin-theme-color, #0675c4)); + } + } + } + + .verbum-footer__submit { + @keyframes button__busy-animation { + 0% { + background-position: 240px 0; + } + } + + #comment-submit { + // protect the button from style leaks from the site; reset all. + all: unset; + display: inline-block; + padding: 7px 20px; + text-align: center; + font-size: 14px; + border-radius: 4px; + border: none; + background: var(--wp-components-color-accent, + var(--wp-admin-theme-color, #0675c4)); + color: var(--wp-components-color-accent-inverted, #fff); + text-decoration: none; + text-shadow: none; + white-space: nowrap; + cursor: default; + + &:disabled { + background-color: #DCDCDE; + } + + &.is-busy { + animation: button__busy-animation 3000ms infinite linear; + background-image: linear-gradient(-45deg, #a7aaad 28%, #c3c4c7 28%, #c3c4c7 72%, #a7aaad 72%); + } + + &:not(:disabled) { + cursor: pointer; + + &:focus, + &:active { + box-shadow: + inset 0 0 0 1px var(--wp-components-color-background, #fff), + 0 0 0 var(--wp-admin-border-width-focus) var(--wp-components-color-accent, + var(--wp-admin-theme-color, #0675c4)); + } + + &:hover { + background: var(--wp-components-color-accent-darker-10, + var(--wp-admin-theme-color-darker-10, #055d9c)); + } + + &:focus-visible { + outline: none; + } + } + } + } + } + + /** + * Comment form error messages + */ + .verbum-error-message { + display: flex; + padding: 16px; + + p { + padding: 8px 12px; + margin: 0; + border-left: 4px solid #f0b849; + background-color: #fef8ee; + width: 100%; + font-size: 14px; + } + } + + /** + * Comment form messages + */ + .verbum-message { + display: flex; + padding: 16px; + + p { + padding: 8px 12px; + margin: 0; + border-left: 4px solid #4ab866; + background-color: #eff9f1; + color: #333; + width: 100%; + font-size: 14px; + } + + &.is-error { + p { + border-left: 4px solid #f0b849; + background-color: #f9ecec; + } + } + } + } + } + + .custom-loading-spinner { + border: 2px solid #f3f3f3; + border-radius: 50%; + border-top: 2px solid #3498db; + width: 16px; + height: 16px; + -webkit-animation: customLoadingSpinner 2s linear infinite; + animation: customLoadingSpinner 2s linear infinite; + } + + @keyframes customLoadingSpinner { + to { + -webkit-transform: rotate(360deg); + } + } + + @-webkit-keyframes customLoadingSpinner { + to { + -webkit-transform: rotate(360deg); + } + } +} + +@keyframes button__busy-animation { + 0% { + background-position: 240px 0; + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/types.tsx b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/types.tsx new file mode 100644 index 0000000000000..47b8b609a81ac --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/types.tsx @@ -0,0 +1,104 @@ +export interface UserInfo { + access_token?: string; + account?: string; + avatar?: string; + email?: string; + link?: string; + name?: string; + service: string; + avatar_classes?: string; + logout_url?: string; + uid?: number; + url?: string; + author?: string; +} + +export interface SubscriptionDetails { + email: { + send_posts: boolean; + send_comments: boolean; + post_delivery_frequency: string; + }; + notification?: { + send_posts: boolean; + }; +} + +export type EmailPostsChange = + | { + type: 'subscribe'; + value: boolean; + trackSource: 'verbum-subscription-modal' | 'verbum-toggle'; + } + | { + type: 'frequency'; + value: 'daily' | 'weekly' | 'instantly'; + trackSource: 'verbum-subscription-modal' | 'verbum-toggle'; + }; + +export interface VerbumComments { + loginPostMessage?: UserInfo; + siteId?: number; + postId?: number; + isAuthRequired?: boolean; + connectURL?: string; + logoutURL?: string; + homeURL?: string; + subscribeToComment?: boolean; + subscribeToBlog?: boolean; + mustLogIn?: boolean; + commentRegistration?: boolean; + requireNameEmail?: boolean; + jetpackAvatar?: string; + jetpackUsername?: string; + jetpackSignature?: string; + jetpackUserId?: number; + isJetpackCommentsLoggedIn?: boolean; + enableBlocks?: boolean; + enableSubscriptionModal?: boolean; + isJetpackComments?: boolean; + allowedBlocks: string[]; + currentLocale: string; + embedNonce: string; + verbumBundleUrl: string; + isRTL: boolean; + + /** + * Contains the time we started loading Highlander. + */ + fullyLoadedTime: number; +} + +export type EmailSubscriptionResponse = { + success: boolean; + subscribed: boolean; + subscription: { + blog_ID: string; + delivery_frequency: string; + status: string; + ts: string; + } | null; +}; + +export interface SimpleSubscribeModalProps { + closeModalHandler: () => void; + email: string; + setSubscribeModalStatus?: ( string ) => void; + subscribeModalStatus?: + | 'showed' + | 'hidden_cookies_disabled' + | 'hidden_subscribe_not_enabled' + | 'hidden_views_limit' + | 'hidden_already_subscribed'; + subscribeState?: string; + setSubscribeState?: ( boolean ) => void; + setHasIframe?: ( boolean ) => void; +} + +export type MailLoginData = { + service: string; + email?: string; + name?: string; + author?: string; + url?: string; +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/utils.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/utils.ts new file mode 100644 index 0000000000000..be912563a5379 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/src/utils.ts @@ -0,0 +1,254 @@ +import { translate } from './i18n'; +import { Facebook, Mail, WordPress } from './images'; +import type { UserInfo, VerbumComments } from './types'; + +/** + * Returns a string of class names from the arguments. + * @param {...any} args - The arguments to be passed to the function. + */ +export function classNames( ...args: Array< string | Record< string, boolean | string > > ) { + const result = []; + for ( let i = 0; i < args.length; i++ ) { + if ( typeof args[ i ] === 'object' ) { + result[ i ] = Object.keys( args[ i ] ) + .filter( key => args[ i ][ key ] ) + .join( ' ' ); + } else if ( args[ i ] ) { + result[ i ] = args[ i ]; + } + } + return result.join( ' ' ); +} + +export const serviceData = { + wordpress: { + cookieName: 'wpc_wpc', + name: 'WordPress.com', + popup: ',height=980,width=500', + icon: WordPress, + class: 'wordpress-login', + }, + facebook: { + cookieName: 'wpc_fbc', + name: 'Facebook', + popup: ',height=650,width=750', + icon: Facebook, + class: 'facebook-login', + }, + mail: { + name: translate( 'Email' ), + icon: Mail, + class: 'mail-login', + }, +}; + +export const canWeAccessCookies = () => { + // Is a WordPress cookie already set and can we read it? + if ( document.cookie.includes( 'wpc_' ) ) { + return true; + } + + // Can we set a cookie and read our own cookie? + document.cookie = 'verbum_test=1; SameSite=None; Secure'; + if ( document.cookie.includes( 'verbum_test' ) ) { + return true; + } + + return false; +}; + +/** + * Uses the current bundle's size and the time it took to download and execute to estimate connection speed. + */ +export function isFastConnection() { + // Hardcoding the size of the bundle. + const bytes = 30000; + const bytesPerMs = bytes / VerbumComments.fullyLoadedTime; + + /** + * This number is extremely inaccurate to measure connection speed. + * Because it contains execution time and the file we're using to measure to really small and has a lot of overhead. + * But it's excellent to measure what we want, how long it takes to download and execute JS. + */ + const bytesPerSecond = bytesPerMs * 1000; + + // this 15000 came from testing. It's the average of a fast connection. + return bytesPerSecond > 15000; +} + +/** + * Get how many times the user saw the subscription modal. + * + * @param {number} uid - The user ID associated with the subscription modal. + * @returns {number} - The number of times the user saw the subscription modal. + */ +export function getSubscriptionModalViewCount( uid: number ) { + const cookieName = 'verbum_subscription_modal_counter_' + uid; + const cookieValue = document.cookie + .split( '; ' ) + .find( row => row.startsWith( `${ cookieName }=` ) ) + ?.split( '=' )[ 1 ]; + return cookieValue ? parseInt( cookieValue ) : 0; +} + +/** + * Set the view count for the subscription modal in a cookie. + * + * @param {number} count - The view count to be set. + * @param {number} uid - The user ID associated with the subscription modal. + * @returns {void} + */ +export function setSubscriptionModalViewCount( count: number, uid: number ) { + const cookieName = 'verbum_subscription_modal_counter_' + uid; + document.cookie = `${ cookieName }=${ count }; SameSite=None; Secure; path=/`; +} +/** + * We checked if the subscribe to blog is enabled, if the user is not already subscribed, + * and if the user already view this modal > 5 times. + * + * @param {boolean} alreadySubscribed - boolean + * @param {number} uid - The user ID associated with the subscription modal. + * @returns {string} - The string that will be used to determine if the modal should be shown. + */ +export function shouldShowSubscriptionModal( alreadySubscribed: boolean, uid: number ) { + const { subscribeToBlog } = VerbumComments; + + if ( ! canWeAccessCookies() ) { + return 'hidden_cookies_disabled'; + } + if ( ! subscribeToBlog ) { + return 'hidden_subscribe_not_enabled'; + } + if ( alreadySubscribed ) { + return 'hidden_already_subscribed'; + } + + // Check if the user already saw the modal 5 times. + const modalViewCounter = getSubscriptionModalViewCount( uid ); + if ( modalViewCounter > 5 ) { + return 'hidden_views_limit'; + } + + return 'showed'; +} + +/** + * Wraps a textarea with a setter that calls onChange when the value changes. + * + * @param {HTMLTextAreaElement} textarea - the textarea to wrap. + * @param {event} onChange - the callback to call when .value is set. + * @returns {object} the textarea with a reactive .value setter. + */ +export function makeReactiveTextArea( + textarea: HTMLTextAreaElement, + onChange: ( value: string ) => void +) { + return { + type: textarea.type, + parentNode: textarea.parentNode, + nextSibling: textarea.nextSibling, + style: textarea.style, + set value( value: string ) { + textarea.value = value; + onChange( value ); + }, + get value(): string { + return textarea.value; + }, + }; +} + +/** + * Check to see if the editor content is empty. + * Used by the textarea and editor components. + * + * @param {string} html - The contents of the comment textarea. + * @returns {boolean} indicating if the editor content is empty. + */ +export function isEmptyEditor( html: string ) { + const parser = new DOMParser(); + const document = parser.parseFromString( html, 'text/html' ); + return document.documentElement.textContent.trim() === '' && ! document.querySelector( 'img' ); +} + +/** + * Retrieve domain for user cookie. + */ +export const addWordPressDomain = window.location.hostname.endsWith( '.wordpress.com' ) + ? ' Domain=.wordpress.com' + : ''; + +/** + * Set the user info in the cookie. + * + * @param {UserInfo} userData - the user info to set. + */ +export const setUserInfoCookie = ( userData: UserInfo ) => { + let cookieName: string; + const { service } = userData; + + if ( service === 'wordpress' ) { + cookieName = 'wpc_wpc'; + } else if ( service === 'facebook' ) { + cookieName = 'wpc_fbc'; + } else if ( service === 'guest' ) { + cookieName = 'wpc_guest'; + } + + const cookieData = new URLSearchParams( { + ...userData, + ...( userData?.avatar && { + avatar: encodeURIComponent( userData.avatar ), + } ), + ...( userData?.email && { email: encodeURIComponent( userData.email ) } ), + ...( userData?.logout_url && { + logout_url: encodeURIComponent( userData.logout_url ), + } ), + ...( userData?.uid && { uid: userData.uid.toString() } ), + ...( userData?.url && { url: encodeURIComponent( userData.url ) } ), + } ).toString(); + + document.cookie = `${ cookieName }=${ cookieData }; path=/; SameSite=None; Secure=True;${ addWordPressDomain }`; +}; + +/** + * Get the user info from the cookie. + * + * @returns {UserInfo} the user info. + */ +export const getUserInfoCookie = () => { + let userData: UserInfo = { service: 'guest' }; + const cookies = document.cookie.split( '; ' ); + + for ( let i = 0; i < cookies.length; i++ ) { + const cookie = cookies[ i ].trim(); + if ( cookie.startsWith( 'wpc_' ) ) { + const service = cookie.slice( 0, 7 ); + + let serviceName = 'guest'; + if ( service === 'wpc_wpc' ) { + serviceName = 'wordpress'; + } else if ( service === 'wpc_fbc' ) { + serviceName = 'facebook'; + } + + const data = cookie.slice( 8 ); + userData = data && { + service: serviceName, + ...Object.fromEntries( new URLSearchParams( decodeURIComponent( data ) ) ), + }; + + if ( serviceName === 'wordpress' ) { + const avatarUrl = new URL( userData.avatar ); + userData.avatar = avatarUrl.origin + avatarUrl.pathname + '?s=64'; + } + } + } + return userData; +}; + +export const hasSubscriptionOptionsVisible = () => + VerbumComments.subscribeToComment || VerbumComments.subscribeToBlog; + +export const isAuthRequired = () => + VerbumComments.requireNameEmail || VerbumComments.commentRegistration; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/00_confirm_sandboxed.test.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/00_confirm_sandboxed.test.ts new file mode 100644 index 0000000000000..3b48b00116d3f --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/00_confirm_sandboxed.test.ts @@ -0,0 +1,7 @@ +import { test, expect } from '@playwright/test'; + +test( 'Confirm current machine is sandboxed', async ( { page } ) => { + await page.goto( 'https://public-api.wordpress.com/?amisandboxed' ); + + await expect( page.getByText( 'Yes, you are currently sandboxing this API.' ) ).toBeVisible(); +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/atomic/author_must_fill_name_and_email.test.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/atomic/author_must_fill_name_and_email.test.ts new file mode 100644 index 0000000000000..e3b656904353a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/atomic/author_must_fill_name_and_email.test.ts @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; +import sites from '../sites'; +import { createRandomComment, createRandomEmail, createRandomName } from '../utils'; + +test( 'Atomic: author_must_fill_name_and_email', async ( { page } ) => { + const randomComment = createRandomComment(); + const randomEmail = createRandomEmail(); + const randomName = createRandomName(); + + await page.goto( sites.atomic.author_must_fill_name_and_email + '#respond' ); + + await page + .frameLocator( 'iframe[name="jetpack_remote_comment"]' ) + .getByPlaceholder( 'Write a Comment...' ) + .type( randomComment ); + await page + .frameLocator( 'iframe[name="jetpack_remote_comment"]' ) + .getByPlaceholder( 'Email (Address never made' ) + .fill( randomEmail ); + await page + .frameLocator( 'iframe[name="jetpack_remote_comment"]' ) + .getByPlaceholder( 'Name' ) + .fill( randomName ); + await page + .frameLocator( 'iframe[name="jetpack_remote_comment"]' ) + .getByRole( 'button', { name: 'Reply' } ) + .click(); + + await page.waitForLoadState( 'domcontentloaded' ); + await expect( page.getByText( randomComment ) ).toBeVisible(); + await expect( page.getByText( randomName ) ).toBeVisible(); + await expect( page.getByText( randomEmail ) ).not.toBeVisible(); +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/atomic/open_comments_for_everyone.test.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/atomic/open_comments_for_everyone.test.ts new file mode 100644 index 0000000000000..e71234195b329 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/atomic/open_comments_for_everyone.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; +import sites from '../sites'; +import { createRandomComment } from '../utils'; + +test( 'Atomic: open_comments_for_everyone - Anonymous', async ( { page } ) => { + const randomComment = createRandomComment(); + + await page.goto( sites.atomic.open_comments_for_everyone + '#respond' ); + const existingAnonComments = await page.getByText( 'Anonymous' ).count(); + + await page + .frameLocator( 'iframe[name="jetpack_remote_comment"]' ) + .getByPlaceholder( 'Write a Comment...' ) + .click(); + await page + .frameLocator( 'iframe[name="jetpack_remote_comment"]' ) + .getByPlaceholder( 'Write a Comment...' ) + .fill( randomComment ); + + await expect( + page + .frameLocator( 'iframe[name="jetpack_remote_comment"]' ) + .getByText( 'Leave a reply. (log in optional)' ) + ).toBeVisible(); + await page + .frameLocator( 'iframe[name="jetpack_remote_comment"]' ) + .getByRole( 'button', { name: 'Reply' } ) + .click(); + await page.waitForLoadState( 'domcontentloaded' ); + await expect( page.getByText( randomComment ) ).toBeVisible(); + await expect( page.getByText( 'Anonymous' ) ).toHaveCount( existingAnonComments + 1 ); +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/author_must_fill_name_and_email.test.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/author_must_fill_name_and_email.test.ts new file mode 100644 index 0000000000000..54f8fb48f5082 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/author_must_fill_name_and_email.test.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; +import sites from '../sites'; +import { createRandomComment, createRandomEmail, createRandomName } from '../utils'; + +test( 'Simple: author_must_fill_name_and_email', async ( { page } ) => { + const randomComment = createRandomComment(); + const randomEmail = createRandomEmail(); + const randomName = createRandomName(); + + await page.goto( sites.simple.author_must_fill_name_and_email + '#respond' ); + + // Reply button should be disabled before log in. + await expect( page.locator( '#comment-submit' ) ).toBeDisabled(); + + await page.getByPlaceholder( 'Write a Comment...' ).type( randomComment ); + await page.getByPlaceholder( 'Email (Address never made' ).fill( randomEmail ); + await page.getByPlaceholder( 'Name' ).fill( randomName ); + await page.getByRole( 'button', { name: 'Reply' } ).click(); + await expect( page.getByRole( 'heading', { name: 'Never miss a beat!' } ) ).toBeVisible(); + await expect( page.getByRole( 'textbox', { name: 'Enter your email address' } ) ).toHaveValue( + randomEmail + ); + + await page.getByRole( 'button', { name: 'Close' } ).click(); + await page.waitForLoadState( 'domcontentloaded' ); + + await expect( page.getByText( randomComment ) ).toBeVisible(); +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/open_comments_for_everyone.test.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/open_comments_for_everyone.test.ts new file mode 100644 index 0000000000000..d65ccfbe65905 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/open_comments_for_everyone.test.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; +import sites from '../sites'; +import { createRandomComment } from '../utils'; + +test( 'Simple: open_comments_for_everyone - Anonymous', async ( { page } ) => { + const randomComment = createRandomComment(); + + await page.goto( sites.simple.open_comments_for_everyone + '#respond' ); + const existingAnonComments = await page.getByText( 'Anonymous' ).count(); + await page.goto( sites.simple.open_comments_for_everyone + '#respond' ); + await page.getByPlaceholder( 'Write a Comment...' ).click(); + await page.getByPlaceholder( 'Write a Comment...' ).type( randomComment ); + await expect( page.getByRole( 'button', { name: 'Reply' } ) ).toBeVisible(); + await expect( page.locator( '#comment-form__verbum' ) ).toContainText( + 'Leave a reply. (log in optional)' + ); + await page.getByRole( 'button', { name: 'Reply' } ).click(); + await expect( page.locator( '#comment-form__verbum' ) ).toContainText( 'Never miss a beat!' ); + await page.getByRole( 'button', { name: 'Close' } ).click(); + await expect( page.getByText( randomComment ) ).toBeVisible(); + await expect( page.getByText( 'Anonymous' ) ).toHaveCount( existingAnonComments + 1 ); +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/user_must_be_registered_and_logged_in_to_comment.test.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/user_must_be_registered_and_logged_in_to_comment.test.ts new file mode 100644 index 0000000000000..8a26861b6af05 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/simple/user_must_be_registered_and_logged_in_to_comment.test.ts @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; +import sites from '../sites'; +import { createRandomComment, testingUser } from '../utils'; + +test( 'Simple: user_must_be_registered_and_logged_in_to_comment - Anonymous', async ( { + page, +} ) => { + const randomComment = createRandomComment(); + + await page.goto( sites.simple.user_must_be_registered_and_logged_in_to_comment + '#respond' ); + await page.getByPlaceholder( 'Write a Comment...' ).click(); + await page.getByPlaceholder( 'Write a Comment...' ).type( randomComment ); + await expect( page.locator( '#comment-form__verbum' ) ).toContainText( + 'Log in to leave a reply.' + ); + // Reply button should be disabled before log in. + await expect( page.locator( '#comment-submit' ) ).toBeDisabled(); + + // + const loginPopup = page.waitForEvent( 'popup' ); + await page.getByRole( 'button' ).first().click(); + const loginPopupPage = await loginPopup; + await loginPopupPage.getByLabel( 'Email Address or Username' ).fill( testingUser.username ); + await loginPopupPage.getByRole( 'button', { name: 'Continue', exact: true } ).click(); + await loginPopupPage.getByLabel( 'Password' ).fill( testingUser.password ); + await loginPopupPage.getByRole( 'button', { name: 'Log In' } ).click(); + // + + await expect( page.locator( '#comment-form__verbum' ) ).toContainText( + `${ testingUser.username } - Logged in via WordPress.com` + ); + await page.getByRole( 'button', { name: 'Reply' } ).click(); + await page.waitForLoadState( 'domcontentloaded' ); + + await expect( page.locator( '#comment-form__verbum' ) ).toContainText( 'Never miss a beat!' ); + await page.getByRole( 'button', { name: 'Close' } ).nth( 2 ).click(); + await page.waitForLoadState( 'domcontentloaded' ); + + await expect( page.getByText( randomComment ) ).toBeVisible(); +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/sites.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/sites.ts new file mode 100644 index 0000000000000..7d756267dc78b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/sites.ts @@ -0,0 +1,18 @@ +export default { + simple: { + open_comments_for_everyone: + 'https://e2esiteopencommentstoeveryone.wordpress.com/2023/12/10/hello-world/', + author_must_fill_name_and_email: + 'https://e2ecommentauthormustfilloutnameandemail.wordpress.com/2023/12/10/hello-world/', + user_must_be_registered_and_logged_in_to_comment: + 'https://e2eusersmustberegisteredandloggedintocomment.wordpress.com/2023/12/10/hello-world/', + }, + atomic: { + open_comments_for_everyone: + 'https://e2esiteopencommentstoeveryoneatomic.wpcomstaging.com/2023/12/10/hello-world/', + author_must_fill_name_and_email: + 'https://e2ecommentauthormustfilloutnameandemailatomic.wpcomstaging.com/2023/12/10/hello-world/', + user_must_be_registered_and_logged_in_to_comment: + 'https://e2eusersmustberegisteredandloggedintocommentatomic.wpcomstaging.com/2023/12/10/hello-world/', + }, +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/utils.ts b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/utils.ts new file mode 100644 index 0000000000000..eb7d843a2cf6f --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tests/utils.ts @@ -0,0 +1,86 @@ +/** + * Create a random email + */ +export function createRandomEmail() { + return `${ Math.random().toString( 36 ).substring( 7 ) }@example.com`; +} +/** + * Create a random name + */ +export function createRandomName() { + return ` ${ Math.random().toString( 36 ).substring( 7 ) } ${ Math.random() + .toString( 36 ) + .substring( 7 ) }`; +} + +const commonWords = [ + 'he', + 'a', + 'one', + 'all', + 'an', + 'each', + 'other', + 'many', + 'some', + 'two', + 'more', + 'long', + 'new', + 'little', + 'most', + 'good', + 'great', + 'right', + 'mean', + 'old', + 'any', + 'same', + 'three', + 'small', + 'another', + 'large', + 'big', + 'even', + 'such', + 'different', + 'kind', + 'still', + 'high', + 'every', + 'own', + 'light', + 'left', + 'few', + 'next', + 'hard', + 'both', + 'important', + 'white', + 'four', + 'second', + 'enough', + 'above', + 'young', +]; + +/** + * Create a random comment + */ +export function createRandomComment() { + const sentence = []; + for ( let i = 0; i < 15; i++ ) { + sentence.push( commonWords[ Math.floor( Math.random() * commonWords.length ) ] ); + } + return sentence.join( ' ' ); +} + +/** + * Instead of the complexity of managing secrets. We can use an empty testing account. + */ +export const testingUser = { + userId: '243752070', + username: 'emptyaccountwithoutsites', + email: 'emptyaccountwithoutsites@gmail.com', + password: 'Wi^^yN54ee0rNXyBmhHtAO6*', +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tsconfig.json b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tsconfig.json new file mode 100644 index 0000000000000..f23743b4fed08 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/verbum-comments/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "allowJs": true, + "checkJs": true, + "jsx": "react-jsx", + "jsxImportSource": "preact" + }, + "typeRoots": [ "./src/components", "./src/editor", "./node_modules/@types" ], + "include": [ "node_modules/vite/client.d.ts", "**/*" ] +} diff --git a/projects/packages/jetpack-mu-wpcom/verbum.webpack.config.js b/projects/packages/jetpack-mu-wpcom/verbum.webpack.config.js new file mode 100644 index 0000000000000..808d3c12d884e --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/verbum.webpack.config.js @@ -0,0 +1,99 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const path = require( 'path' ); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const jetpackConfig = require( '@automattic/jetpack-webpack-config/webpack' ); +const CopyPlugin = require( 'copy-webpack-plugin' ); +const webpack = require( 'webpack' ); + +const babelOpts = { + plugins: [ + [ + '@babel/plugin-transform-react-jsx', + { + pragma: 'h', + pragmaFrag: 'Fragment', + }, + ], + ], + presets: [ [ '@automattic/jetpack-webpack-config/babel/preset' ] ], +}; + +module.exports = { + entry: { + 'verbum-comments': './src/features/verbum-comments/src/index.tsx', + }, + mode: jetpackConfig.mode, + devtool: jetpackConfig.devtool, + output: { + ...jetpackConfig.output, + filename: '[name]/[name].js', + path: path.resolve( __dirname, 'src/build' ), + environment: { + module: true, + dynamicImport: true, + }, + }, + optimization: { + ...jetpackConfig.optimization, + }, + resolve: { + ...jetpackConfig.resolve, + }, + node: false, + plugins: [ + ...jetpackConfig.StandardPlugins( { + DependencyExtractionPlugin: { injectPolyfill: false }, + MiniCssExtractPlugin: { filename: '[name]/[name].css' }, + } ), + new webpack.ProvidePlugin( { + h: [ 'preact', 'h' ], + Fragment: [ 'preact', 'Fragment' ], + } ), + new CopyPlugin( { + patterns: [ + { + from: './src/features/verbum-comments/class-verbum-comments.php', + to: './verbum-comments', + }, + { + from: './src/features/verbum-comments/assets', + to: './verbum-comments/assets', + }, + ], + } ), + ], + module: { + strictExportPresence: true, + rules: [ + // Transpile JavaScript. + jetpackConfig.TranspileRule( { + exclude: /node_modules\//, + babelOpts, + } ), + + // Transpile @automattic/jetpack-* in node_modules too. + jetpackConfig.TranspileRule( { + includeNodeModules: [ '@automattic/jetpack-' ], + } ), + + // preact has some `__` internal methods, which confuse i18n-check-webpack-plugin. Hack around that. + jetpackConfig.TranspileRule( { + includeNodeModules: [ 'preact' ], + babelOpts: { + configFile: false, + plugins: [ [ 'babel-plugin-transform-rename-properties', { rename: { __: '__ǃ' } } ] ], + presets: [], + }, + } ), + + // Handle CSS. + jetpackConfig.CssRule( { + extensions: [ 'css', 'scss' ], + extraLoaders: [ 'sass-loader' ], + } ), + + // Handle images. + jetpackConfig.FileRule(), + ], + }, +}; diff --git a/projects/packages/jetpack-mu-wpcom/webpack.config.js b/projects/packages/jetpack-mu-wpcom/webpack.config.js index 6648dbca59424..3201a45e87dcf 100644 --- a/projects/packages/jetpack-mu-wpcom/webpack.config.js +++ b/projects/packages/jetpack-mu-wpcom/webpack.config.js @@ -2,50 +2,56 @@ const path = require( 'path' ); // eslint-disable-next-line @typescript-eslint/no-var-requires const jetpackConfig = require( '@automattic/jetpack-webpack-config/webpack' ); +const verbumConfig = require( './verbum.webpack.config.js' ); -module.exports = { - entry: { - 'error-reporting': './src/features/error-reporting/index.js', - 'block-theme-previews': './src/features/block-theme-previews/index.js', - }, - mode: jetpackConfig.mode, - devtool: jetpackConfig.devtool, - output: { - ...jetpackConfig.output, - filename: '[name]/[name].js', - path: path.resolve( __dirname, 'src/build' ), - }, - optimization: { - ...jetpackConfig.optimization, - }, - resolve: { - ...jetpackConfig.resolve, - }, - node: false, - plugins: [ - ...jetpackConfig.StandardPlugins( { MiniCssExtractPlugin: { filename: '[name]/[name].css' } } ), - ], - module: { - strictExportPresence: true, - rules: [ - // Transpile JavaScript. - jetpackConfig.TranspileRule( { - exclude: /node_modules\//, +module.exports = [ + verbumConfig, + { + entry: { + 'error-reporting': './src/features/error-reporting/index.js', + 'block-theme-previews': './src/features/block-theme-previews/index.js', + }, + mode: jetpackConfig.mode, + devtool: jetpackConfig.devtool, + output: { + ...jetpackConfig.output, + filename: '[name]/[name].js', + path: path.resolve( __dirname, 'src/build' ), + }, + optimization: { + ...jetpackConfig.optimization, + }, + resolve: { + ...jetpackConfig.resolve, + }, + node: false, + plugins: [ + ...jetpackConfig.StandardPlugins( { + MiniCssExtractPlugin: { filename: '[name]/[name].css' }, } ), + ], + module: { + strictExportPresence: true, + rules: [ + // Transpile JavaScript. + jetpackConfig.TranspileRule( { + exclude: /node_modules\//, + } ), - // Transpile @automattic/jetpack-* in node_modules too. - jetpackConfig.TranspileRule( { - includeNodeModules: [ '@automattic/jetpack-' ], - } ), + // Transpile @automattic/jetpack-* in node_modules too. + jetpackConfig.TranspileRule( { + includeNodeModules: [ '@automattic/jetpack-' ], + } ), - // Handle CSS. - jetpackConfig.CssRule( { - extensions: [ 'css', 'scss' ], - extraLoaders: [ 'sass-loader' ], - } ), + // Handle CSS. + jetpackConfig.CssRule( { + extensions: [ 'css', 'scss' ], + extraLoaders: [ 'sass-loader' ], + } ), - // Handle images. - jetpackConfig.FileRule(), - ], + // Handle images. + jetpackConfig.FileRule(), + ], + }, }, -}; +]; diff --git a/projects/plugins/mu-wpcom-plugin/changelog/add-verbum-comment-into-jetpack-mu-wpcom b/projects/plugins/mu-wpcom-plugin/changelog/add-verbum-comment-into-jetpack-mu-wpcom new file mode 100644 index 0000000000000..9aa70e3ec1f75 --- /dev/null +++ b/projects/plugins/mu-wpcom-plugin/changelog/add-verbum-comment-into-jetpack-mu-wpcom @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Updated composer.lock. + + diff --git a/projects/plugins/mu-wpcom-plugin/composer.json b/projects/plugins/mu-wpcom-plugin/composer.json index 4c3bbac608a8b..018ee37cea969 100644 --- a/projects/plugins/mu-wpcom-plugin/composer.json +++ b/projects/plugins/mu-wpcom-plugin/composer.json @@ -46,6 +46,6 @@ ] }, "config": { - "autoloader-suffix": "d9d132a783958a00a2c7cccff60ca42d_jetpack_mu_wpcom_pluginⓥ2_0_14" + "autoloader-suffix": "d9d132a783958a00a2c7cccff60ca42d_jetpack_mu_wpcom_pluginⓥ2_0_15_alpha" } } diff --git a/projects/plugins/mu-wpcom-plugin/composer.lock b/projects/plugins/mu-wpcom-plugin/composer.lock index 463570cea7666..2688170fb11dd 100644 --- a/projects/plugins/mu-wpcom-plugin/composer.lock +++ b/projects/plugins/mu-wpcom-plugin/composer.lock @@ -12,7 +12,7 @@ "dist": { "type": "path", "url": "../../packages/jetpack-mu-wpcom", - "reference": "e680986f807f26c9aea852decad5b9f6fff63c30" + "reference": "4cbfebf77f0369a8c15b1a745c1e9d481b93315b" }, "require": { "php": ">=7.0" @@ -33,7 +33,7 @@ }, "autotagger": true, "branch-alias": { - "dev-trunk": "5.9.x-dev" + "dev-trunk": "5.10.x-dev" }, "textdomain": "jetpack-mu-wpcom", "version-constants": { diff --git a/projects/plugins/mu-wpcom-plugin/mu-wpcom-plugin.php b/projects/plugins/mu-wpcom-plugin/mu-wpcom-plugin.php index 2392e33d8f904..876b06c1b4f5a 100644 --- a/projects/plugins/mu-wpcom-plugin/mu-wpcom-plugin.php +++ b/projects/plugins/mu-wpcom-plugin/mu-wpcom-plugin.php @@ -3,7 +3,7 @@ * * Plugin Name: WordPress.com Features * Description: Test plugin for the jetpack-mu-wpcom package - * Version: 2.0.14 + * Version: 2.0.15-alpha * Author: Automattic * License: GPLv2 or later * Text Domain: jetpack-mu-wpcom-plugin diff --git a/projects/plugins/mu-wpcom-plugin/package.json b/projects/plugins/mu-wpcom-plugin/package.json index 9d0f4a1b56990..e9d6d6155151e 100644 --- a/projects/plugins/mu-wpcom-plugin/package.json +++ b/projects/plugins/mu-wpcom-plugin/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@automattic/jetpack-mu-wpcom-plugin", - "version": "2.0.14", + "version": "2.0.15-alpha", "description": "Test plugin for the jetpack-mu-wpcom package", "homepage": "https://jetpack.com", "bugs": {