diff --git a/crowdin.yml b/crowdin.yml
index 2fd87a2fc..dfb77e78b 100644
--- a/crowdin.yml
+++ b/crowdin.yml
@@ -26,3 +26,5 @@ files:
translation: browser/browser/zen-space-routing.ftl
- source: en-US/browser/browser/zen-command-palette.ftl
translation: browser/browser/zen-command-palette.ftl
+ - source: en-US/browser/browser/zen-share.ftl
+ translation: browser/browser/zen-share.ftl
diff --git a/locales/en-US/browser/browser/zen-share.ftl b/locales/en-US/browser/browser/zen-share.ftl
new file mode 100644
index 000000000..5df7e87c6
--- /dev/null
+++ b/locales/en-US/browser/browser/zen-share.ftl
@@ -0,0 +1,45 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+zen-share-space =
+ .label = Share Space…
+zen-share-folder =
+ .label = Share Folder…
+zen-share-split-view =
+ .label = Share Split View…
+
+zen-share-link-copied-toast = Share link copied!
+zen-share-link-expires-description = Anyone with the link can open it until { $date }
+zen-share-link-permanent-description = Anyone with the link can open it
+
+zen-share-error-toast = Couldn't create a share link
+zen-share-error-auth-description = The share server rejected your key
+zen-share-error-too-large-description = This share is too big to upload
+zen-share-error-rate-limited-description = You're sharing too fast, try again in a minute
+zen-share-error-network-description = Couldn't reach the share server
+zen-share-error-empty-description = There's nothing shareable here
+zen-share-error-server-description = The share server returned an error
+
+zen-share-overlay-loading = Loading shared tabs…
+zen-share-overlay-from-space = A Space from { $name }
+zen-share-overlay-from-folder = A Folder from { $name }
+zen-share-overlay-shared-with-you = Shared with you
+zen-share-overlay-add-space = Add Space to Zen
+zen-share-overlay-add-folder = Add Folder to Zen
+zen-share-imported-toast = Added to your browser!
+zen-share-import-error-toast = Couldn't import this share
+zen-share-import-error-dead-description = The link has expired or doesn't exist
+zen-share-import-error-invalid-description = The shared data is invalid
+
+zen-share-confirm-window =
+ .title = Share with your buds
+zen-share-confirm-title = Share with your buds
+zen-share-confirm-dialog =
+ .buttonlabelaccept = Share
+zen-share-confirm-description = Zen uploads a copy of these tabs to the share server and copies a link anyone can open. The link expires after 30 days.
+zen-share-confirm-anonymous = Sharing is anonymous unless you add a name.
+zen-share-confirm-name-input =
+ .placeholder = Your name (optional)
+zen-share-confirm-dont-ask =
+ .label = Don't ask me again
diff --git a/prefs/zen/share.yaml b/prefs/zen/share.yaml
index d4cdd1a20..a9853c5c1 100644
--- a/prefs/zen/share.yaml
+++ b/prefs/zen/share.yaml
@@ -2,5 +2,18 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
-- name: zen.share.enabled
+- name: zen.share.base-url
+ value: "https://share.zen-browser.app"
+
+- name: zen.share.secret-key
+ value: ""
+
+- name: zen.share.display-name
+ value: ""
+
+# Allow zen.share.display-name to be synced across devices.
+- name: services.sync.prefs.sync.zen.share.display-name
value: true
+
+- name: zen.share.dont-ask-before-sharing
+ value: false
diff --git a/src/browser/base/content/zen-assets.jar.inc.mn b/src/browser/base/content/zen-assets.jar.inc.mn
index 07d61a897..a663368a2 100644
--- a/src/browser/base/content/zen-assets.jar.inc.mn
+++ b/src/browser/base/content/zen-assets.jar.inc.mn
@@ -21,3 +21,4 @@
#include ../../../zen/boosts/jar.inc.mn
#include ../../../zen/live-folders/jar.inc.mn
#include ../../../zen/space-routing/jar.inc.mn
+#include ../../../zen/share/jar.inc.mn
diff --git a/src/browser/base/content/zen-commands.inc.xhtml b/src/browser/base/content/zen-commands.inc.xhtml
index 3019feaca..4205420cc 100644
--- a/src/browser/base/content/zen-commands.inc.xhtml
+++ b/src/browser/base/content/zen-commands.inc.xhtml
@@ -40,6 +40,7 @@
+
diff --git a/src/browser/base/content/zen-locales.inc.xhtml b/src/browser/base/content/zen-locales.inc.xhtml
index 2b0ec6692..fca9b29ea 100644
--- a/src/browser/base/content/zen-locales.inc.xhtml
+++ b/src/browser/base/content/zen-locales.inc.xhtml
@@ -12,4 +12,5 @@
+
diff --git a/src/browser/base/content/zen-panels/popups.inc b/src/browser/base/content/zen-panels/popups.inc
index 2ff92383d..2fff39cca 100644
--- a/src/browser/base/content/zen-panels/popups.inc
+++ b/src/browser/base/content/zen-panels/popups.inc
@@ -32,6 +32,7 @@
+
@@ -55,6 +56,7 @@
+
diff --git a/src/zen/common/ZenPreloadedScripts.js b/src/zen/common/ZenPreloadedScripts.js
index e8128603a..652683803 100644
--- a/src/zen/common/ZenPreloadedScripts.js
+++ b/src/zen/common/ZenPreloadedScripts.js
@@ -28,6 +28,7 @@
"chrome://browser/content/zen-components/ZenEmojiPicker.mjs",
"chrome://browser/content/zen-components/ZenLiveFoldersUI.mjs",
"chrome://browser/content/zen-components/ZenDownloadAnimation.mjs",
+ "resource:///modules/zen/share/ZenShareManager.mjs",
];
for (let script of scripts) {
diff --git a/src/zen/common/styles/zen-browser-container.css b/src/zen/common/styles/zen-browser-container.css
index 395a97c2b..e07ed229a 100644
--- a/src/zen/common/styles/zen-browser-container.css
+++ b/src/zen/common/styles/zen-browser-container.css
@@ -5,6 +5,7 @@
*/
:root:not([inDOMFullscreen="true"]):not([chromehidden~="location"]):not([chromehidden~="toolbar"]) {
& #tabbrowser-tabbox #tabbrowser-tabpanels .browserSidebarContainer {
+ --zen-transparent-browser-background: light-dark(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.1));
overflow: clip;
:root:not([zen-no-padding="true"]) &:not(.zen-glance-overlay) {
@@ -18,7 +19,7 @@
}
&[transparent="true"] {
- background: light-dark(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.1));
+ background: var(--zen-transparent-browser-background);
}
}
diff --git a/src/zen/common/styles/zen-buttons.css b/src/zen/common/styles/zen-buttons.css
index 0f6bbae5c..5e8700a00 100644
--- a/src/zen/common/styles/zen-buttons.css
+++ b/src/zen/common/styles/zen-buttons.css
@@ -78,3 +78,52 @@ dialog::part(dialog-button) {
scale: 0.98;
}
}
+
+:root {
+ --zen-accent-button-color: light-dark(
+ oklch(from var(--zen-primary-color) clamp(0.35, l, 0.6) c h),
+ oklch(from var(--zen-primary-color) clamp(0.7, l, 0.85) c h)
+ );
+ --zen-accent-button-background: light-dark(
+ oklch(from var(--zen-primary-color) clamp(0.35, l, 0.6) c h),
+ oklch(from var(--zen-primary-color) clamp(0.55, l, 0.7) c h)
+ );
+}
+
+.zen-big-accent-button {
+ appearance: none;
+ border: none;
+ border-radius: 12px;
+ padding: 1.2rem 1rem;
+ background: transparent;
+ color: var(--zen-accent-button-color);
+ font: inherit;
+ font-size: 1.2rem;
+ font-weight: 600;
+ cursor: pointer;
+ will-change: transform;
+ transition: scale 0.15s ease-out;
+
+ &:hover {
+ scale: 1.03;
+ filter: brightness(1.1);
+ }
+
+ &:active:hover {
+ scale: 1;
+ color: color-mix(in srgb, var(--zen-accent-button-color) 80%, transparent);
+ }
+
+ &.primary {
+ background: var(--zen-accent-button-background);
+ color: white;
+
+ &:hover {
+ filter: brightness(1.1);
+ }
+
+ &:active:hover {
+ color: rgba(255, 255, 255, 0.8);
+ }
+ }
+}
diff --git a/src/zen/common/zen-sets.js b/src/zen/common/zen-sets.js
index a1b663436..34a57d79d 100644
--- a/src/zen/common/zen-sets.js
+++ b/src/zen/common/zen-sets.js
@@ -90,6 +90,9 @@ document.addEventListener(
case "cmd_zenCtxDeleteWorkspace":
gZenWorkspaces.contextDeleteWorkspace(event);
break;
+ case "cmd_zenCtxShareWorkspace":
+ gZenWorkspaces.contextShareWorkspace();
+ break;
case "cmd_zenChangeWorkspaceName":
gZenVerticalTabsManager.renameTabStart({
target: gZenWorkspaces.activeWorkspaceIndicator.querySelector(
diff --git a/src/zen/folders/ZenFolders.mjs b/src/zen/folders/ZenFolders.mjs
index 40078fc9f..1c27e5c6f 100644
--- a/src/zen/folders/ZenFolders.mjs
+++ b/src/zen/folders/ZenFolders.mjs
@@ -183,6 +183,9 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
case "context_zenFolderChangeIcon":
this.changeFolderUserIcon(this.#lastFolderContextMenu);
break;
+ case "context_zenShareFolder":
+ gZenShareManager.shareFolder(this.#lastFolderContextMenu);
+ break;
}
});
}
diff --git a/src/zen/folders/jar.inc.mn b/src/zen/folders/jar.inc.mn
index 091b8bf6d..1a0767344 100644
--- a/src/zen/folders/jar.inc.mn
+++ b/src/zen/folders/jar.inc.mn
@@ -4,4 +4,4 @@
content/browser/zen-components/ZenFolder.mjs (../../zen/folders/ZenFolder.mjs)
content/browser/zen-components/ZenFolders.mjs (../../zen/folders/ZenFolders.mjs)
- content/browser/zen-styles/zen-folders.css (../../zen/folders/zen-folders.css)
+* content/browser/zen-styles/zen-folders.css (../../zen/folders/zen-folders.css)
diff --git a/src/zen/folders/zen-folder-icon.inc.css b/src/zen/folders/zen-folder-icon.inc.css
new file mode 100644
index 000000000..8bd922e14
--- /dev/null
+++ b/src/zen/folders/zen-folder-icon.inc.css
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+& g,
+& rect,
+& path {
+ transition:
+ transform 0.3s cubic-bezier(0.42, 0, 0, 1),
+ opacity 0.3s cubic-bezier(0.42, 0, 0, 1);
+}
+
+&[state="open"] .back {
+ transform: skewX(16deg) translate(-4px, 2px) scale(0.85);
+}
+
+&[state="open"] :is(.front, .dots, .icon) {
+ transform: skewX(-16deg) translate(8px, 2px) scale(0.85);
+}
diff --git a/src/zen/folders/zen-folders.css b/src/zen/folders/zen-folders.css
index a413d3445..5cb7f2b1e 100644
--- a/src/zen/folders/zen-folders.css
+++ b/src/zen/folders/zen-folders.css
@@ -8,15 +8,17 @@
display: none !important;
}
+:root {
+ --zen-folder-behind-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color) 60%, gray), color-mix(in srgb, var(--zen-primary-color) 60%, #c1c1c1));
+ --zen-folder-front-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color), white 70%), color-mix(in srgb, var(--zen-primary-color), black 40%));
+ --zen-folder-stroke: light-dark(color-mix(in srgb, var(--zen-primary-color) 50%, black), color-mix(in srgb, var(--zen-colors-primary) 15%, #ebebeb));
+}
+
zen-folder {
display: flex;
flex-direction: column;
visibility: visible;
- --zen-folder-behind-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color) 60%, gray), color-mix(in srgb, var(--zen-primary-color) 60%, #c1c1c1));
- --zen-folder-front-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color), white 70%), color-mix(in srgb, var(--zen-primary-color), black 40%));
- --zen-folder-stroke: light-dark(color-mix(in srgb, var(--zen-primary-color) 50%, black), color-mix(in srgb, var(--zen-colors-primary) 15%, #ebebeb));
-
-moz-window-dragging: no-drag;
transition: var(--zen-tabbox-element-indent-transition);
@@ -114,21 +116,7 @@ zen-folder {
transform: translate(8.5px, 10.5px);
}
- & g,
- & rect,
- & path {
- transition:
- transform 0.3s cubic-bezier(0.42, 0, 0, 1),
- opacity 0.3s cubic-bezier(0.42, 0, 0, 1);
- }
-
- &[state="open"] .back {
- transform: skewX(16deg) translate(-4px, 2px) scale(0.85);
- }
-
- &[state="open"] :is(.front, .dots, .icon) {
- transform: skewX(-16deg) translate(8px, 2px) scale(0.85);
- }
+%include zen-folder-icon.inc.css
& .icon {
opacity: 1;
diff --git a/src/zen/glance/ZenGlanceManager.mjs b/src/zen/glance/ZenGlanceManager.mjs
index 4db6e72fd..4742604f4 100644
--- a/src/zen/glance/ZenGlanceManager.mjs
+++ b/src/zen/glance/ZenGlanceManager.mjs
@@ -392,7 +392,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature {
return Promise.resolve(this.#currentTab);
}
- // Existing-tab glances perform no navigation and are exempt; for fresh
+ // Existing-tab glances perform no navigation and are exempt. For fresh
// loads, refuse any URL the triggering principal isn't allowed to load
// (e.g. a web page linking to file://).
if (!existingTab && !this.#isGlanceLoadAllowed(data)) {
diff --git a/src/zen/share/ZenShareClient.sys.mjs b/src/zen/share/ZenShareClient.sys.mjs
new file mode 100644
index 000000000..53951bdae
--- /dev/null
+++ b/src/zen/share/ZenShareClient.sys.mjs
@@ -0,0 +1,238 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gBaseUrl",
+ "zen.share.base-url",
+ "",
+ null,
+ value => value.trim().replace(/\/+$/, "")
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gSecretKey",
+ "zen.share.secret-key",
+ ""
+);
+
+const SCHEMA_URL = "resource:///modules/zen/share/share.schema.json";
+
+// The server rejects bigger bodies at 25 MiB.
+const MAX_SHARE_BYTES = 25 * 1024 * 1024;
+const MAX_NAME_LENGTH = 200;
+
+// Uploads can be tens of MiB, reads are small JSON.
+const CREATE_TIMEOUT_MS = 120000;
+const READ_TIMEOUT_MS = 30000;
+
+// Public share page paths: /{slug}/{XXXX-XXXX-XXXX-XXXX id}.
+const SHARE_PATH_RE =
+ /^\/(space|folder|split-view|split)\/([0-9A-Za-z]{4}-[0-9A-Za-z]{4}-[0-9A-Za-z]{4}-[0-9A-Za-z]{4})\/?$/;
+
+export class ZenShareError extends Error {
+ /**
+ * @param {string} code - One of "invalid-document", "auth", "too-large",
+ * "rate-limited", "not-found", "network", "server".
+ * @param {string} message
+ */
+ constructor(code, message) {
+ super(message);
+ this.name = "ZenShareError";
+ this.code = code;
+ }
+}
+
+class nsZenShareClient {
+ #validatorPromise = null;
+
+ get #baseUrl() {
+ return lazy.gBaseUrl;
+ }
+
+ #authHeaders() {
+ // A secret key authorizes on its own and makes the share permanent. Never
+ // send both keys.
+ if (lazy.gSecretKey) {
+ return { "x-secret-key": lazy.gSecretKey };
+ }
+ return { "x-api-key": AppConstants.MOZ_MOZILLA_API_KEY };
+ }
+
+ #getValidator() {
+ if (!this.#validatorPromise) {
+ this.#validatorPromise = fetch(SCHEMA_URL, { credentials: "omit" })
+ .then(response => response.json())
+ .then(schema => new lazy.JsonSchema.Validator(schema));
+ }
+ return this.#validatorPromise;
+ }
+
+ /**
+ * Validates a share document against the schema.
+ *
+ * @param {object} doc
+ * @returns {Promise<{valid: boolean, errors: object[]}>}
+ */
+ async validateDocument(doc) {
+ const validator = await this.#getValidator();
+ return validator.validate(doc);
+ }
+
+ async #assertValidDocument(doc) {
+ const result = await this.validateDocument(doc);
+ if (!result.valid) {
+ console.error("ZenShare: document fails the schema", result.errors);
+ throw new ZenShareError(
+ "invalid-document",
+ "share document fails the schema"
+ );
+ }
+ }
+
+ async #request(url, options, timeoutMs = READ_TIMEOUT_MS) {
+ const controller = new AbortController();
+ const timer = lazy.setTimeout(() => controller.abort(), timeoutMs);
+ let response;
+ try {
+ response = await fetch(url, {
+ credentials: "omit",
+ signal: controller.signal,
+ ...options,
+ });
+ } catch (e) {
+ throw new ZenShareError(
+ "network",
+ `could not reach ${url}: ${e.message}`
+ );
+ } finally {
+ lazy.clearTimeout(timer);
+ }
+ if (response.ok) {
+ return response;
+ }
+ let message = `${response.status}`;
+ try {
+ message = (await response.json()).error || message;
+ } catch (e) {}
+ switch (response.status) {
+ case 401:
+ case 403:
+ throw new ZenShareError("auth", message);
+ case 404:
+ throw new ZenShareError("not-found", message);
+ case 413:
+ throw new ZenShareError("too-large", message);
+ case 429:
+ throw new ZenShareError("rate-limited", message);
+ case 400:
+ case 422:
+ throw new ZenShareError("invalid-document", message);
+ default:
+ throw new ZenShareError("server", message);
+ }
+ }
+
+ /**
+ * Uploads a share document.
+ *
+ * @param {object} doc - A document conforming to share.schema.json.
+ * @param {{name?: string}} options - Optional sharer display name, shown on
+ * the share page as "A Space from {name}".
+ * @returns {Promise