PublicEmailController.php
1 month ago
PublicEmailRoute.php
1 month ago
ShareMetadataBuilder.php
2 months ago
ShareVisibility.php
2 months ago
index.php
2 months ago
ShareMetadataBuilder.php
279 lines
| 1 | <?php declare(strict_types = 1); |
| 2 | |
| 3 | namespace MailPoet\Newsletter\Sharing; |
| 4 | |
| 5 | if (!defined('ABSPATH')) exit; |
| 6 | |
| 7 | |
| 8 | use MailPoet\Entities\NewsletterEntity; |
| 9 | use MailPoet\Util\pQuery\pQuery; |
| 10 | use MailPoet\WP\Functions as WPFunctions; |
| 11 | |
| 12 | class ShareMetadataBuilder { |
| 13 | private const COPY_BUTTON_ATTRIBUTE = 'data-mailpoet-share-copy'; |
| 14 | |
| 15 | /** @var WPFunctions */ |
| 16 | private $wp; |
| 17 | |
| 18 | public function __construct( |
| 19 | WPFunctions $wp |
| 20 | ) { |
| 21 | $this->wp = $wp; |
| 22 | } |
| 23 | |
| 24 | public function injectMetadata(string $html, NewsletterEntity $newsletter, string $canonicalUrl): string { |
| 25 | $metadata = $this->buildMetadata($html, $newsletter, $canonicalUrl); |
| 26 | $position = stripos($html, '</head>'); |
| 27 | if ($position === false) { |
| 28 | return $metadata . "\n" . $html; |
| 29 | } |
| 30 | |
| 31 | // Inject before </head> using substr_replace rather than preg_replace so |
| 32 | // that dollar-digit sequences in the newsletter subject (e.g. "Save $50") |
| 33 | // are not interpreted as backreferences in the replacement string. |
| 34 | return substr_replace($html, $metadata . "\n", $position, 0); |
| 35 | } |
| 36 | |
| 37 | /** |
| 38 | * Inject the share toolbar right after <body>. Pass $replaceStateUrl when the |
| 39 | * current URL carries subscriber-specific data (e.g. a tokenised view-in-browser |
| 40 | * link) — the toolbar's JS will rewrite the address bar to that URL on load so |
| 41 | * the subscriber's token isn't shared by accident if they copy from the bar |
| 42 | * or use their browser's share menu. |
| 43 | */ |
| 44 | public function injectShareToolbar(string $html, NewsletterEntity $newsletter, string $canonicalUrl, string $replaceStateUrl = ''): string { |
| 45 | return $this->injectAfterBodyOpen($html, $this->buildShareToolbar($newsletter, $canonicalUrl, $replaceStateUrl)); |
| 46 | } |
| 47 | |
| 48 | public function buildMetadata(string $html, NewsletterEntity $newsletter, string $canonicalUrl): string { |
| 49 | $tags = [ |
| 50 | $this->metaTag('name', 'robots', 'noindex, nofollow'), |
| 51 | ]; |
| 52 | |
| 53 | $title = $newsletter->getCampaignNameOrSubject(); |
| 54 | if ($title === '') { |
| 55 | return implode("\n", $tags); |
| 56 | } |
| 57 | |
| 58 | $description = trim($newsletter->getPreheader()); |
| 59 | $image = $this->findFirstContentImage($html); |
| 60 | |
| 61 | $tags[] = $this->metaTag('property', 'og:title', $title); |
| 62 | $tags[] = $this->metaTag('property', 'og:type', 'website'); |
| 63 | $tags[] = $this->metaTag('property', 'og:url', $canonicalUrl); |
| 64 | $tags[] = $this->metaTag('name', 'twitter:card', $image ? 'summary_large_image' : 'summary'); |
| 65 | $tags[] = $this->metaTag('name', 'twitter:title', $title); |
| 66 | |
| 67 | if ($description !== '') { |
| 68 | $tags[] = $this->metaTag('property', 'og:description', $description); |
| 69 | $tags[] = $this->metaTag('name', 'twitter:description', $description); |
| 70 | } |
| 71 | |
| 72 | if ($image) { |
| 73 | $tags[] = $this->metaTag('property', 'og:image', $image['src']); |
| 74 | $tags[] = $this->metaTag('name', 'twitter:image', $image['src']); |
| 75 | if ($image['alt'] !== '') { |
| 76 | $tags[] = $this->metaTag('property', 'og:image:alt', $image['alt']); |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | return implode("\n", $tags); |
| 81 | } |
| 82 | |
| 83 | private function buildShareToolbar(NewsletterEntity $newsletter, string $canonicalUrl, string $replaceStateUrl): string { |
| 84 | $title = __('Share this email', 'mailpoet'); |
| 85 | $description = __('Copy the public link or share it on your favorite channel.', 'mailpoet'); |
| 86 | $emailTitle = $newsletter->getCampaignNameOrSubject() ?: __('Email', 'mailpoet'); |
| 87 | $copyLabel = __('Copy link', 'mailpoet'); |
| 88 | $shareLabel = _x('Share', 'Web Share button label', 'mailpoet'); |
| 89 | $encodedUrl = rawurlencode($canonicalUrl); |
| 90 | $encodedTitle = rawurlencode($emailTitle); |
| 91 | |
| 92 | $hostAttrs = ' data-mailpoet-share-host'; |
| 93 | if ($replaceStateUrl !== '') { |
| 94 | $hostAttrs .= ' data-mailpoet-share-replace-state="' . $this->wp->escAttr($replaceStateUrl) . '"'; |
| 95 | } |
| 96 | |
| 97 | $toolbar = $this->buildShareToolbarStyles() |
| 98 | . '<div class="mailpoet-share-toolbar" role="region" aria-label="' . $this->wp->escAttr($title) . '">' |
| 99 | . '<div class="mailpoet-share-toolbar__inner">' |
| 100 | . '<div class="mailpoet-share-toolbar__copy">' |
| 101 | . '<strong>' . $this->wp->escHtml($title) . '</strong>' |
| 102 | . '<span>' . $this->wp->escHtml($description) . '</span>' |
| 103 | . '</div>' |
| 104 | . '<div class="mailpoet-share-toolbar__controls">' |
| 105 | . '<div class="mailpoet-share-toolbar__url-row">' |
| 106 | . '<input class="mailpoet-share-toolbar__url components-text-control__input" type="url" readonly aria-label="' . $this->wp->escAttr(__('Public share link', 'mailpoet')) . '" value="' . $this->wp->escAttr($canonicalUrl) . '" />' |
| 107 | . $this->buildShareToolbarCopyButton($canonicalUrl, $copyLabel) |
| 108 | . '</div>' |
| 109 | . '<div class="mailpoet-share-toolbar__actions">' |
| 110 | . '<button type="button" class="mailpoet-share-toolbar__button mailpoet-share-toolbar__web-share components-button is-secondary" aria-label="' . $this->wp->escAttr($shareLabel) . '" data-mailpoet-web-share="' . $this->wp->escAttr($canonicalUrl) . '" data-mailpoet-web-share-title="' . $this->wp->escAttr($emailTitle) . '">' |
| 111 | . $this->buildShareToolbarActionContent('share', $shareLabel) |
| 112 | . '</button>' |
| 113 | . $this->buildShareToolbarLink('facebook', $this->wp->escUrl('https://www.facebook.com/sharer/sharer.php?u=' . $encodedUrl), 'Facebook', true) |
| 114 | . $this->buildShareToolbarLink('x', $this->wp->escUrl('https://twitter.com/intent/tweet?url=' . $encodedUrl . '&text=' . $encodedTitle), 'X', true) |
| 115 | . $this->buildShareToolbarLink('whatsapp', $this->wp->escUrl('https://api.whatsapp.com/send?text=' . rawurlencode(trim($emailTitle . ' ' . $canonicalUrl))), 'WhatsApp', true) |
| 116 | . $this->buildShareToolbarLink('email', $this->wp->escUrl('mailto:?subject=' . $encodedTitle . '&body=' . $encodedUrl), __('Email', 'mailpoet'), true) |
| 117 | . '</div></div></div></div>'; |
| 118 | |
| 119 | return '<div' . $hostAttrs . '>' . $toolbar . '</div>' . $this->buildShareToolbarScript(); |
| 120 | } |
| 121 | |
| 122 | private function buildShareToolbarCopyButton(string $canonicalUrl, string $copyLabel): string { |
| 123 | return '<button type="button" class="mailpoet-share-toolbar__button mailpoet-share-toolbar__copy-button components-button is-secondary" aria-label="' . $this->wp->escAttr($copyLabel) . '" ' . self::COPY_BUTTON_ATTRIBUTE . '="' . $this->wp->escAttr($canonicalUrl) . '" data-mailpoet-share-copied-label="' . $this->wp->escAttr(__('Copied', 'mailpoet')) . '">' |
| 124 | . $this->buildShareToolbarActionContent('copy', $copyLabel) |
| 125 | . '</button>'; |
| 126 | } |
| 127 | |
| 128 | private function buildShareToolbarLink(string $iconName, string $url, string $label, bool $opensNewTab): string { |
| 129 | $isSocialLink = in_array($iconName, ['facebook', 'x', 'whatsapp', 'email'], true); |
| 130 | $className = $isSocialLink |
| 131 | ? 'mailpoet-share-toolbar__link mailpoet-share-toolbar__social-button mailpoet-share-toolbar__social-button--' . $this->wp->escAttr($iconName) . ' components-button' |
| 132 | : 'mailpoet-share-toolbar__link components-button is-secondary'; |
| 133 | |
| 134 | return '<a class="' . $className . '" href="' . $url . '" aria-label="' . $this->wp->escAttr($label) . '"' |
| 135 | . ($opensNewTab ? ' target="_blank" rel="noopener noreferrer"' : '') |
| 136 | . '>' |
| 137 | . $this->buildShareToolbarActionContent($iconName, $label) |
| 138 | . '</a>'; |
| 139 | } |
| 140 | |
| 141 | private function buildShareToolbarActionContent(string $iconName, string $label): string { |
| 142 | return $this->buildShareToolbarIcon($iconName) |
| 143 | . '<span data-mailpoet-share-label aria-live="polite">' . $this->wp->escHtml($label) . '</span>'; |
| 144 | } |
| 145 | |
| 146 | private function buildShareToolbarIcon(string $iconName): string { |
| 147 | $icons = [ |
| 148 | 'copy' => '<path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1Zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2Zm0 16H8V7h11v14Z" />', |
| 149 | 'share' => '<path d="M18 16.1c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11A2.99 2.99 0 1 0 15 5c0 .24.04.47.09.7L8.04 9.81A3 3 0 1 0 8.04 14.2l7.12 4.18c-.05.2-.07.41-.07.62a2.91 2.91 0 1 0 2.91-2.9Z" />', |
| 150 | 'facebook' => '<path d="M14 8.5V6.75c0-.5.4-.75.85-.75H17V2.5h-3.1C10.83 2.5 9 4.33 9 7.2v1.3H6v3.75h3V22h5v-9.75h3.25l.55-3.75H14Z" />', |
| 151 | 'x' => '<path d="M13.7 10.6 21.4 2h-1.8l-6.7 7.5L7.6 2H1.5l8.1 11.5L1.5 22h1.8l7.1-7.8 5.7 7.8h6.1l-8.5-11.4Zm-2.5 2.8-.8-1.1L3.9 3.4h2.8l5.2 7.2.8 1.1 6.9 9h-2.8l-5.6-7.3Z" />', |
| 152 | 'whatsapp' => '<path d="M12.04 2C6.58 2 2.15 6.43 2.15 11.9c0 1.75.46 3.45 1.33 4.95L2 22l5.29-1.39a9.85 9.85 0 0 0 4.75 1.21h.01c5.46 0 9.9-4.43 9.9-9.89A9.9 9.9 0 0 0 12.04 2Zm5.82 14.2c-.25.7-1.45 1.34-2.02 1.43-.52.08-1.18.12-1.9-.12-.44-.14-1-.33-1.72-.64-3.02-1.3-4.99-4.33-5.14-4.53-.15-.2-1.23-1.64-1.23-3.13 0-1.49.78-2.22 1.06-2.52.28-.3.61-.37.82-.37h.59c.19 0 .44-.07.69.53.25.6.85 2.08.92 2.23.08.15.13.33.03.53-.1.2-.15.33-.3.51-.15.18-.32.4-.45.54-.15.15-.31.31-.13.61.18.3.8 1.31 1.71 2.12 1.18 1.05 2.17 1.37 2.47 1.52.3.15.48.13.66-.08.18-.2.76-.89.97-1.2.2-.3.41-.25.69-.15.28.1 1.78.84 2.08.99.3.15.51.23.58.36.08.13.08.75-.17 1.45Z" />', |
| 153 | 'email' => '<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2Zm0 4-8 5-8-5V6l8 5 8-5v2Z" />', |
| 154 | ]; |
| 155 | if (!isset($icons[$iconName])) { |
| 156 | return ''; |
| 157 | } |
| 158 | |
| 159 | return '<span class="mailpoet-share-toolbar__icon mailpoet-share-toolbar__icon--' . $this->wp->escAttr($iconName) . '" aria-hidden="true">' |
| 160 | . '<svg viewBox="0 0 24 24" focusable="false">' . $icons[$iconName] . '</svg>' |
| 161 | . '</span>'; |
| 162 | } |
| 163 | |
| 164 | private function buildShareToolbarStyles(): string { |
| 165 | return '<style>' |
| 166 | . '.mailpoet-share-toolbar{background:#fff;border-bottom:1px solid #dcdcde;color:#1d2327;font-family:system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;margin-bottom:20px;position:relative;width:100%}' |
| 167 | . '.mailpoet-share-toolbar *{box-sizing:border-box}' |
| 168 | . '.mailpoet-share-toolbar__inner{align-items:center;display:flex;gap:24px;margin:0 auto;max-width:800px;padding:16px 24px;width:100%}' |
| 169 | . '.mailpoet-share-toolbar__copy{display:flex;flex:0 0 260px;flex-direction:column;gap:4px;min-width:0}' |
| 170 | . '.mailpoet-share-toolbar__copy strong{font-size:16px;font-weight:600;line-height:1.3}' |
| 171 | . '.mailpoet-share-toolbar__copy span{color:#50575e;font-size:13px;line-height:1.4;text-wrap:balance}' |
| 172 | . '.mailpoet-share-toolbar__controls{display:flex;flex:1 1 auto;flex-direction:column;gap:10px;min-width:0}' |
| 173 | . '.mailpoet-share-toolbar__url-row{align-items:flex-end;display:flex;gap:10px}' |
| 174 | . '.mailpoet-share-toolbar__url{border:1px solid #8c8f94;border-radius:2px;color:#1d2327;flex:1 1 auto;font-family:monospace;font-size:13px;line-height:20px;min-height:40px;min-width:0;padding:8px}' |
| 175 | . '.mailpoet-share-toolbar__actions{display:flex;flex-wrap:wrap;gap:10px}' |
| 176 | . '.mailpoet-share-toolbar__button,.mailpoet-share-toolbar__link{align-items:center;border-radius:2px;display:inline-flex;font-size:13px;font-weight:500;gap:6px;justify-content:center;line-height:20px;min-height:40px;padding:6px 12px;text-decoration:none;white-space:nowrap}' |
| 177 | . '.mailpoet-share-toolbar__button.components-button.is-secondary,.mailpoet-share-toolbar__link.components-button.is-secondary{background:#fff;border:1px solid #2271b1;color:#2271b1;cursor:pointer}' |
| 178 | . '.mailpoet-share-toolbar__button.components-button.is-secondary:hover,.mailpoet-share-toolbar__button.components-button.is-secondary:focus,.mailpoet-share-toolbar__link.components-button.is-secondary:hover,.mailpoet-share-toolbar__link.components-button.is-secondary:focus{border-color:#135e96;color:#135e96}' |
| 179 | . '.mailpoet-share-toolbar__copy-button{flex:0 0 auto}' |
| 180 | . '.mailpoet-share-toolbar__social-button.components-button{border:1px solid transparent;box-shadow:none;color:#fff;flex:1 1 0}' |
| 181 | . '.mailpoet-share-toolbar__social-button.components-button:hover,.mailpoet-share-toolbar__social-button.components-button:focus{border-color:transparent;color:#fff;opacity:.85}' |
| 182 | . '.mailpoet-share-toolbar__social-button--facebook.components-button{background:#1877f2}' |
| 183 | . '.mailpoet-share-toolbar__social-button--x.components-button{background:#111}' |
| 184 | . '.mailpoet-share-toolbar__social-button--whatsapp.components-button{background:#25d366}' |
| 185 | . '.mailpoet-share-toolbar__social-button--email.components-button{background:#3858e9}' |
| 186 | . '.mailpoet-share-toolbar__icon{display:inline-flex;height:20px;width:20px}' |
| 187 | . '.mailpoet-share-toolbar__icon svg{display:block;fill:currentColor;height:20px;width:20px}' |
| 188 | . '.mailpoet-share-toolbar__web-share{display:none}' |
| 189 | . '@media(max-width:782px){.mailpoet-share-toolbar__inner{align-items:stretch;flex-direction:column;gap:12px;padding:16px}.mailpoet-share-toolbar__copy{flex:auto}.mailpoet-share-toolbar__url-row{align-items:stretch;flex-direction:column}.mailpoet-share-toolbar__button,.mailpoet-share-toolbar__link{width:100%}}' |
| 190 | . '</style>'; |
| 191 | } |
| 192 | |
| 193 | private function buildShareToolbarScript(): string { |
| 194 | return '<script>' |
| 195 | . '(function(){' |
| 196 | . 'var hosts=document.querySelectorAll("[data-mailpoet-share-host]");' |
| 197 | // Replace state once if any host asks us to scrub a tokenised URL from the address bar. |
| 198 | . 'for(var r=0;r<hosts.length;r++){var replace=hosts[r].getAttribute("data-mailpoet-share-replace-state");if(replace&&window.history&&typeof window.history.replaceState==="function"){try{window.history.replaceState(null,document.title,replace);}catch(error){}break;}}' |
| 199 | . 'function copyUrl(url){' |
| 200 | . 'if(navigator.clipboard&&window.isSecureContext){return navigator.clipboard.writeText(url);}' |
| 201 | . 'var input=document.createElement("textarea");' |
| 202 | . 'input.value=url;input.setAttribute("readonly","");input.style.position="fixed";input.style.left="-9999px";document.body.appendChild(input);input.select();' |
| 203 | . 'try{document.execCommand("copy");}catch(error){}' |
| 204 | . 'document.body.removeChild(input);return Promise.resolve();' |
| 205 | . '}' |
| 206 | . 'var copyButtons=document.querySelectorAll("[' . self::COPY_BUTTON_ATTRIBUTE . ']");' |
| 207 | . 'for(var i=0;i<copyButtons.length;i++){if(copyButtons[i].dataset.mailpoetShareBound){continue;}copyButtons[i].dataset.mailpoetShareBound="1";copyButtons[i].addEventListener("click",function(){var button=this;var labelElement=button.querySelector("[data-mailpoet-share-label]");var label=labelElement?labelElement.textContent:button.textContent;copyUrl(button.getAttribute("' . self::COPY_BUTTON_ATTRIBUTE . '")).then(function(){var copiedLabel=button.getAttribute("data-mailpoet-share-copied-label")||label;if(labelElement){labelElement.textContent=copiedLabel;}else{button.textContent=copiedLabel;}window.setTimeout(function(){if(labelElement){labelElement.textContent=label;}else{button.textContent=label;}},2000);});});}' |
| 208 | . 'var shareButtons=document.querySelectorAll("[data-mailpoet-web-share]");' |
| 209 | . 'for(var j=0;j<shareButtons.length;j++){if(!navigator.share||shareButtons[j].dataset.mailpoetShareBound){continue;}shareButtons[j].dataset.mailpoetShareBound="1";shareButtons[j].style.display="inline-flex";shareButtons[j].addEventListener("click",function(){navigator.share({title:this.getAttribute("data-mailpoet-web-share-title"),url:this.getAttribute("data-mailpoet-web-share")}).catch(function(){});});}' |
| 210 | // Promote each host into its own shadow root so email styles can\'t leak in or out. |
| 211 | // Handlers were bound above on the original nodes; moving them into the shadow root preserves listeners. |
| 212 | . 'for(var k=0;k<hosts.length;k++){var host=hosts[k];if(host.shadowRoot||typeof host.attachShadow!=="function"){continue;}try{var shadow=host.attachShadow({mode:"open"});while(host.firstChild){shadow.appendChild(host.firstChild);}}catch(error){}}' |
| 213 | . '})();' |
| 214 | . '</script>'; |
| 215 | } |
| 216 | |
| 217 | private function injectAfterBodyOpen(string $html, string $markup): string { |
| 218 | $bodyPosition = stripos($html, '<body'); |
| 219 | if ($bodyPosition === false) { |
| 220 | return $markup . $html; |
| 221 | } |
| 222 | |
| 223 | $bodyOpeningTagEnd = strpos($html, '>', $bodyPosition); |
| 224 | if ($bodyOpeningTagEnd === false) { |
| 225 | return $markup . $html; |
| 226 | } |
| 227 | |
| 228 | return substr($html, 0, $bodyOpeningTagEnd + 1) . $markup . substr($html, $bodyOpeningTagEnd + 1); |
| 229 | } |
| 230 | |
| 231 | /** |
| 232 | * @return array{src: string, alt: string}|null |
| 233 | */ |
| 234 | private function findFirstContentImage(string $html): ?array { |
| 235 | $dom = pQuery::parseStr($html); |
| 236 | foreach ($dom->query('img') as $image) { |
| 237 | $src = trim((string)$image->src); |
| 238 | if (!$this->isUsableImage($src, $image)) { |
| 239 | continue; |
| 240 | } |
| 241 | return [ |
| 242 | 'src' => $src, |
| 243 | 'alt' => trim((string)$image->alt), |
| 244 | ]; |
| 245 | } |
| 246 | return null; |
| 247 | } |
| 248 | |
| 249 | private function isUsableImage(string $src, $image): bool { |
| 250 | if ($src === '' || !preg_match('/^https?:\/\//i', $src)) { |
| 251 | return false; |
| 252 | } |
| 253 | |
| 254 | $haystack = strtolower($src . ' ' . (string)$image->alt); |
| 255 | foreach (['fake-logo', 'your-logo-placeholder', 'social-icons', 'mailpoet-logo', 'powered-by-mailpoet'] as $needle) { |
| 256 | if (strpos($haystack, $needle) !== false) { |
| 257 | return false; |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | $width = isset($image->width) ? (int)$image->width : 0; |
| 262 | $height = isset($image->height) ? (int)$image->height : 0; |
| 263 | if (($width > 0 && $width < 64) || ($height > 0 && $height < 64)) { |
| 264 | return false; |
| 265 | } |
| 266 | |
| 267 | return true; |
| 268 | } |
| 269 | |
| 270 | private function metaTag(string $attribute, string $name, string $content): string { |
| 271 | return sprintf( |
| 272 | '<meta %s="%s" content="%s" />', |
| 273 | $attribute, |
| 274 | $this->wp->escAttr($name), |
| 275 | $this->wp->escAttr($content) |
| 276 | ); |
| 277 | } |
| 278 | } |
| 279 |