Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions extension/newtab.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
2 changes: 1 addition & 1 deletion extension/newtab.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Tabby</title><link rel="preload" href="fonts/fraunces.woff2" as="font" type="font/woff2" crossorigin><link rel="stylesheet" href="newtab.css"></head>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Tabby</title><link rel="icon" type="image/png" sizes="16x16" href="icons/icon16.png"><link rel="icon" type="image/png" sizes="32x32" href="icons/icon32.png"><link rel="preload" href="fonts/fraunces.woff2" as="font" type="font/woff2" crossorigin><link rel="stylesheet" href="newtab.css"></head>
<body>
<main class="shell">
<header>
Expand Down
207 changes: 186 additions & 21 deletions extension/newtab.js
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion extension/options.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Tabby settings</title><link rel="stylesheet" href="newtab.css"></head><body><main class="shell"><header><p class="eyebrow">TABBY SETTINGS</p><button id="close-settings" class="quiet" type="button" aria-label="Close settings">Close</button></header><section class="location-panel"><h1>Your Location</h1><form id="settings-form"><div class="location-actions"><button id="use-location" class="primary" type="button">Use my location</button></div><p class="or-divider">or</p><div class="zip-field"><div class="zip-input-wrap"><label for="zip">Zip Code <span class="label-optional">(optional)</span></label><input id="zip" inputmode="numeric" maxlength="5" pattern="[0-9]{5}" placeholder="90210"></div><button type="submit">Save</button></div></form><p id="saved" class="notice" role="status" hidden></p></section></main><script type="module" src="options.js"></script></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Tabby settings</title><link rel="icon" type="image/png" sizes="16x16" href="icons/icon16.png"><link rel="icon" type="image/png" sizes="32x32" href="icons/icon32.png"><link rel="stylesheet" href="newtab.css"></head><body><main class="shell"><header><p class="eyebrow">TABBY SETTINGS</p><button id="close-settings" class="quiet" type="button" aria-label="Close settings">Close</button></header><section class="location-panel"><h1>Your Location</h1><form id="settings-form"><div class="location-actions"><button id="use-location" class="primary" type="button">Use my location</button></div><p class="or-divider">or</p><div class="zip-field"><div class="zip-input-wrap"><label for="zip">Zip Code <span class="label-optional">(optional)</span></label><input id="zip" inputmode="numeric" maxlength="5" pattern="[0-9]{5}" placeholder="90210"></div><button type="submit">Save</button></div></form><p id="saved" class="notice" role="status" hidden></p></section></main><script type="module" src="options.js"></script></body></html>
Binary file added favicon.ico
Binary file not shown.
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tabby",
"version": "2.0.0",
"version": "2.1.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 2 additions & 0 deletions scripts/release.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);

Expand Down
Loading
Loading