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.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/newtab.js b/extension/newtab.js index 4ca07e3..946c71a 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,96 @@ 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()}`; +} + +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. 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; + link.target = "_blank"; + link.rel = "noreferrer"; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +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" }); + } +} + +function sharePhotoUrl(card) { + const backendUrl = BACKEND_URL.replace(/\/$/, ""); + return `${backendUrl}/api/photo-share?url=${encodeURIComponent(card.imageUrl)}`; } +// 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?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) } +]; + 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 +526,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,10 +548,105 @@ 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; +} + +// 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 +// 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); + positionShareMenu(menu, toggleButton); + 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) { const savedLocation = settings?.location; if (savedLocation && Number.isFinite(savedLocation.lat) && Number.isFinite(savedLocation.lon)) { 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

diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..90c4df8 Binary files /dev/null and b/favicon.ico differ 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": { 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/newtab.test.js b/test/newtab.test.js index 84605e6..801317c 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,357 @@ 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', 'Reddit', 'Pinterest', 'Nextdoor', '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('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 }; + }; - window.renderCard(shareCardData); - document.querySelector('#card .share').dispatchEvent(new window.Event('click')); - await new Promise(r => setTimeout(r, 10)); + let menu; + try { + window.renderCard(shareCardData); + menu = openShareMenu(); + } finally { + window.HTMLElement.prototype.getBoundingClientRect = originalRect; + } - assert.ok(sharedData); - assert.equal(sharedData.files, undefined); - assert.equal(sharedData.url, 'https://rescuegroups.org/animals/luna'); - }); + 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('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 = () => {}; + 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 }; + }; - window.renderCard(shareCardData); - document.querySelector('#card .share').dispatchEvent(new window.Event('click')); - await new Promise(r => setTimeout(r, 10)); + let menu; + try { + window.renderCard(shareCardData); + menu = openShareMenu(); + } finally { + window.HTMLElement.prototype.getBoundingClientRect = originalRect; + } - assert.ok(sharedData, 'a failed photo fetch should not block sharing the link and text'); - assert.equal(sharedData.files, undefined); - }); + assert.equal(menu.style.top, '246px'); + }); - 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 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.equal(loggedErrors.length, 0, 'a user-cancelled share should not be logged as an error'); - assert.equal(document.getElementById('notice').textContent, ''); + 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; clickedTarget = this.target; clickedRel = this.rel; }; + try { + window.renderCard(shareCardData); + clickMenuItem(openShareMenu(), 'Email'); + } finally { + window.HTMLAnchorElement.prototype.click = originalClick; + } + + 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:')); + }); + + 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(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('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')}`); + }); + + 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.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 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); + 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')}`)); + 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', () => { + let openedUrl; + window.open = (url) => { openedUrl = url; }; + window.renderCard(shareCardData); + 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 () => { + 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.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" }) }; + }; + + 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(clipboardText.includes('Luna')); - assert.ok(clipboardText.includes('https://rescuegroups.org/animals/luna')); - assert.ok(document.getElementById('notice').textContent.includes('Copied to clipboard')); + 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', () => { 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) {