krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.8.0: HutchSafariExtension/Resources/content.js · raw

  1const hutchSupportedHosts = new Set([
  2  "git.sr.ht",
  3  "hg.sr.ht",
  4  "todo.sr.ht",
  5  "builds.sr.ht",
  6  "lists.sr.ht",
  7  "meta.sr.ht",
  8  "sr.ht",
  9]);
 10
 11const hutchBrowserAPI = globalThis.browser ?? globalThis.chrome;
 12const bannerStorageKey = "showOpenInHutchBanner";
 13const dismissedStorageKey = `dismissedOpenInHutchBanner:${location.hostname}`;
 14
 15function hutchIsOwnerRootPath(path) {
 16  const components = path.split("/").filter(Boolean);
 17  return components.length === 1 && components[0].startsWith("~");
 18}
 19
 20function hutchNormalizedPath(pathname) {
 21  return pathname.split("/").filter(Boolean).join("/");
 22}
 23
 24function hutchDeepLinkPath(hostname, path) {
 25  const components = path.split("/").filter(Boolean);
 26  if (
 27    hostname === "sr.ht" &&
 28    components.length === 2 &&
 29    components[0] === "projects" &&
 30    components[1].startsWith("~")
 31  ) {
 32    return components[1];
 33  }
 34
 35  return path;
 36}
 37
 38function hutchDeepLinkService(hostname, path) {
 39  if (hutchIsOwnerRootPath(path)) {
 40    return "lookup";
 41  }
 42
 43  switch (hostname) {
 44    case "git.sr.ht":
 45      return "git";
 46    case "hg.sr.ht":
 47      return "hg";
 48    case "todo.sr.ht":
 49      return "todo";
 50    case "builds.sr.ht":
 51      return "builds";
 52    case "lists.sr.ht":
 53      return "lists";
 54    default:
 55      return "lookup";
 56  }
 57}
 58
 59function hutchDeepLinkForLocation() {
 60  if (location.protocol !== "https:" || !hutchSupportedHosts.has(location.hostname)) {
 61    return null;
 62  }
 63
 64  const path = hutchDeepLinkPath(location.hostname, hutchNormalizedPath(location.pathname));
 65  const service = hutchDeepLinkService(location.hostname, path);
 66  return `hutch://${service}${path ? `/${path}` : ""}${location.search}${location.hash}`;
 67}
 68
 69function storageGet(defaults) {
 70  return new Promise((resolve) => {
 71    hutchBrowserAPI.storage.local.get(defaults, resolve);
 72  });
 73}
 74
 75function storageSet(values) {
 76  return new Promise((resolve) => {
 77    hutchBrowserAPI.storage.local.set(values, resolve);
 78  });
 79}
 80
 81function showNotice(message) {
 82  const existing = document.getElementById("hutch-open-notice");
 83  existing?.remove();
 84
 85  const notice = document.createElement("div");
 86  notice.id = "hutch-open-notice";
 87  notice.textContent = message;
 88  notice.style.cssText = [
 89    "position:fixed",
 90    "left:50%",
 91    "bottom:18px",
 92    "z-index:2147483647",
 93    "transform:translateX(-50%)",
 94    "background:#1f2937",
 95    "color:white",
 96    "font:13px -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif",
 97    "padding:8px 12px",
 98    "border-radius:8px",
 99    "box-shadow:0 8px 24px rgba(0,0,0,.24)",
100  ].join(";");
101
102  document.documentElement.appendChild(notice);
103  setTimeout(() => notice.remove(), 3500);
104}
105
106async function injectBannerIfEnabled() {
107  const hutchURL = hutchDeepLinkForLocation();
108  if (!hutchURL || document.getElementById("hutch-open-banner")) {
109    return;
110  }
111
112  const settings = await storageGet({
113    [bannerStorageKey]: false,
114    [dismissedStorageKey]: false,
115  });
116  if (!settings[bannerStorageKey] || settings[dismissedStorageKey]) {
117    return;
118  }
119
120  const banner = document.createElement("div");
121  banner.id = "hutch-open-banner";
122  banner.style.cssText = [
123    "position:fixed",
124    "right:14px",
125    "bottom:14px",
126    "z-index:2147483647",
127    "display:flex",
128    "align-items:center",
129    "gap:8px",
130    "background:#111827",
131    "color:white",
132    "font:13px -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif",
133    "padding:8px 10px",
134    "border-radius:8px",
135    "box-shadow:0 8px 24px rgba(0,0,0,.22)",
136  ].join(";");
137
138  const button = document.createElement("button");
139  button.type = "button";
140  button.textContent = "Open in Hutch";
141  button.style.cssText = [
142    "appearance:none",
143    "border:0",
144    "background:transparent",
145    "color:inherit",
146    "font:inherit",
147    "font-weight:600",
148    "padding:0",
149  ].join(";");
150  button.addEventListener("click", () => {
151    console.debug("[Hutch] Banner opening deep link", { sourceURL: location.href, hutchURL });
152    location.href = hutchURL;
153  });
154
155  const dismiss = document.createElement("button");
156  dismiss.type = "button";
157  dismiss.textContent = "Close";
158  dismiss.setAttribute("aria-label", "Dismiss Open in Hutch banner");
159  dismiss.style.cssText = [
160    "appearance:none",
161    "border:0",
162    "background:rgba(255,255,255,.14)",
163    "color:inherit",
164    "font:12px -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif",
165    "padding:3px 6px",
166    "border-radius:6px",
167  ].join(";");
168  dismiss.addEventListener("click", async () => {
169    await storageSet({ [dismissedStorageKey]: true });
170    banner.remove();
171  });
172
173  banner.append(button, dismiss);
174  document.documentElement.appendChild(banner);
175}
176
177hutchBrowserAPI.runtime.onMessage.addListener((message) => {
178  if (message?.type === "hutchUnsupportedPage") {
179    showNotice(message.message);
180  }
181});
182
183injectBannerIfEnabled();