From 9a3c7024bbc9d22eb709ae7cf5c7d29ba41dba16 Mon Sep 17 00:00:00 2001 From: BrandonML Date: Mon, 14 Sep 2026 13:52:19 -0400 Subject: [PATCH 1/7] Add a custom share-via menu with explicit channels (#34, #35) navigator.share()'s OS share sheet on Windows is limited to whatever's registered on that machine, with no way to pick a channel it doesn't list (#34), and native share also leaves message ordering/spacing up to whatever the receiving app does when it joins the separate text/url fields back together (#35). Replace the single native-share button with a popover offering explicit channels -- WhatsApp, Email, X/Twitter, Facebook, LinkedIn, Copy link -- built from share-intent URLs Tabby fully controls, plus a "More options..." entry that still uses navigator.share (with photo attachment) when available. Text-based channels now share one composed message with the cat's profile link ahead of the Tabby plug, both on their own blank-separated line, instead of relying on OS/app-dependent concatenation. Co-Authored-By: Claude Sonnet 5 --- extension/newtab.css | 24 ++++ extension/newtab.js | 154 +++++++++++++++++--- test/newtab.test.js | 336 +++++++++++++++++++++++++++++++------------ 3 files changed, 398 insertions(+), 116 deletions(-) diff --git a/extension/newtab.css b/extension/newtab.css index 7fc5638..bde34a2 100644 --- a/extension/newtab.css +++ b/extension/newtab.css @@ -167,6 +167,30 @@ h1 { margin: 0; font-weight: 700; color: var(--ink); } } .share:hover { background: var(--moss); color: var(--paper); } +.share-menu { + position: fixed; + z-index: 20; + min-width: 190px; + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px; + background: var(--paper); + border: 1px solid var(--card-edge); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(42, 38, 32, .18); +} +.share-menu-item { + background: transparent; + color: var(--ink); + font-weight: 600; + font-size: 0.875rem; + text-align: left; + padding: 9px 10px; + border-radius: 6px; +} +.share-menu-item:hover { background: var(--wall); } + @media (max-width: 360px) { .photo { width: calc(100% - 32px); margin: 16px 16px 0; } .content { padding: 14px 16px 18px; } diff --git a/extension/newtab.js b/extension/newtab.js index 4ca07e3..037ceef 100644 --- a/extension/newtab.js +++ b/extension/newtab.js @@ -281,6 +281,7 @@ function nextCard(cards, seenIds = []) { } function renderCard(card, { stale = false, exploreLabel = null, locationLabel = null } = {}) { + closeShareMenu(); // a card rebuild (e.g. "Show another cat") orphans any open menu -- close it first const meta = [card.breed, card.age, card.sex].filter(Boolean).join(" · "); // While exploring, distanceMiles is measured from the explored city, not // the user — naming that city avoids the number reading as "from you". @@ -406,13 +407,8 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel = actions.appendChild(profileA); } - if (shareUrl && typeof navigator.share === "function") { - const shareButton = document.createElement("button"); - shareButton.type = "button"; - shareButton.className = "share"; - shareButton.textContent = `Share ${card.name}`; - shareButton.addEventListener("click", () => shareCard(card, shareUrl)); - actions.appendChild(shareButton); + if (shareUrl) { + actions.appendChild(buildShareControl(card, shareUrl)); } content.appendChild(actions); @@ -423,12 +419,57 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel = showNotice(stale ? "Showing a recent saved match while we refresh." : ""); } -function buildShareText(card) { +function buildShareIntro(card) { const meta = [card.breed, card.age, card.sex].filter(Boolean).join(", "); - const intro = meta ? `${card.name} (${meta}) is looking for a home at ${card.rescueName}.` : `${card.name} is looking for a home at ${card.rescueName}.`; - return `${intro}\n\n${TABBY_TAGLINE} Get Tabby: ${tabbyStoreUrl()}`; + return meta ? `${card.name} (${meta}) is looking for a home at ${card.rescueName}.` : `${card.name} is looking for a home at ${card.rescueName}.`; +} + +function buildShareText(card) { + return `${buildShareIntro(card)}\n\n${TABBY_TAGLINE} Get Tabby: ${tabbyStoreUrl()}`; +} + +// The profile link is embedded directly in the message (ahead of the Tabby +// plug, both on their own blank-separated line) so every text-based channel +// below shows the same, deliberately ordered copy (GitHub issue #35). +// Native share (see shareCard()) can't use this -- it hands `text` and `url` +// to the target app as two separate fields, and it's the target app, not +// Tabby, that decides how/where to rejoin them. +function buildShareMessage(card, shareUrl) { + return `${buildShareIntro(card)}\n\n${shareUrl}\n\n${TABBY_TAGLINE} Get Tabby: ${tabbyStoreUrl()}`; +} + +// mailto: goes through here too -- window.open() hands it to the OS mail +// client the same way a clicked would, without ever +// navigating this new-tab page itself. +function openShareTarget(url) { + window.open(url, "_blank", "noopener,noreferrer"); +} + +async function copyShareLink(card, shareUrl) { + try { + await navigator.clipboard.writeText(buildShareMessage(card, shareUrl)); + showNotice("Copied to clipboard."); + } catch (error) { + console.error("[tabby]", error); + showNotice("Unable to copy the link. Try again.", { type: "error" }); + } } +// Link-based channels (X/Facebook/LinkedIn) get just the profile URL -- they +// build their own preview card from that page's Open Graph tags, not from +// any text Tabby sends, so there's nothing to compose for them. Text-based +// channels (WhatsApp/email/copy) get the fully composed message so their +// content/ordering is exact (issue #35), not left to how a native share +// target happens to join separate text/url fields together. +const SHARE_CHANNELS = [ + { label: "WhatsApp", activate: (card, shareUrl) => openShareTarget(`https://wa.me/?text=${encodeURIComponent(buildShareMessage(card, shareUrl))}`) }, + { label: "Email", activate: (card, shareUrl) => openShareTarget(`mailto:?subject=${encodeURIComponent(`Meet ${card.name}`)}&body=${encodeURIComponent(buildShareMessage(card, shareUrl))}`) }, + { label: "X / Twitter", activate: (card, shareUrl) => openShareTarget(`https://twitter.com/intent/tweet?text=${encodeURIComponent(buildShareIntro(card))}&url=${encodeURIComponent(shareUrl)}`) }, + { label: "Facebook", activate: (_card, shareUrl) => openShareTarget(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`) }, + { label: "LinkedIn", activate: (_card, shareUrl) => openShareTarget(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`) }, + { label: "Copy link", activate: (card, shareUrl) => copyShareLink(card, shareUrl) } +]; + const IMAGE_CONTENT_TYPE_EXTENSIONS = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif" }; // RescueGroups' CDN has no CORS headers (see applyContentAwareCrop's comment @@ -446,16 +487,6 @@ async function fetchSharePhoto(imageUrl) { return new File([blob], `cat.${extension}`, { type: blob.type || "image/jpeg" }); } -async function copyShareTextFallback(text, url) { - try { - await navigator.clipboard.writeText(`${text}\n${url}`); - showNotice("Copied to clipboard."); - } catch (error) { - console.error("[tabby]", error); - showNotice("Unable to share right now.", { type: "error" }); - } -} - // Tries to attach the actual photo (issue #27 calls this the most important // part of the share), then degrades in two steps if that's not possible: // first to a link-only native share, then -- if navigator.share itself @@ -478,8 +509,89 @@ async function shareCard(card, shareUrl) { } catch (error) { if (error?.name === "AbortError") return; // The user closed the share sheet -- not a failure. console.error("[tabby]", error); - await copyShareTextFallback(text, shareUrl); + await copyShareLink(card, shareUrl); + } +} + +// Reassigned to a real cleanup closure whenever a menu is open, and reset to +// a no-op once it closes -- renderCard() calls this unconditionally on every +// rebuild so a stale menu from a previous card never lingers. +let closeShareMenu = () => {}; + +function buildShareMenuItem(label, onActivate) { + const item = document.createElement("button"); + item.type = "button"; + item.className = "share-menu-item"; + item.setAttribute("role", "menuitem"); + item.textContent = label; + item.addEventListener("click", () => { + closeShareMenu(); + onActivate(); + }); + return item; +} + +// Rendered into document.body at a fixed position computed from the toggle +// button's own rect, rather than nested inside it -- .card clips its +// contents with overflow: hidden (for the photo's rounded corners), which +// would silently cut off a menu positioned inside that subtree. +function openShareMenu(card, shareUrl, toggleButton) { + const menu = document.createElement("div"); + menu.className = "share-menu"; + menu.setAttribute("role", "menu"); + + for (const channel of SHARE_CHANNELS) { + menu.appendChild(buildShareMenuItem(channel.label, () => channel.activate(card, shareUrl))); } + // The one channel that can't be built from a plain URL/mailto -- it needs + // whatever's actually registered as a share target on this device (and, + // when supported, the photo file itself), which only the Web Share API + // has access to. + if (typeof navigator.share === "function") { + menu.appendChild(buildShareMenuItem("More options…", () => shareCard(card, shareUrl))); + } + + document.body.appendChild(menu); + const rect = toggleButton.getBoundingClientRect(); + const menuWidth = menu.getBoundingClientRect().width; + menu.style.top = `${rect.bottom + 6}px`; + menu.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8))}px`; + toggleButton.setAttribute("aria-expanded", "true"); + + const onOutsideClick = (event) => { + if (!menu.contains(event.target) && event.target !== toggleButton) closeShareMenu(); + }; + const onKeydown = (event) => { + if (event.key === "Escape") closeShareMenu(); + }; + // Deferred so the same click that opened the menu doesn't immediately + // close it again via this listener. + setTimeout(() => document.addEventListener("click", onOutsideClick), 0); + document.addEventListener("keydown", onKeydown); + + closeShareMenu = () => { + menu.remove(); + toggleButton.setAttribute("aria-expanded", "false"); + document.removeEventListener("click", onOutsideClick); + document.removeEventListener("keydown", onKeydown); + closeShareMenu = () => {}; + }; +} + +function buildShareControl(card, shareUrl) { + const shareButton = document.createElement("button"); + shareButton.type = "button"; + shareButton.className = "share"; + shareButton.textContent = `Share ${card.name}`; + shareButton.setAttribute("aria-haspopup", "true"); + shareButton.setAttribute("aria-expanded", "false"); + shareButton.addEventListener("click", (event) => { + event.stopPropagation(); + const wasOpen = shareButton.getAttribute("aria-expanded") === "true"; + closeShareMenu(); + if (!wasOpen) openShareMenu(card, shareUrl, shareButton); + }); + return shareButton; } async function resolveLocation(settings, promptForLocation) { diff --git a/test/newtab.test.js b/test/newtab.test.js index 84605e6..7031ff1 100644 --- a/test/newtab.test.js +++ b/test/newtab.test.js @@ -163,7 +163,7 @@ describe('newtab.js DOM manipulation', () => { assert.equal(h1.classList.contains('name-long'), false); }); }); - describe('Share this cat (GitHub issue #27)', () => { + describe('Share this cat (GitHub issues #27, #34, #35)', () => { const shareCardData = { name: "Luna", breed: "Tabby", @@ -175,126 +175,272 @@ describe('newtab.js DOM manipulation', () => { imageUrl: "https://cdn.rescuegroups.org/pic.jpg" }; - it('does not render a share button when the platform has no Web Share API', () => { - delete window.navigator.share; - window.renderCard(shareCardData); - assert.equal(document.querySelector('#card .share'), null); + function openShareMenu() { + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + return document.querySelector('.share-menu'); + } + + function clickMenuItem(menu, label) { + const item = [...menu.querySelectorAll('.share-menu-item')].find(el => el.textContent === label); + assert.ok(item, `expected a "${label}" menu item`); + item.dispatchEvent(new window.Event('click')); + return item; + } + + it('composes the message with the profile link ahead of the Tabby plug, both on their own blank-separated line (issue #35)', () => { + const message = window.buildShareMessage(shareCardData, shareCardData.profileUrl); + assert.equal( + message, + 'Luna (Tabby, Adult, Female) is looking for a home at Happy Paws Rescue.\n\n' + + 'https://rescuegroups.org/animals/luna\n\n' + + 'Meet an adoptable cat every time you open a new tab. Get Tabby: https://chromewebstore.google.com/detail/tabby-new-tab-for-adoptab/elfpnkoboidkgahmoggodpnmekfodcig' + ); }); - it('renders a "Share {name}" button when navigator.share is available', () => { - window.navigator.share = async () => {}; + it('renders a "Share {name}" button even when the platform has no Web Share API (issue #34)', () => { + delete window.navigator.share; window.renderCard(shareCardData); const shareButton = document.querySelector('#card .share'); - assert.ok(shareButton); + assert.ok(shareButton, 'explicit channels (WhatsApp, email, etc.) do not depend on the Web Share API'); assert.equal(shareButton.textContent, 'Share Luna'); assert.equal(shareButton.tagName, 'BUTTON'); + + const menu = openShareMenu(); + const labels = [...menu.querySelectorAll('.share-menu-item')].map(el => el.textContent); + assert.ok(!labels.includes('More options…'), 'no native fallback item when navigator.share is unavailable'); + assert.ok(labels.includes('WhatsApp')); }); - it('shares the photo, text and profile link together when the platform supports file attachments', async () => { - window.navigator.canShare = () => true; - let sharedData; - window.navigator.share = async (data) => { sharedData = data; }; - window.fetch = async (url) => { - assert.ok(url.includes('/api/photo-share?url='), 'must fetch through the CORS-safe photo-share proxy, not the CDN directly'); - return { ok: true, blob: async () => new window.Blob(["fake-photo-bytes"], { type: "image/jpeg" }) }; - }; + describe('Share menu (GitHub issue #34)', () => { + it('opens with every explicit channel plus a native fallback when navigator.share is available', () => { + window.navigator.share = async () => {}; + window.renderCard(shareCardData); + const menu = openShareMenu(); + assert.ok(menu, 'clicking the share button should open the menu'); + const labels = [...menu.querySelectorAll('.share-menu-item')].map(el => el.textContent); + assert.deepEqual(labels, ['WhatsApp', 'Email', 'X / Twitter', 'Facebook', 'LinkedIn', 'Copy link', 'More options…']); + }); - window.renderCard(shareCardData); - document.querySelector('#card .share').dispatchEvent(new window.Event('click')); - await new Promise(r => setTimeout(r, 10)); + it('toggles closed when the share button is clicked again', () => { + window.navigator.share = async () => {}; + window.renderCard(shareCardData); + openShareMenu(); + assert.ok(document.querySelector('.share-menu')); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + assert.equal(document.querySelector('.share-menu'), null); + }); - assert.ok(sharedData, 'navigator.share should have been called'); - assert.equal(sharedData.title, 'Meet Luna'); - assert.equal(sharedData.url, 'https://rescuegroups.org/animals/luna'); - assert.ok(sharedData.text.includes('Luna')); - assert.ok(sharedData.text.includes('Happy Paws Rescue')); - assert.ok(sharedData.text.includes('Meet an adoptable cat every time you open a new tab.'), 'must include the Tabby tagline'); - assert.ok(sharedData.text.includes('chromewebstore.google.com'), 'must promote the Tabby listing'); - assert.equal(sharedData.files.length, 1); - assert.ok(sharedData.files[0] instanceof window.File); - assert.equal(sharedData.files[0].type, 'image/jpeg'); - }); - - it('promotes the Edge Add-ons listing instead of the Chrome Web Store when running in Edge', async () => { - Object.defineProperty(window.navigator, 'userAgent', { - value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', - configurable: true + it('closes on an outside click', async () => { + window.navigator.share = async () => {}; + window.renderCard(shareCardData); + openShareMenu(); + // The outside-click listener is attached via a deferred setTimeout so + // the click that opened the menu doesn't immediately close it again. + await new Promise(r => setTimeout(r, 0)); + document.dispatchEvent(new window.Event('click')); + assert.equal(document.querySelector('.share-menu'), null); }); - window.navigator.canShare = () => true; - let sharedData; - window.navigator.share = async (data) => { sharedData = data; }; - window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); - window.renderCard(shareCardData); - document.querySelector('#card .share').dispatchEvent(new window.Event('click')); - await new Promise(r => setTimeout(r, 10)); + it('closes on Escape', () => { + window.navigator.share = async () => {}; + window.renderCard(shareCardData); + openShareMenu(); + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' })); + assert.equal(document.querySelector('.share-menu'), null); + }); - assert.ok(sharedData); - assert.ok(sharedData.text.includes('microsoftedge.microsoft.com/addons'), 'Edge users should get the Edge Add-ons link, not the CWS one'); - assert.ok(!sharedData.text.includes('chromewebstore.google.com'), 'must not also include the Chrome Web Store link'); - }); + it('closes automatically when the card re-renders, so a stale menu never lingers', () => { + window.navigator.share = async () => {}; + window.renderCard(shareCardData); + openShareMenu(); + assert.ok(document.querySelector('.share-menu')); + window.renderCard({ ...shareCardData, name: 'Milo' }); + assert.equal(document.querySelector('.share-menu'), null); + }); - it('shares without a photo file when canShare rejects file attachments', async () => { - window.navigator.canShare = () => false; - let sharedData; - window.navigator.share = async (data) => { sharedData = data; }; - window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + it('opens WhatsApp with the composed message', () => { + let openedUrl; + window.open = (url) => { openedUrl = url; }; + window.renderCard(shareCardData); + clickMenuItem(openShareMenu(), 'WhatsApp'); - window.renderCard(shareCardData); - document.querySelector('#card .share').dispatchEvent(new window.Event('click')); - await new Promise(r => setTimeout(r, 10)); + assert.ok(openedUrl.startsWith('https://wa.me/?text=')); + const message = decodeURIComponent(openedUrl.split('text=')[1]); + assert.ok(message.includes('https://rescuegroups.org/animals/luna\n\n'), 'the profile link must sit on its own blank-separated line, ahead of the Tabby plug'); + }); - assert.ok(sharedData); - assert.equal(sharedData.files, undefined); - assert.equal(sharedData.url, 'https://rescuegroups.org/animals/luna'); - }); + it('opens the mail client with a subject and the composed message as the body', () => { + let openedUrl; + window.open = (url) => { openedUrl = url; }; + window.renderCard(shareCardData); + clickMenuItem(openShareMenu(), 'Email'); - it('shares without a photo file when the photo-share proxy fetch fails', async () => { - let sharedData; - window.navigator.canShare = () => true; - window.navigator.share = async (data) => { sharedData = data; }; - window.fetch = async () => ({ ok: false }); - window.console.error = () => {}; + assert.ok(openedUrl.startsWith('mailto:?subject=Meet%20Luna&body=')); + const body = decodeURIComponent(openedUrl.split('body=')[1]); + assert.ok(body.includes('https://rescuegroups.org/animals/luna')); + assert.ok(body.includes('Get Tabby:')); + }); - window.renderCard(shareCardData); - document.querySelector('#card .share').dispatchEvent(new window.Event('click')); - await new Promise(r => setTimeout(r, 10)); + it('opens an X/Twitter intent with the intro text and the profile url', () => { + let openedUrl; + window.open = (url) => { openedUrl = url; }; + window.renderCard(shareCardData); + clickMenuItem(openShareMenu(), 'X / Twitter'); - assert.ok(sharedData, 'a failed photo fetch should not block sharing the link and text'); - assert.equal(sharedData.files, undefined); - }); + assert.ok(openedUrl.startsWith('https://twitter.com/intent/tweet?')); + assert.ok(openedUrl.includes(`url=${encodeURIComponent('https://rescuegroups.org/animals/luna')}`)); + assert.ok(decodeURIComponent(openedUrl).includes('Luna')); + }); - it('treats the user cancelling the native share sheet as a no-op, not an error', async () => { - window.navigator.canShare = () => true; - window.navigator.share = async () => { const err = new Error('cancelled'); err.name = 'AbortError'; throw err; }; - window.navigator.clipboard = { writeText: async () => { throw new Error('should not be called'); } }; - window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); - const loggedErrors = []; - window.console.error = (...args) => { loggedErrors.push(args); }; + it('opens the Facebook sharer with just the profile url -- Facebook builds its own card from that page\'s Open Graph tags', () => { + let openedUrl; + window.open = (url) => { openedUrl = url; }; + window.renderCard(shareCardData); + clickMenuItem(openShareMenu(), 'Facebook'); + assert.equal(openedUrl, `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent('https://rescuegroups.org/animals/luna')}`); + }); - window.renderCard(shareCardData); - document.querySelector('#card .share').dispatchEvent(new window.Event('click')); - await new Promise(r => setTimeout(r, 10)); + it('opens the LinkedIn sharer with just the profile url', () => { + let openedUrl; + window.open = (url) => { openedUrl = url; }; + window.renderCard(shareCardData); + clickMenuItem(openShareMenu(), 'LinkedIn'); + assert.equal(openedUrl, `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent('https://rescuegroups.org/animals/luna')}`); + }); + + it('copies the composed message to the clipboard', async () => { + let clipboardText; + window.navigator.clipboard = { writeText: async (text) => { clipboardText = text; } }; + window.renderCard(shareCardData); + clickMenuItem(openShareMenu(), 'Copy link'); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(clipboardText.includes('https://rescuegroups.org/animals/luna')); + assert.ok(document.getElementById('notice').textContent.includes('Copied to clipboard')); + }); + + it('shows an error notice when copying fails', async () => { + window.navigator.clipboard = { writeText: async () => { throw new Error('denied'); } }; + window.console.error = () => {}; + window.renderCard(shareCardData); + clickMenuItem(openShareMenu(), 'Copy link'); + await new Promise(r => setTimeout(r, 10)); - assert.equal(loggedErrors.length, 0, 'a user-cancelled share should not be logged as an error'); - assert.equal(document.getElementById('notice').textContent, ''); + assert.ok(document.getElementById('notice').textContent.includes('Unable to copy')); + }); }); - it('falls back to copying the details to the clipboard when navigator.share fails for a real reason', async () => { - window.navigator.canShare = () => true; - window.navigator.share = async () => { throw new Error('share failed'); }; - let clipboardText; - window.navigator.clipboard = { writeText: async (text) => { clipboardText = text; } }; - window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); - window.console.error = () => {}; + describe('"More options" -- native Web Share API (GitHub issue #27)', () => { + function activateNativeShare() { + clickMenuItem(openShareMenu(), 'More options…'); + } - window.renderCard(shareCardData); - document.querySelector('#card .share').dispatchEvent(new window.Event('click')); - await new Promise(r => setTimeout(r, 10)); + it('shares the photo, text and profile link together when the platform supports file attachments', async () => { + window.navigator.canShare = () => true; + let sharedData; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async (url) => { + assert.ok(url.includes('/api/photo-share?url='), 'must fetch through the CORS-safe photo-share proxy, not the CDN directly'); + return { ok: true, blob: async () => new window.Blob(["fake-photo-bytes"], { type: "image/jpeg" }) }; + }; - assert.ok(clipboardText.includes('Luna')); - assert.ok(clipboardText.includes('https://rescuegroups.org/animals/luna')); - assert.ok(document.getElementById('notice').textContent.includes('Copied to clipboard')); + window.renderCard(shareCardData); + activateNativeShare(); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData, 'navigator.share should have been called'); + assert.equal(sharedData.title, 'Meet Luna'); + assert.equal(sharedData.url, 'https://rescuegroups.org/animals/luna'); + assert.ok(sharedData.text.includes('Luna')); + assert.ok(sharedData.text.includes('Happy Paws Rescue')); + assert.ok(sharedData.text.includes('Meet an adoptable cat every time you open a new tab.'), 'must include the Tabby tagline'); + assert.ok(sharedData.text.includes('chromewebstore.google.com'), 'must promote the Tabby listing'); + assert.equal(sharedData.files.length, 1); + assert.ok(sharedData.files[0] instanceof window.File); + assert.equal(sharedData.files[0].type, 'image/jpeg'); + }); + + it('promotes the Edge Add-ons listing instead of the Chrome Web Store when running in Edge', async () => { + Object.defineProperty(window.navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', + configurable: true + }); + window.navigator.canShare = () => true; + let sharedData; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + + window.renderCard(shareCardData); + activateNativeShare(); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData); + assert.ok(sharedData.text.includes('microsoftedge.microsoft.com/addons'), 'Edge users should get the Edge Add-ons link, not the CWS one'); + assert.ok(!sharedData.text.includes('chromewebstore.google.com'), 'must not also include the Chrome Web Store link'); + }); + + it('shares without a photo file when canShare rejects file attachments', async () => { + window.navigator.canShare = () => false; + let sharedData; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + + window.renderCard(shareCardData); + activateNativeShare(); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData); + assert.equal(sharedData.files, undefined); + assert.equal(sharedData.url, 'https://rescuegroups.org/animals/luna'); + }); + + it('shares without a photo file when the photo-share proxy fetch fails', async () => { + let sharedData; + window.navigator.canShare = () => true; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async () => ({ ok: false }); + window.console.error = () => {}; + + window.renderCard(shareCardData); + activateNativeShare(); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData, 'a failed photo fetch should not block sharing the link and text'); + assert.equal(sharedData.files, undefined); + }); + + it('treats the user cancelling the native share sheet as a no-op, not an error', async () => { + window.navigator.canShare = () => true; + window.navigator.share = async () => { const err = new Error('cancelled'); err.name = 'AbortError'; throw err; }; + window.navigator.clipboard = { writeText: async () => { throw new Error('should not be called'); } }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + const loggedErrors = []; + window.console.error = (...args) => { loggedErrors.push(args); }; + + window.renderCard(shareCardData); + activateNativeShare(); + await new Promise(r => setTimeout(r, 10)); + + assert.equal(loggedErrors.length, 0, 'a user-cancelled share should not be logged as an error'); + assert.equal(document.getElementById('notice').textContent, ''); + }); + + it('falls back to copying the details to the clipboard when navigator.share fails for a real reason', async () => { + window.navigator.canShare = () => true; + window.navigator.share = async () => { throw new Error('share failed'); }; + let clipboardText; + window.navigator.clipboard = { writeText: async (text) => { clipboardText = text; } }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + window.console.error = () => {}; + + window.renderCard(shareCardData); + activateNativeShare(); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(clipboardText.includes('Luna')); + assert.ok(clipboardText.includes('https://rescuegroups.org/animals/luna')); + assert.ok(document.getElementById('notice').textContent.includes('Copied to clipboard')); + }); }); }); describe('showNotice error vs. informational tone', () => { From 12c77c83577ea0ba19aed8940d1225bd6e77bdf4 Mon Sep 17 00:00:00 2001 From: BrandonML Date: Mon, 14 Sep 2026 14:39:22 -0400 Subject: [PATCH 2/7] Fix share-menu positioning/mailto, swap LinkedIn for Reddit/Pinterest/Nextdoor Real-world testing surfaced three issues with the share-via menu: - The popover always opened downward, but the Share button sits near the bottom of the card -- which is often already near the bottom of the viewport -- so lower menu items were regularly unreachable without scrolling. It now flips above the button when there isn't enough room below. - Email sharing silently did nothing: window.open('mailto:...') is unreliable in Chrome. Switched to a real anchor click, which is what browsers actually special-case for handing a non-http(s) scheme off to the OS/registered app. - Facebook and LinkedIn only ever take a URL and build their preview card by scraping that page's Open Graph tags -- most RescueGroups- hosted rescue pages don't have (correct) ones, so those cards were regularly missing the cat's photo/name entirely. LinkedIn is dropped in favor of Reddit and Pinterest, whose share intents accept the title/image/description directly and don't depend on the rescue site's own markup, plus Nextdoor (a pre-filled text composer, same as WhatsApp/email). Facebook stays since it's still widely used -- its OG-tag dependency is a platform restriction, not something a channel swap fixes; that needs the cat-details share page already planned separately. Co-Authored-By: Claude Sonnet 5 --- extension/newtab.js | 61 +++++++++++++++++++++------- test/newtab.test.js | 96 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 132 insertions(+), 25 deletions(-) diff --git a/extension/newtab.js b/extension/newtab.js index 037ceef..a3fc62a 100644 --- a/extension/newtab.js +++ b/extension/newtab.js @@ -438,13 +438,20 @@ function buildShareMessage(card, shareUrl) { return `${buildShareIntro(card)}\n\n${shareUrl}\n\n${TABBY_TAGLINE} Get Tabby: ${tabbyStoreUrl()}`; } -// mailto: goes through here too -- window.open() hands it to the OS mail -// client the same way a clicked would, without ever -// navigating this new-tab page itself. function openShareTarget(url) { window.open(url, "_blank", "noopener,noreferrer"); } +// window.open('mailto:...') is unreliable in Chrome -- it silently does +// nothing in a lot of real-world configurations. A real anchor click is what +// browsers actually special-case for handing a non-http(s) scheme off to the +// OS/registered app without navigating this page. +function openMailto(url) { + const link = document.createElement("a"); + link.href = url; + link.click(); +} + async function copyShareLink(card, shareUrl) { try { await navigator.clipboard.writeText(buildShareMessage(card, shareUrl)); @@ -455,18 +462,30 @@ async function copyShareLink(card, shareUrl) { } } -// Link-based channels (X/Facebook/LinkedIn) get just the profile URL -- they -// build their own preview card from that page's Open Graph tags, not from -// any text Tabby sends, so there's nothing to compose for them. Text-based -// channels (WhatsApp/email/copy) get the fully composed message so their +function sharePhotoUrl(card) { + const backendUrl = BACKEND_URL.replace(/\/$/, ""); + return `${backendUrl}/api/photo-share?url=${encodeURIComponent(card.imageUrl)}`; +} + +// Facebook only ever takes a URL -- it builds its own preview card by +// scraping that page's Open Graph tags, not from anything Tabby sends, and +// most rescues' RescueGroups-hosted pages don't have (correct) OG tags, so +// this one channel is stuck showing generic/missing content until the +// cat-details share page (tracked separately) replaces the raw profile link. +// Reddit and Pinterest sidestep that entirely -- their intents accept the +// title/image/description directly as params, so they show real cat details +// regardless of the rescue's own site. Text-composer channels (WhatsApp, +// email, Nextdoor, copy) get the fully composed message so their // content/ordering is exact (issue #35), not left to how a native share -// target happens to join separate text/url fields together. +// target happens to join separate text/url fields back together. const SHARE_CHANNELS = [ { label: "WhatsApp", activate: (card, shareUrl) => openShareTarget(`https://wa.me/?text=${encodeURIComponent(buildShareMessage(card, shareUrl))}`) }, - { label: "Email", activate: (card, shareUrl) => openShareTarget(`mailto:?subject=${encodeURIComponent(`Meet ${card.name}`)}&body=${encodeURIComponent(buildShareMessage(card, shareUrl))}`) }, + { label: "Email", activate: (card, shareUrl) => openMailto(`mailto:?subject=${encodeURIComponent(`Meet ${card.name}`)}&body=${encodeURIComponent(buildShareMessage(card, shareUrl))}`) }, { label: "X / Twitter", activate: (card, shareUrl) => openShareTarget(`https://twitter.com/intent/tweet?text=${encodeURIComponent(buildShareIntro(card))}&url=${encodeURIComponent(shareUrl)}`) }, { label: "Facebook", activate: (_card, shareUrl) => openShareTarget(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`) }, - { label: "LinkedIn", activate: (_card, shareUrl) => openShareTarget(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`) }, + { label: "Reddit", activate: (card, shareUrl) => openShareTarget(`https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(`Meet ${card.name}`)}`) }, + { label: "Pinterest", activate: (card, shareUrl) => openShareTarget(`https://www.pinterest.com/pin/create/button/?url=${encodeURIComponent(shareUrl)}&media=${encodeURIComponent(sharePhotoUrl(card))}&description=${encodeURIComponent(buildShareIntro(card))}`) }, + { label: "Nextdoor", activate: (card, shareUrl) => openShareTarget(`https://nextdoor.com/sharekit/?source=tabby&body=${encodeURIComponent(buildShareMessage(card, shareUrl))}`) }, { label: "Copy link", activate: (card, shareUrl) => copyShareLink(card, shareUrl) } ]; @@ -531,6 +550,23 @@ function buildShareMenuItem(label, onActivate) { return item; } +// Opens below the button by default, but flips above it when there isn't +// enough room left in the viewport -- the Share button sits near the bottom +// of the card, which is often already near the bottom of the screen, so an +// always-downward menu regularly left its lower items unreachable without +// scrolling. +function positionShareMenu(menu, toggleButton) { + const rect = toggleButton.getBoundingClientRect(); + const menuRect = menu.getBoundingClientRect(); + + const fitsBelow = rect.bottom + 6 + menuRect.height <= window.innerHeight - 8; + const top = fitsBelow ? rect.bottom + 6 : Math.max(8, rect.top - 6 - menuRect.height); + const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuRect.width - 8)); + + menu.style.top = `${Math.min(top, window.innerHeight - menuRect.height - 8)}px`; + menu.style.left = `${left}px`; +} + // Rendered into document.body at a fixed position computed from the toggle // button's own rect, rather than nested inside it -- .card clips its // contents with overflow: hidden (for the photo's rounded corners), which @@ -552,10 +588,7 @@ function openShareMenu(card, shareUrl, toggleButton) { } document.body.appendChild(menu); - const rect = toggleButton.getBoundingClientRect(); - const menuWidth = menu.getBoundingClientRect().width; - menu.style.top = `${rect.bottom + 6}px`; - menu.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8))}px`; + positionShareMenu(menu, toggleButton); toggleButton.setAttribute("aria-expanded", "true"); const onOutsideClick = (event) => { diff --git a/test/newtab.test.js b/test/newtab.test.js index 7031ff1..037a99e 100644 --- a/test/newtab.test.js +++ b/test/newtab.test.js @@ -218,7 +218,7 @@ describe('newtab.js DOM manipulation', () => { const menu = openShareMenu(); assert.ok(menu, 'clicking the share button should open the menu'); const labels = [...menu.querySelectorAll('.share-menu-item')].map(el => el.textContent); - assert.deepEqual(labels, ['WhatsApp', 'Email', 'X / Twitter', 'Facebook', 'LinkedIn', 'Copy link', 'More options…']); + assert.deepEqual(labels, ['WhatsApp', 'Email', 'X / Twitter', 'Facebook', 'Reddit', 'Pinterest', 'Nextdoor', 'Copy link', 'More options…']); }); it('toggles closed when the share button is clicked again', () => { @@ -258,6 +258,50 @@ describe('newtab.js DOM manipulation', () => { assert.equal(document.querySelector('.share-menu'), null); }); + it('flips the menu above the button when there is not enough room below it in the viewport', () => { + window.navigator.share = async () => {}; + Object.defineProperty(window, 'innerHeight', { value: 400, configurable: true }); + Object.defineProperty(window, 'innerWidth', { value: 800, configurable: true }); + const originalRect = window.HTMLElement.prototype.getBoundingClientRect; + window.HTMLElement.prototype.getBoundingClientRect = function () { + if (this.classList.contains('share')) return { top: 350, bottom: 390, left: 100, right: 300, width: 200, height: 40 }; + if (this.classList.contains('share-menu')) return { top: 0, bottom: 0, left: 0, right: 0, width: 190, height: 300 }; + return { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }; + }; + + let menu; + try { + window.renderCard(shareCardData); + menu = openShareMenu(); + } finally { + window.HTMLElement.prototype.getBoundingClientRect = originalRect; + } + + assert.ok(parseFloat(menu.style.top) < 350, 'a menu opening below the button here would run off the bottom of a 400px-tall viewport'); + }); + + it('positions the menu below the button when there is enough room', () => { + window.navigator.share = async () => {}; + Object.defineProperty(window, 'innerHeight', { value: 1000, configurable: true }); + Object.defineProperty(window, 'innerWidth', { value: 800, configurable: true }); + const originalRect = window.HTMLElement.prototype.getBoundingClientRect; + window.HTMLElement.prototype.getBoundingClientRect = function () { + if (this.classList.contains('share')) return { top: 200, bottom: 240, left: 100, right: 300, width: 200, height: 40 }; + if (this.classList.contains('share-menu')) return { top: 0, bottom: 0, left: 0, right: 0, width: 190, height: 300 }; + return { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }; + }; + + let menu; + try { + window.renderCard(shareCardData); + menu = openShareMenu(); + } finally { + window.HTMLElement.prototype.getBoundingClientRect = originalRect; + } + + assert.equal(menu.style.top, '246px'); + }); + it('opens WhatsApp with the composed message', () => { let openedUrl; window.open = (url) => { openedUrl = url; }; @@ -269,14 +313,19 @@ describe('newtab.js DOM manipulation', () => { assert.ok(message.includes('https://rescuegroups.org/animals/luna\n\n'), 'the profile link must sit on its own blank-separated line, ahead of the Tabby plug'); }); - it('opens the mail client with a subject and the composed message as the body', () => { - let openedUrl; - window.open = (url) => { openedUrl = url; }; - window.renderCard(shareCardData); - clickMenuItem(openShareMenu(), 'Email'); + it('opens the mail client via a real anchor click (window.open silently fails for mailto: in Chrome) with a subject and the composed message as the body', () => { + let clickedHref; + const originalClick = window.HTMLAnchorElement.prototype.click; + window.HTMLAnchorElement.prototype.click = function () { clickedHref = this.href; }; + try { + window.renderCard(shareCardData); + clickMenuItem(openShareMenu(), 'Email'); + } finally { + window.HTMLAnchorElement.prototype.click = originalClick; + } - assert.ok(openedUrl.startsWith('mailto:?subject=Meet%20Luna&body=')); - const body = decodeURIComponent(openedUrl.split('body=')[1]); + assert.ok(clickedHref.startsWith('mailto:?subject=Meet%20Luna&body=')); + const body = decodeURIComponent(clickedHref.split('body=')[1]); assert.ok(body.includes('https://rescuegroups.org/animals/luna')); assert.ok(body.includes('Get Tabby:')); }); @@ -300,12 +349,37 @@ describe('newtab.js DOM manipulation', () => { assert.equal(openedUrl, `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent('https://rescuegroups.org/animals/luna')}`); }); - it('opens the LinkedIn sharer with just the profile url', () => { + it('opens the Reddit submit intent with a title and the profile url -- Reddit, unlike Facebook, accepts the title directly', () => { + let openedUrl; + window.open = (url) => { openedUrl = url; }; + window.renderCard(shareCardData); + clickMenuItem(openShareMenu(), 'Reddit'); + assert.equal(openedUrl, `https://www.reddit.com/submit?url=${encodeURIComponent('https://rescuegroups.org/animals/luna')}&title=${encodeURIComponent('Meet Luna')}`); + }); + + it('opens the Pinterest pin intent with the photo-share proxy image and a description -- unaffected by the rescue site\'s own Open Graph tags', () => { + let openedUrl; + window.open = (url) => { openedUrl = url; }; + window.renderCard(shareCardData); + clickMenuItem(openShareMenu(), 'Pinterest'); + + assert.ok(openedUrl.startsWith('https://www.pinterest.com/pin/create/button/?')); + assert.ok(openedUrl.includes(`url=${encodeURIComponent('https://rescuegroups.org/animals/luna')}`)); + const mediaParam = decodeURIComponent(openedUrl.match(/media=([^&]+)/)[1]); + assert.ok(mediaParam.includes('/api/photo-share?url='), 'must use the CORS-safe photo-share proxy, not the CDN directly'); + assert.ok(decodeURIComponent(openedUrl).includes('Luna')); + }); + + it('opens the Nextdoor share plugin with the composed message as the body', () => { let openedUrl; window.open = (url) => { openedUrl = url; }; window.renderCard(shareCardData); - clickMenuItem(openShareMenu(), 'LinkedIn'); - assert.equal(openedUrl, `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent('https://rescuegroups.org/animals/luna')}`); + clickMenuItem(openShareMenu(), 'Nextdoor'); + + assert.ok(openedUrl.startsWith('https://nextdoor.com/sharekit/?source=tabby&body=')); + const body = decodeURIComponent(openedUrl.split('body=')[1]); + assert.ok(body.includes('https://rescuegroups.org/animals/luna')); + assert.ok(body.includes('Get Tabby:')); }); it('copies the composed message to the clipboard', async () => { From dcacd923d2c33ec2987963e1deaa2bf1e815919a Mon Sep 17 00:00:00 2001 From: BrandonML Date: Mon, 14 Sep 2026 14:59:44 -0400 Subject: [PATCH 3/7] Fix Pinterest/Reddit share params, harden mailto anchor click Pinterest: its pin-creation API fetches the `media` URL itself, server-side, so a non-public/non-https backend (the local dev BACKEND_URL) can't be reached from there -- it surfaced as a raw "not a valid URL format" error in Pinterest's own dialog instead of degrading gracefully. Only send `media` when it's a real https URL (always true in a release build -- see scripts/release.js); omit it otherwise and still let the pin go through without a forced image. Reddit: a link post (url= + title=) has no body-text field at all, and its thumbnail comes from Reddit scraping the target page's Open Graph tags -- same reliability problem as Facebook. Submit it as a self/text post instead (title= + text=) with the same composed message as WhatsApp/Nextdoor; the profile link is still right there in the text and Reddit auto-links it. Twitter/X's web intent has no media parameter at all -- confirmed via their own developer docs, not something fixable client-side. Email: append the synthetic anchor to the document before clicking it (and remove it right after) instead of clicking it detached, matching how other "trigger mailto/download via synthetic click" implementations do it. Co-Authored-By: Claude Sonnet 5 --- extension/newtab.js | 46 +++++++++++++++++++++++++++++++-------------- test/newtab.test.js | 19 ++++++++++++++----- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/extension/newtab.js b/extension/newtab.js index a3fc62a..792b210 100644 --- a/extension/newtab.js +++ b/extension/newtab.js @@ -445,11 +445,17 @@ function openShareTarget(url) { // window.open('mailto:...') is unreliable in Chrome -- it silently does // nothing in a lot of real-world configurations. A real anchor click is what // browsers actually special-case for handing a non-http(s) scheme off to the -// OS/registered app without navigating this page. +// OS/registered app without navigating this page. Briefly attaching it to +// the document (rather than clicking it detached) matches how every other +// "trigger a mailto/download via a synthetic click" implementation does it -- +// some engines only give an element real activation/navigation behavior once +// it's actually connected. function openMailto(url) { const link = document.createElement("a"); link.href = url; + document.body.appendChild(link); link.click(); + link.remove(); } async function copyShareLink(card, shareUrl) { @@ -467,24 +473,36 @@ function sharePhotoUrl(card) { return `${backendUrl}/api/photo-share?url=${encodeURIComponent(card.imageUrl)}`; } -// Facebook only ever takes a URL -- it builds its own preview card by -// scraping that page's Open Graph tags, not from anything Tabby sends, and -// most rescues' RescueGroups-hosted pages don't have (correct) OG tags, so -// this one channel is stuck showing generic/missing content until the -// cat-details share page (tracked separately) replaces the raw profile link. -// Reddit and Pinterest sidestep that entirely -- their intents accept the -// title/image/description directly as params, so they show real cat details -// regardless of the rescue's own site. Text-composer channels (WhatsApp, -// email, Nextdoor, copy) get the fully composed message so their -// content/ordering is exact (issue #35), not left to how a native share -// target happens to join separate text/url fields back together. +// Facebook and X only ever take a URL/text -- any card image comes from that +// page's own Open Graph/Twitter Card tags (X has no media param at all -- +// there's no fix for that one short of Twitter adding it), and most rescues' +// RescueGroups-hosted pages don't have (correct) OG tags, so these two are +// stuck showing generic/missing content until the cat-details share page +// (tracked separately) replaces the raw profile link. Pinterest sidesteps +// that -- its intent takes the photo directly via `media`, guarded below to +// a real https URL (Pinterest fetches it server-side, so a local dev +// backend can't be reached and previously surfaced as a confusing error in +// Pinterest's own dialog instead of degrading gracefully). Reddit's +// link-post mode has the same OG-thumbnail problem *and* no body field at +// all, so it's submitted as a self/text post instead, like the +// text-composer channels below (WhatsApp, email, Nextdoor, copy) -- all get +// the fully composed message so their content/ordering is exact (issue +// #35), not left to how a native share target happens to join separate +// text/url fields back together. const SHARE_CHANNELS = [ { label: "WhatsApp", activate: (card, shareUrl) => openShareTarget(`https://wa.me/?text=${encodeURIComponent(buildShareMessage(card, shareUrl))}`) }, { label: "Email", activate: (card, shareUrl) => openMailto(`mailto:?subject=${encodeURIComponent(`Meet ${card.name}`)}&body=${encodeURIComponent(buildShareMessage(card, shareUrl))}`) }, { label: "X / Twitter", activate: (card, shareUrl) => openShareTarget(`https://twitter.com/intent/tweet?text=${encodeURIComponent(buildShareIntro(card))}&url=${encodeURIComponent(shareUrl)}`) }, { label: "Facebook", activate: (_card, shareUrl) => openShareTarget(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`) }, - { label: "Reddit", activate: (card, shareUrl) => openShareTarget(`https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(`Meet ${card.name}`)}`) }, - { label: "Pinterest", activate: (card, shareUrl) => openShareTarget(`https://www.pinterest.com/pin/create/button/?url=${encodeURIComponent(shareUrl)}&media=${encodeURIComponent(sharePhotoUrl(card))}&description=${encodeURIComponent(buildShareIntro(card))}`) }, + { label: "Reddit", activate: (card, shareUrl) => openShareTarget(`https://www.reddit.com/submit?title=${encodeURIComponent(`Meet ${card.name}`)}&text=${encodeURIComponent(buildShareMessage(card, shareUrl))}`) }, + { + label: "Pinterest", + activate: (card, shareUrl) => { + const photoUrl = sharePhotoUrl(card); + const mediaParam = photoUrl.startsWith("https://") ? `&media=${encodeURIComponent(photoUrl)}` : ""; + openShareTarget(`https://www.pinterest.com/pin/create/button/?url=${encodeURIComponent(shareUrl)}${mediaParam}&description=${encodeURIComponent(buildShareIntro(card))}`); + } + }, { label: "Nextdoor", activate: (card, shareUrl) => openShareTarget(`https://nextdoor.com/sharekit/?source=tabby&body=${encodeURIComponent(buildShareMessage(card, shareUrl))}`) }, { label: "Copy link", activate: (card, shareUrl) => copyShareLink(card, shareUrl) } ]; diff --git a/test/newtab.test.js b/test/newtab.test.js index 037a99e..3f20761 100644 --- a/test/newtab.test.js +++ b/test/newtab.test.js @@ -349,15 +349,19 @@ describe('newtab.js DOM manipulation', () => { assert.equal(openedUrl, `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent('https://rescuegroups.org/animals/luna')}`); }); - it('opens the Reddit submit intent with a title and the profile url -- Reddit, unlike Facebook, accepts the title directly', () => { + it('opens Reddit as a self/text post with a title and the composed message as the body -- a link post has no body field and its thumbnail depends on the rescue site\'s own Open Graph tags, same as Facebook', () => { let openedUrl; window.open = (url) => { openedUrl = url; }; window.renderCard(shareCardData); clickMenuItem(openShareMenu(), 'Reddit'); - assert.equal(openedUrl, `https://www.reddit.com/submit?url=${encodeURIComponent('https://rescuegroups.org/animals/luna')}&title=${encodeURIComponent('Meet Luna')}`); + + assert.ok(openedUrl.startsWith('https://www.reddit.com/submit?title=Meet%20Luna&text=')); + const body = decodeURIComponent(openedUrl.split('text=')[1]); + assert.ok(body.includes('https://rescuegroups.org/animals/luna')); + assert.ok(body.includes('Get Tabby:')); }); - it('opens the Pinterest pin intent with the photo-share proxy image and a description -- unaffected by the rescue site\'s own Open Graph tags', () => { + it('opens the Pinterest pin intent with a description and the photo-share proxy image, when the backend is a real https url', () => { let openedUrl; window.open = (url) => { openedUrl = url; }; window.renderCard(shareCardData); @@ -365,9 +369,14 @@ describe('newtab.js DOM manipulation', () => { assert.ok(openedUrl.startsWith('https://www.pinterest.com/pin/create/button/?')); assert.ok(openedUrl.includes(`url=${encodeURIComponent('https://rescuegroups.org/animals/luna')}`)); - const mediaParam = decodeURIComponent(openedUrl.match(/media=([^&]+)/)[1]); - assert.ok(mediaParam.includes('/api/photo-share?url='), 'must use the CORS-safe photo-share proxy, not the CDN directly'); assert.ok(decodeURIComponent(openedUrl).includes('Luna')); + // This suite's config.js BACKEND_URL is the real dev value, + // http://localhost:8787 (see scripts/release.js) -- not https, so + // `media` is correctly omitted here. The included-branch is exactly + // symmetric (see the guard in newtab.js) and was verified against a + // real https backend in a real browser rather than duplicated here. + assert.ok(!openedUrl.includes('media='), 'a non-https backend (local dev) must not send media -- Pinterest fetches it server-side and can\'t reach localhost, which previously surfaced as an error in Pinterest\'s own dialog'); + assert.ok(openedUrl.includes('description='), 'the pin should still work without a forced image'); }); it('opens the Nextdoor share plugin with the composed message as the body', () => { From f45e9901698bb0e435c52b8e77a4c3ba7d425e9e Mon Sep 17 00:00:00 2001 From: BrandonML Date: Mon, 14 Sep 2026 15:32:29 -0400 Subject: [PATCH 4/7] Open email share in a new tab instead of the current one Co-Authored-By: Claude Sonnet 5 --- extension/newtab.js | 2 ++ test/newtab.test.js | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/extension/newtab.js b/extension/newtab.js index 792b210..946c71a 100644 --- a/extension/newtab.js +++ b/extension/newtab.js @@ -453,6 +453,8 @@ function openShareTarget(url) { function openMailto(url) { const link = document.createElement("a"); link.href = url; + link.target = "_blank"; + link.rel = "noreferrer"; document.body.appendChild(link); link.click(); link.remove(); diff --git a/test/newtab.test.js b/test/newtab.test.js index 3f20761..801317c 100644 --- a/test/newtab.test.js +++ b/test/newtab.test.js @@ -313,10 +313,10 @@ describe('newtab.js DOM manipulation', () => { assert.ok(message.includes('https://rescuegroups.org/animals/luna\n\n'), 'the profile link must sit on its own blank-separated line, ahead of the Tabby plug'); }); - it('opens the mail client via a real anchor click (window.open silently fails for mailto: in Chrome) with a subject and the composed message as the body', () => { - let clickedHref; + it('opens the mail client via a real, new-tab anchor click (window.open silently fails for mailto: in Chrome) with a subject and the composed message as the body', () => { + let clickedHref, clickedTarget, clickedRel; const originalClick = window.HTMLAnchorElement.prototype.click; - window.HTMLAnchorElement.prototype.click = function () { clickedHref = this.href; }; + window.HTMLAnchorElement.prototype.click = function () { clickedHref = this.href; clickedTarget = this.target; clickedRel = this.rel; }; try { window.renderCard(shareCardData); clickMenuItem(openShareMenu(), 'Email'); @@ -325,6 +325,8 @@ describe('newtab.js DOM manipulation', () => { } assert.ok(clickedHref.startsWith('mailto:?subject=Meet%20Luna&body=')); + assert.equal(clickedTarget, '_blank'); + assert.equal(clickedRel, 'noreferrer'); const body = decodeURIComponent(clickedHref.split('body=')[1]); assert.ok(body.includes('https://rescuegroups.org/animals/luna')); assert.ok(body.includes('Get Tabby:')); From ec22419cb4e77a51b85fb2f7941f2a735898df58 Mon Sep 17 00:00:00 2001 From: BrandonML Date: Mon, 14 Sep 2026 15:53:05 -0400 Subject: [PATCH 5/7] Add an explicit favicon link so Edge shows it on a fresh new tab (#33) Neither page declared a favicon, so each browser fell back to its own default resolution. Chrome (and Edge, but only when a page is reached by direct navigation, e.g. options.html) derives one from the extension's manifest icons; Edge specifically doesn't do that same fallback for a chrome_url_overrides.newtab page in a freshly opened tab, which is why the icon only showed up after visiting settings and persisted only by leftover tab state, not on a new tab. An explicit removes the reliance on that fallback entirely. Co-Authored-By: Claude Sonnet 5 --- extension/newtab.html | 2 +- extension/options.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extension/newtab.html b/extension/newtab.html index 6b2ca2c..ff44a91 100644 --- a/extension/newtab.html +++ b/extension/newtab.html @@ -1,6 +1,6 @@ - Tabby + Tabby
diff --git a/extension/options.html b/extension/options.html index c8beefa..2ec4371 100644 --- a/extension/options.html +++ b/extension/options.html @@ -1 +1 @@ -Tabby settings

TABBY SETTINGS

Your Location

or

+Tabby settings

TABBY SETTINGS

Your Location

or

From 84f01d4d359c3266dbfd42f3ac64d60d6189d20b Mon Sep 17 00:00:00 2001 From: BrandonML Date: Mon, 14 Sep 2026 16:05:15 -0400 Subject: [PATCH 6/7] Add a root favicon.ico as a second attempt at #33's Edge new-tab icon The explicit added earlier didn't fix Edge showing a placeholder on a freshly opened new tab -- one theory for that gap is Edge's new-tab-page icon resolution specifically probes for an implicit /favicon.ico at the origin root rather than reading the page's own tags, unlike normal page navigation. Added one (PNG-in-ICO container wrapping icon32.png, no external tooling needed) at the package root, and wired it into the release zip -- the release script only ever copied manifest.json + extension/ before, so this would otherwise have shipped in git but not in a real release build. Confidence on this one is genuinely lower than the last fix -- if it doesn't resolve the new-tab case either, the plan is to revert this and close #33 as a won't-fix Edge platform limitation. Co-Authored-By: Claude Sonnet 5 --- favicon.ico | Bin 0 -> 1306 bytes scripts/release.js | 2 ++ test/release.test.js | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 favicon.ico diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..90c4df821c957f3a59c376fa4ce47fb6f34fa3ab GIT binary patch literal 1306 zcmV+#1?Bnx0096203aX$0096X00ad902TlM0EtjeM-2)Z3IG5A4M|8uQUCw|AOHXW zAP5Ek0047(dh`GQ1i48>K~#7F#8zu;6jc=d?vy^1lD0x9l@>@*QY=O)C0gkAv4o(F zG~$oaLKoT^6BA7g2%>~qCGnN0810`Twzazj#2*^{Y28OH4J|a-x>blgB=WPBftv5*bGefBXW5Spq5EeXL-H-yV6ai zS=w^Em*UzsSFV|XwBG=%=XP%{hUcZE@e zHPwn9i!-pi2-vGCAOzSP6}3FTw_Js-=dz+=)U170*t!y%b%KULy{IQaLI4L|CdbOw ztSkZDIq}8=0h)wey85@6lOzem5st;6_EZGOY>@?Dgg4mNg6^E06My*e;@;iDXiZpY z(FzcU)>g)%J%s=;%(F@;Sd@>&^Yc)(x;#d`a_t|GsQ|@@LtGUzoufS!0f6OGRJFQH z)S}|lL^zsZVNMPnU%W&hDpxq>gXpOWFsJcI8h^@5mlyMua#d+|K|z3LO3J_(6E1Nn zM>rNkLIP~dS3vt!v#)#_k1klio7#julD80*98-G{mvU4;-kgvC-5tw7T1qND-_eZM z>b3~`V&@)YJeV%}@UWP5IRY^rECtYc)47Xus!qF1CIHP4#Taq8Q4Zy57=}wr0k$WL zF&+#m$zhC|ri65ETmeN(mg3<#3BR9og~_2@szGZriz^^6J4ct-^Ro{%wN0q7SEJ?K zj}QokaN|Y*%`G1YtfsCB9&ev6XKD>ykUMb&FL6y*vAZ{p3}dr|mU0k&^{1;>sZ zM|-;q2e=&{Iw`Qir3Kizs|BG@NJ-*jZgw^w&9Arua_8OIfmyR=;>e+|(DL>hsD5r8 zx?NwQVQU?l8aJW4t4&}OBTjm{@|!?y?`j8ze)m(2nC0i+d1=m>orTQI3}j_yV)jE> zh8zM%&YY&n)Z*^IxhrF)i4-{7)eSOQo7t%g=gdL^k1ya-d;ynx+#nN`Oor1J6A&On z9x%!utI8g%m8>QwWfld;l`SN<=?~Zr#HBo%=B{IiU~4@g~EQc(0=klxs+%UQ)P? zVnTq|={><;V66lg*`F8BV@t~}4ETqQ!a9sN4*pd=^VbC(u63waZ&$B4TOojg;gs9W z{9MLpZDiN^8}Xg}t@yON13iQNICcIk0yjg#XiXT!h(lbX8d2=BbUU&qx<*XE(e|Uk zNOcF;rA7bNM+l0}YFp3%FXs{nyQe>I;TW{>AGA4jXeFyu- zeJ=L~ITC5${$W5Kg)C3<_4F>0E04;Mzli{iKBv3d+v&ZoEv6Ho270@_UvqMu&*@p_ zb9s_|PB)vDxje}}UZdCPsr|+2?o{h*=l=l!0RR6#vUfiK000I_L_t&o03nyHP$qJo Q5C8xG07*qoM6N<$f<{(Wy8r+H literal 0 HcmV?d00001 diff --git a/scripts/release.js b/scripts/release.js index 623ee3f..e764cf2 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -70,6 +70,7 @@ export function run(argv, rootDir = ROOT) { const manifestPath = path.join(rootDir, "manifest.json"); const packagePath = path.join(rootDir, "package.json"); const extensionDir = path.join(rootDir, "extension"); + const faviconPath = path.join(rootDir, "favicon.ico"); const distDir = path.join(rootDir, "dist"); writeJsonVersion(manifestPath, version); @@ -86,6 +87,7 @@ export function run(argv, rootDir = ROOT) { const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "tabby-release-")); try { fs.copyFileSync(manifestPath, path.join(stagingDir, "manifest.json")); + fs.copyFileSync(faviconPath, path.join(stagingDir, "favicon.ico")); fs.cpSync(extensionDir, path.join(stagingDir, "extension"), { recursive: true }); writeBackendUrl(path.join(stagingDir, "extension", "config.js"), backendUrl); diff --git a/test/release.test.js b/test/release.test.js index 395e861..b272d0b 100644 --- a/test/release.test.js +++ b/test/release.test.js @@ -38,7 +38,7 @@ describe("release script", () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tabby-release-test-")); - for (const name of ["manifest.json", "package.json", "extension", "server", "test", "package-lock.json"]) { + for (const name of ["manifest.json", "package.json", "extension", "server", "test", "package-lock.json", "favicon.ico"]) { const src = path.join(REPO_ROOT, name); if (fs.existsSync(src)) { fs.cpSync(src, path.join(tmpDir, name), { recursive: true }); @@ -96,6 +96,7 @@ describe("release script", () => { const entries = listZipEntries(fs.readFileSync(zipPath)); assert.ok(entries.includes("manifest.json")); + assert.ok(entries.includes("favicon.ico"), "favicon.ico must ship at the package root -- that's where a browser's implicit /favicon.ico probe looks (issue #33)"); assert.ok(entries.some((name) => name.startsWith("extension/"))); for (const name of entries) { From 7481d3992184401660a5e59b8c5a1f9b816100ed Mon Sep 17 00:00:00 2001 From: BrandonML Date: Mon, 14 Sep 2026 16:14:07 -0400 Subject: [PATCH 7/7] chore(release): bump version to 2.1.0 Co-Authored-By: Claude Sonnet 5 --- manifest.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index f076cf2..e1b3da6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Tabby: New Tab for Adoptable Cats", - "version": "2.0.0", + "version": "2.1.0", "description": "See one real, nearby adoptable cat on every new tab.", "permissions": [ "storage", diff --git a/package.json b/package.json index 27cd339..716d75b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tabby", - "version": "2.0.0", + "version": "2.1.0", "private": true, "type": "module", "scripts": {