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
+Tabby settings
TABBY SETTINGS
Your Location
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) {