diff --git a/locales/en-US/browser/browser/zen-live-folders.ftl b/locales/en-US/browser/browser/zen-live-folders.ftl
new file mode 100644
index 000000000..12ba2a14f
--- /dev/null
+++ b/locales/en-US/browser/browser/zen-live-folders.ftl
@@ -0,0 +1,98 @@
+# 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-live-folder-options =
+ .label = Live Folder Options
+
+zen-live-folder-last-fetched =
+ .label = Last fetch: { $time }
+
+zen-live-folder-refresh =
+ .label = Refresh
+
+zen-live-folder-github-option-author-self =
+ .label = Created by Me
+
+zen-live-folder-github-option-assigned-self =
+ .label = Assigned to Me
+
+zen-live-folder-github-option-review-requested =
+ .label = Review Requests
+
+zen-live-folder-type-rss =
+ .label = RSS Feed
+
+zen-live-folder-option-fetch-interval =
+ .label = Fetch Interval
+
+zen-live-folder-fetch-interval-mins =
+ .label = { $mins ->
+ [one] 1 minute
+ *[other] { $mins } minutes
+ }
+
+zen-live-folder-fetch-interval-hours =
+ .label = { $hours ->
+ [one] 1 hour
+ *[other] { $hours } hours
+ }
+
+zen-live-folder-rss-option-time-range =
+ .label = Time Range
+
+zen-live-folder-time-range-hours =
+ .label = { $hours ->
+ [one] Last hour
+ *[other] Last { $hours } hours
+ }
+
+zen-live-folder-time-range-all-time =
+ .label = All time
+
+zen-live-folder-time-range-days =
+ .label = { $days ->
+ [one] Last day
+ *[other] Last { $days } days
+ }
+
+zen-live-folder-rss-option-item-limit =
+ .label = Item Limit
+
+zen-live-folder-rss-option-feed-url =
+ .label = Feed URL
+
+zen-live-folder-rss-prompt-feed-url = Please enter the feed URL
+
+zen-live-folder-rss-option-item-limit-num =
+ .label = { $limit } items
+
+zen-live-folder-failed-fetch =
+ .label = Failed to update
+ .tooltiptext = Failed to update. Try again.
+
+zen-live-folder-github-no-auth =
+ .label = Not signed in to GitHub
+ .tooltiptext = Sign back in to GitHub.
+
+zen-live-folder-github-no-filter =
+ .label = Filter is not set
+ .tooltiptext = No filter set, nothing will be fetched.
+
+zen-live-folder-rss-invalid-url-title = Failed to create the Live Folder
+zen-live-folder-rss-invalid-url-description = The feed URL is invalid. Check the address and try again
+
+zen-live-folder-github-option-repo-filter =
+ .label = Repositories
+
+zen-live-folder-github-option-repo =
+ .label = { $repo }
+
+zen-live-folder-github-pull-requests =
+ .label = Pull Requests
+
+zen-live-folder-github-issues =
+ .label = Issues
+
+zen-live-folder-github-option-repo-list-note =
+ .label = This list is generated based on your currently active pull requests.
diff --git a/locales/en-US/browser/browser/zen-vertical-tabs.ftl b/locales/en-US/browser/browser/zen-vertical-tabs.ftl
index cc9791f7f..9f22dd01a 100644
--- a/locales/en-US/browser/browser/zen-vertical-tabs.ftl
+++ b/locales/en-US/browser/browser/zen-vertical-tabs.ftl
@@ -42,4 +42,9 @@ tabbrowser-reset-pin-button =
[one] Reset and pin tab
*[other] Reset and pin { $tabCount } tabs
}
-tab-reset-pin-label = Back to pinned url
+
+zen-tab-sublabel =
+ { $tabSubtitle ->
+ [zen-default-pinned] Back to pinned url
+ *[other] { $tabSubtitle }
+ }
diff --git a/locales/en-US/browser/browser/zen-workspaces.ftl b/locales/en-US/browser/browser/zen-workspaces.ftl
index 097a2b02e..5a4125163 100644
--- a/locales/en-US/browser/browser/zen-workspaces.ftl
+++ b/locales/en-US/browser/browser/zen-workspaces.ftl
@@ -13,6 +13,9 @@ zen-panel-ui-workspaces-create =
zen-panel-ui-folder-create =
.label = Create Folder
+zen-panel-ui-live-folder-create =
+ .label = Live Folder
+
zen-panel-ui-new-empty-split =
.label = New Split
diff --git a/prefs/privatefox/privacy.yaml b/prefs/privatefox/privacy.yaml
index dfe89b769..3640d8c76 100644
--- a/prefs/privatefox/privacy.yaml
+++ b/prefs/privatefox/privacy.yaml
@@ -63,6 +63,10 @@
value: false
locked: true
+- name: browser.search.serpEventTelemetryCategorization.enabled
+ value: false
+ locked: true
+
- name: browser.newtabpage.activity-stream.telemetry
value: false
locked: true
diff --git a/src/browser/base/content/zen-assets.inc.xhtml b/src/browser/base/content/zen-assets.inc.xhtml
index 0ad4b1f6f..1404e03b6 100644
--- a/src/browser/base/content/zen-assets.inc.xhtml
+++ b/src/browser/base/content/zen-assets.inc.xhtml
@@ -51,3 +51,4 @@
+
diff --git a/src/browser/base/content/zen-assets.jar.inc.mn b/src/browser/base/content/zen-assets.jar.inc.mn
index d97cdfaf6..2a44065d4 100644
--- a/src/browser/base/content/zen-assets.jar.inc.mn
+++ b/src/browser/base/content/zen-assets.jar.inc.mn
@@ -18,3 +18,4 @@
#include ../../../zen/images/jar.inc.mn
#include ../../../zen/vendor/jar.inc.mn
#include ../../../zen/fonts/jar.inc.mn
+#include ../../../zen/live-folders/jar.inc.mn
diff --git a/src/browser/base/content/zen-commands.inc.xhtml b/src/browser/base/content/zen-commands.inc.xhtml
index 23bc35478..670e8725d 100644
--- a/src/browser/base/content/zen-commands.inc.xhtml
+++ b/src/browser/base/content/zen-commands.inc.xhtml
@@ -63,4 +63,6 @@
+
+
diff --git a/src/browser/base/content/zen-locales.inc.xhtml b/src/browser/base/content/zen-locales.inc.xhtml
index aac0742ad..2040154fa 100644
--- a/src/browser/base/content/zen-locales.inc.xhtml
+++ b/src/browser/base/content/zen-locales.inc.xhtml
@@ -9,4 +9,5 @@
+
diff --git a/src/browser/base/content/zen-panels/popups.inc b/src/browser/base/content/zen-panels/popups.inc
index 864d88e10..d9b804d6a 100644
--- a/src/browser/base/content/zen-panels/popups.inc
+++ b/src/browser/base/content/zen-panels/popups.inc
@@ -3,6 +3,23 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+
diff --git a/src/browser/components/sessionstore/TabState-sys-mjs.patch b/src/browser/components/sessionstore/TabState-sys-mjs.patch
index a5e05c9ab..01a91ed76 100644
--- a/src/browser/components/sessionstore/TabState-sys-mjs.patch
+++ b/src/browser/components/sessionstore/TabState-sys-mjs.patch
@@ -1,8 +1,8 @@
diff --git a/browser/components/sessionstore/TabState.sys.mjs b/browser/components/sessionstore/TabState.sys.mjs
-index 82721356d191055bec0d4b0ca49e481221988801..68437e6f9fa54fc75ca9e24d738e8afcd0ea22f8 100644
+index 82721356d191055bec0d4b0ca49e481221988801..e01904a6ea73e068c236adecbac6a97adedb2bd3 100644
--- a/browser/components/sessionstore/TabState.sys.mjs
+++ b/browser/components/sessionstore/TabState.sys.mjs
-@@ -85,7 +85,24 @@ class _TabState {
+@@ -85,7 +85,25 @@ class _TabState {
tabData.groupId = tab.group.id;
}
@@ -19,6 +19,7 @@ index 82721356d191055bec0d4b0ca49e481221988801..68437e6f9fa54fc75ca9e24d738e8afc
+ tabData.zenIsGlance = tab.hasAttribute("zen-glance-tab");
+ tabData._zenPinnedInitialState = tab._zenPinnedInitialState;
+ tabData._zenIsActiveTab = tab._zenContentsVisible;
++ tabData.zenLiveFolderItemId = tab.getAttribute("zen-live-folder-item-id");
+
tabData.searchMode = tab.ownerGlobal.gURLBar.getSearchMode(browser, true);
+ if (tabData.searchMode?.source === tab.ownerGlobal.UrlbarUtils.RESULT_SOURCE.ZEN_ACTIONS) {
diff --git a/src/browser/components/tabbrowser/content/tab-js.patch b/src/browser/components/tabbrowser/content/tab-js.patch
index c137b3749..feb3f6822 100644
--- a/src/browser/components/tabbrowser/content/tab-js.patch
+++ b/src/browser/components/tabbrowser/content/tab-js.patch
@@ -1,5 +1,5 @@
diff --git a/browser/components/tabbrowser/content/tab.js b/browser/components/tabbrowser/content/tab.js
-index 836bee14d2b63604688ebe477a5d915a5e99b305..a2aca4443ee1c909149739d002028c6c481aa62a 100644
+index 836bee14d2b63604688ebe477a5d915a5e99b305..a675aed711560b4a44604fc17478cffa7fb68439 100644
--- a/browser/components/tabbrowser/content/tab.js
+++ b/browser/components/tabbrowser/content/tab.js
@@ -21,6 +21,7 @@
@@ -14,7 +14,7 @@ index 836bee14d2b63604688ebe477a5d915a5e99b305..a2aca4443ee1c909149739d002028c6c
-+
++
diff --git a/src/browser/themes/shared/zen-icons/common/selectable/logo-github.svg b/src/browser/themes/shared/zen-icons/common/selectable/logo-github.svg
new file mode 100644
index 000000000..f36d2391e
--- /dev/null
+++ b/src/browser/themes/shared/zen-icons/common/selectable/logo-github.svg
@@ -0,0 +1,5 @@
+#filter dumbComments emptyLines substitution
+# 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/.
+
\ No newline at end of file
diff --git a/src/browser/themes/shared/zen-icons/icons.css b/src/browser/themes/shared/zen-icons/icons.css
index 44b9aff95..3d78a3e93 100644
--- a/src/browser/themes/shared/zen-icons/icons.css
+++ b/src/browser/themes/shared/zen-icons/icons.css
@@ -18,6 +18,11 @@
border-radius: 100% !important;
}
+#zenCreateNewPopup menuitem {
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+}
+
#back-button {
list-style-image: url("back.svg") !important;
}
diff --git a/src/browser/themes/shared/zen-icons/jar.inc.mn b/src/browser/themes/shared/zen-icons/jar.inc.mn
index 091badb41..7317c6ccb 100644
--- a/src/browser/themes/shared/zen-icons/jar.inc.mn
+++ b/src/browser/themes/shared/zen-icons/jar.inc.mn
@@ -437,6 +437,7 @@
* skin/classic/browser/zen-icons/selectable/lightning.svg (../shared/zen-icons/common/selectable/lightning.svg)
* skin/classic/browser/zen-icons/selectable/location.svg (../shared/zen-icons/common/selectable/location.svg)
* skin/classic/browser/zen-icons/selectable/lock-closed.svg (../shared/zen-icons/common/selectable/lock-closed.svg)
+* skin/classic/browser/zen-icons/selectable/logo-github.svg (../shared/zen-icons/common/selectable/logo-github.svg)
* skin/classic/browser/zen-icons/selectable/logo-rss.svg (../shared/zen-icons/common/selectable/logo-rss.svg)
* skin/classic/browser/zen-icons/selectable/logo-usd.svg (../shared/zen-icons/common/selectable/logo-usd.svg)
* skin/classic/browser/zen-icons/selectable/mail.svg (../shared/zen-icons/common/selectable/mail.svg)
diff --git a/src/browser/themes/shared/zen-icons/nucleo/security-broken.svg b/src/browser/themes/shared/zen-icons/nucleo/security-broken.svg
index 27084111b..5027043a8 100644
--- a/src/browser/themes/shared/zen-icons/nucleo/security-broken.svg
+++ b/src/browser/themes/shared/zen-icons/nucleo/security-broken.svg
@@ -2,4 +2,4 @@
# 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/.
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/browser/themes/shared/zen-icons/update-resources.sh b/src/browser/themes/shared/zen-icons/update-resources.sh
index 3c830592a..196e7f7f6 100755
--- a/src/browser/themes/shared/zen-icons/update-resources.sh
+++ b/src/browser/themes/shared/zen-icons/update-resources.sh
@@ -4,6 +4,13 @@
# note: you need to be in the same directory as the script to run it
+# a list of SVG files that we should ignore when exporting, because
+# they render poorly when optimized, and we don't use them in many places
+# so the cost of optimizing them is higher than the benefit of having optimized SVGs
+do_not_optimize=(
+ "security-broken.svg"
+)
+
if [ $(basename $PWD) != "zen-icons" ]; then
echo "You need to be in the zen-icons directory to run this script"
exit 1
@@ -35,6 +42,11 @@ merge_svg_paths() {
stroke_attr=$(grep -o 'stroke="[^"]*"' "$file" | head -n 1)
stroke_width_attr=$(grep -o 'stroke-width="[^"]*"' "$file" | head -n 1)
stroke_opacity_attr=$(grep -o 'stroke-opacity="[^"]*"' "$file" | head -n 1)
+ # Check if the file is in the do_not_optimize list
+ if [[ " ${do_not_optimize[@]} " =~ " $(basename $file) " ]]; then
+ echo "Skipping optimization for $file"
+ return
+ fi
# Use inkscape to merge all paths into one
inkscape "$file" --actions="select-all;object-to-path;select-all;path-combine" --export-plain-svg --export-filename="${temp_file}"
# optimize the svg
diff --git a/src/zen/ZenComponents.manifest b/src/zen/ZenComponents.manifest
index bb1049da1..5cfcfd855 100644
--- a/src/zen/ZenComponents.manifest
+++ b/src/zen/ZenComponents.manifest
@@ -15,3 +15,4 @@ category app-startup nsBrowserGlue @mozilla.org/browser/browserglue;1 applicatio
#include common/Components.manifest
#include sessionstore/SessionComponents.manifest
+#include live-folders/LiveFoldersComponents.manifest
diff --git a/src/zen/common/modules/ZenSessionStore.mjs b/src/zen/common/modules/ZenSessionStore.mjs
index fed920f3b..4f5674f55 100644
--- a/src/zen/common/modules/ZenSessionStore.mjs
+++ b/src/zen/common/modules/ZenSessionStore.mjs
@@ -17,6 +17,9 @@ class ZenSessionStore extends nsZenPreloadedFeature {
if (tabData.zenWorkspace) {
tab.setAttribute("zen-workspace-id", tabData.zenWorkspace);
}
+ if (tabData.zenLiveFolderItemId) {
+ tab.setAttribute("zen-live-folder-item-id", tabData.zenLiveFolderItemId);
+ }
// Keep for now, for backward compatibility for window sync to work.
if (tabData.zenSyncId || tabData.zenPinnedId) {
tab.setAttribute("id", tabData.zenSyncId || tabData.zenPinnedId);
diff --git a/src/zen/common/modules/ZenUIManager.mjs b/src/zen/common/modules/ZenUIManager.mjs
index 89520f4f9..4a0714848 100644
--- a/src/zen/common/modules/ZenUIManager.mjs
+++ b/src/zen/common/modules/ZenUIManager.mjs
@@ -58,6 +58,7 @@ window.gZenUIManager = {
gZenMediaController.init();
gZenVerticalTabsManager.init();
+ gZenLiveFoldersUI.init();
this._initCreateNewPopup();
this._debloatContextMenus();
diff --git a/src/zen/common/zen-sets.js b/src/zen/common/zen-sets.js
index b0acdbd84..bc87d6625 100644
--- a/src/zen/common/zen-sets.js
+++ b/src/zen/common/zen-sets.js
@@ -130,6 +130,13 @@ document.addEventListener(
case "cmd_zenNewNavigatorUnsynced":
OpenBrowserWindow({ zenSyncedWindow: false });
break;
+ case "cmd_zenNewLiveFolder": {
+ const { ZenLiveFoldersManager } = ChromeUtils.importESModule(
+ "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs"
+ );
+ ZenLiveFoldersManager.handleEvent(event);
+ break;
+ }
default:
gZenGlanceManager.handleMainCommandSet(event);
if (event.target.id.startsWith("cmd_zenWorkspaceSwitch")) {
diff --git a/src/zen/drag-and-drop/ZenDragAndDrop.js b/src/zen/drag-and-drop/ZenDragAndDrop.js
index 936ebf7f9..013c2919c 100644
--- a/src/zen/drag-and-drop/ZenDragAndDrop.js
+++ b/src/zen/drag-and-drop/ZenDragAndDrop.js
@@ -930,6 +930,22 @@
gZenPinnedTabManager.removeTabContainersDragoverClass();
}
+ #canDropIntoFolder(dropElement, draggedTab) {
+ let folder = dropElement?.classList.contains("tab-group-label-container")
+ ? dropElement.parentElement
+ : dropElement?.group;
+ if (!folder?.isZenFolder) {
+ return true;
+ }
+ if (folder.isLiveFolder) {
+ const liveFolderItemId = draggedTab.getAttribute("zen-live-folder-item-id");
+ if (!liveFolderItemId || !liveFolderItemId.startsWith(`${folder.id}:`)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
// eslint-disable-next-line complexity
#applyDragoverIndicator(event, dropElement, movingTabs, draggedTab) {
const separation = 4;
@@ -998,6 +1014,15 @@
(overlapPercent > 1 - threshold &&
(possibleFolderElement.collapsed ||
possibleFolderElement.childGroupsAndTabs.length < 2)));
+ if (
+ canHightlightGroup &&
+ !dropIntoFolder &&
+ !this.#canDropIntoFolder(dropElement, draggedTab)
+ ) {
+ this.clearDragOverVisuals();
+ dropElement = null;
+ return [dropElement, dropBefore];
+ }
if (
isTabGroupLabel(draggedTab) &&
draggedTab.group?.isZenFolder &&
diff --git a/src/zen/folders/ZenFolder.mjs b/src/zen/folders/ZenFolder.mjs
index cd6418d4c..18d649665 100644
--- a/src/zen/folders/ZenFolder.mjs
+++ b/src/zen/folders/ZenFolder.mjs
@@ -2,6 +2,11 @@
// 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/.
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ZenLiveFoldersManager: "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
+});
+
export class nsZenFolder extends MozTabbrowserTabGroup {
#initialized = false;
@@ -262,7 +267,12 @@ export class nsZenFolder extends MozTabbrowserTabGroup {
on_click(event) {
if (event.target === this.resetButton) {
event.stopPropagation();
- this.unloadAllTabs(event);
+
+ if (event.target.hasAttribute("live-folder-action")) {
+ lazy.ZenLiveFoldersManager.handleEvent(event);
+ } else {
+ this.unloadAllTabs(event);
+ }
return;
}
super.on_click(event);
diff --git a/src/zen/folders/ZenFolders.mjs b/src/zen/folders/ZenFolders.mjs
index 8ef65e6e7..ea7891b80 100644
--- a/src/zen/folders/ZenFolders.mjs
+++ b/src/zen/folders/ZenFolders.mjs
@@ -91,6 +91,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
return;
}
this.#lastFolderContextMenu = folder;
+ gZenLiveFoldersUI.buildContextMenu(folder);
const newSubfolderItem = document.getElementById("context_zenFolderNewSubfolder");
newSubfolderItem.setAttribute(
@@ -241,7 +242,24 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
}
if (group.collapsed && !this._sessionRestoring) {
- group.collapsed = group.hasAttribute("has-active");
+ if (group.isLiveFolder) {
+ if (!group.hasAttribute("has-active")) {
+ let groupStart = group.groupStartElement;
+ let marginTop = groupStart.style.marginTop ? parseInt(groupStart.style.marginTop) : 0;
+ if (marginTop < 0) {
+ groupStart.style.marginTop = `${marginTop + 4}px`;
+ }
+ }
+
+ tab.setAttribute("folder-active", "true");
+ group.setAttribute("has-active", "true");
+ group.groupContainer.removeAttribute("hidden");
+ group.activeTabs = [...new Set([...group.activeTabs, tab])].sort(
+ (a, b) => a._tPos > b._tPos
+ );
+ } else {
+ group.collapsed = group.hasAttribute("has-active");
+ }
}
}
@@ -576,6 +594,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
folder.label = options.label || "New Folder";
folder.saveOnWindowClose = !!options.saveOnWindowClose;
folder.color = "zen-workspace-color";
+ folder.isLiveFolder = options.isLiveFolder;
folder.setAttribute("zen-workspace-id", options.workspaceId || gZenWorkspaces.activeWorkspace);
@@ -1022,6 +1041,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
prevSiblingInfo,
emptyTabIds: emptyFolderTabs,
userIcon: userIcon?.getAttribute("href"),
+ isLiveFolder: folder.isLiveFolder,
// note: We shouldn't be using the workspace-id anywhere, we are just
// remembering it for the pinned tabs manager to use it later.
workspaceId: folder.getAttribute("zen-workspace-id"),
@@ -1061,6 +1081,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
pinned: folderData.pinned,
saveOnWindowClose: folderData.saveOnWindowClose,
workspaceId: folderData.workspaceId,
+ isLiveFolder: folderData.isLiveFolder,
});
folder.setAttribute("id", folderData.id);
workingData.node = folder;
@@ -1131,7 +1152,9 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
}
gBrowser.tabContainer._invalidateCachedTabs();
- this._sessionRestoring = false;
+ setTimeout(() => {
+ delete this._sessionRestoring;
+ }, 0);
}
/**
@@ -1254,6 +1277,10 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
return heightShift;
}
+ get #folderAnimationDuration() {
+ return this._sessionRestoring ? 0 : 0.12;
+ }
+
async animateCollapse(group) {
this.cancelPopupTimer();
@@ -1309,11 +1336,13 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
});
}
+ let duration = this.#folderAnimationDuration;
+
animations.push(
...this.#createAnimation(
itemsToHide,
{ opacity: [1, 0], height: ["auto", 0] },
- { duration: 0.12, ease: "easeInOut" }
+ { duration, ease: "easeInOut" }
),
...this.updateFolderIcon(group),
...this.#createAnimation(
@@ -1321,7 +1350,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
{
marginTop: -(collapsedHeight + 4 * (selectedTabs.length === 0 ? 1 : 0)),
},
- { duration: 0.12, ease: "easeInOut" }
+ { duration, ease: "easeInOut" }
)
);
@@ -1429,16 +1458,18 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
group.activeTabs = [];
};
+ let duration = this.#folderAnimationDuration;
+
animations.push(
...this.#createAnimation(
itemsToShow,
{ opacity: "", height: "" },
- { duration: 0.12, ease: "easeInOut" }
+ { duration, ease: "easeInOut" }
),
...this.#createAnimation(
itemsToHide,
{ opacity: 0, height: 0 },
- { duration: 0.12, ease: "easeInOut" }
+ { duration, ease: "easeInOut" }
),
...this.updateFolderIcon(group),
...this.#createAnimation(
@@ -1446,7 +1477,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
{
marginTop: 0,
},
- { duration: 0.12, ease: "easeInOut" },
+ { duration, ease: "easeInOut" },
afterMarginTop
)
);
diff --git a/src/zen/folders/zen-folders.css b/src/zen/folders/zen-folders.css
index 8a9cc4b84..9fd39fc1e 100644
--- a/src/zen/folders/zen-folders.css
+++ b/src/zen/folders/zen-folders.css
@@ -192,6 +192,12 @@ zen-folder {
max-width: 100% !important;
}
}
+
+ .tab-reset-button[live-folder-action] {
+ list-style-image: url("chrome://browser/skin/zen-icons/security-broken.svg");
+ display: flex;
+ opacity: 1;
+ }
}
:root[zen-sidebar-expanded] &[has-active] > .tab-group-label-container {
@@ -207,7 +213,7 @@ zen-folder {
}
zen-workspace[collapsedpinnedtabs] .zen-workspace-pinned-tabs-section,
-zen-folder[collapsed]:not([has-active]) > .tab-group-container {
+zen-folder[collapsed] > .tab-group-container {
overflow-y: clip;
}
diff --git a/src/zen/live-folders/LiveFoldersComponents.manifest b/src/zen/live-folders/LiveFoldersComponents.manifest
new file mode 100644
index 000000000..6a3f48527
--- /dev/null
+++ b/src/zen/live-folders/LiveFoldersComponents.manifest
@@ -0,0 +1,5 @@
+# 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/.
+
+category browser-window-delayed-startup resource:///modules/zen/ZenLiveFoldersManager.sys.mjs ZenLiveFoldersManager.init
diff --git a/src/zen/live-folders/ZenLiveFolder.sys.mjs b/src/zen/live-folders/ZenLiveFolder.sys.mjs
new file mode 100644
index 000000000..68d8f132b
--- /dev/null
+++ b/src/zen/live-folders/ZenLiveFolder.sys.mjs
@@ -0,0 +1,337 @@
+// 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/.
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ requestIdleCallback: "resource://gre/modules/Timer.sys.mjs",
+ cancelIdleCallback: "resource://gre/modules/Timer.sys.mjs",
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+ NetworkHelper: "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs",
+});
+
+export class nsZenLiveFolderProvider {
+ #timerHandle = null;
+ #idleCallbackHandle = null;
+
+ constructor({ id, manager, state }) {
+ this.id = id;
+ this.manager = manager;
+ this.state = { ...state };
+ }
+
+ fetchItems() {
+ throw new Error("Unimplemented");
+ }
+
+ getMetadata() {
+ throw new Error("Unimplemented");
+ }
+
+ async refresh() {
+ this.stop();
+ const result = await this.#internalFetch();
+ this.start();
+ return result;
+ }
+
+ start() {
+ const now = Date.now();
+ const lastFetched = this.state.lastFetched;
+ const interval = this.state.interval;
+
+ const timeSinceLast = now - lastFetched;
+ let delay = interval - timeSinceLast;
+
+ if (delay <= 0) {
+ delay = 0;
+ }
+
+ this.#scheduleNext(delay);
+ }
+
+ stop() {
+ if (this.#timerHandle) {
+ lazy.clearTimeout(this.#timerHandle);
+ this.#timerHandle = null;
+ }
+
+ if (this.#idleCallbackHandle) {
+ lazy.cancelIdleCallback(this.#idleCallbackHandle);
+ this.#idleCallbackHandle = null;
+ }
+ }
+
+ #scheduleNext(delay) {
+ if (this.#timerHandle) {
+ lazy.clearTimeout(this.#timerHandle);
+ }
+
+ this.#timerHandle = lazy.setTimeout(() => {
+ const fetchWhenIdle = () => {
+ this.#internalFetch();
+ this.#idleCallbackHandle = null;
+ };
+
+ this.#idleCallbackHandle = lazy.requestIdleCallback(fetchWhenIdle);
+ this.#scheduleNext(this.state.interval);
+ }, delay);
+ }
+
+ async #internalFetch() {
+ try {
+ const items = await this.fetchItems();
+ this.state.lastFetched = Date.now();
+ this.requestSave();
+
+ this.manager.onLiveFolderFetch(this, items);
+ return items;
+ } catch {}
+
+ return null;
+ }
+
+ get options() {
+ return [];
+ }
+
+ onOptionTrigger(option) {
+ const key = option.getAttribute("option-key");
+
+ switch (key) {
+ case "refresh": {
+ this.refresh();
+ break;
+ }
+ case "setInterval": {
+ const intervalMs = Number.parseInt(option.getAttribute("option-value"));
+ if (intervalMs > 0) {
+ this.state.interval = intervalMs;
+ this.requestSave();
+ this.stop();
+ this.start();
+ }
+
+ break;
+ }
+ }
+ }
+
+ onActionButtonClick(errorId) {
+ switch (errorId) {
+ case "zen-live-folder-failed-fetch": {
+ this.refresh();
+ break;
+ }
+ }
+ }
+
+ requestSave() {
+ this.manager.saveState();
+ }
+
+ fetch(url, { maxContentLength = 5 * 1024 * 1024 } = {}) {
+ const uri = lazy.NetUtil.newURI(url);
+ // TODO: Support userContextId when fetching, it should be inherited from the folder's
+ // current space context ID.
+ let userContextId = 0;
+ let folder = this.manager.getFolderForLiveFolder(this);
+ if (folder) {
+ let space = folder.ownerGlobal.gZenWorkspaces.getWorkspaceFromId(
+ folder.getAttribute("zen-workspace-id")
+ );
+ if (space) {
+ userContextId = space.containerTabId || 0;
+ }
+ }
+ const principal = Services.scriptSecurityManager.createContentPrincipal(uri, {
+ userContextId,
+ });
+
+ const securityFlags =
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT |
+ Ci.nsILoadInfo.SEC_COOKIES_INCLUDE;
+
+ const channel = Services.io
+ .newChannelFromURI(
+ uri,
+ this.manager.window.document,
+ principal,
+ principal,
+ securityFlags,
+ Ci.nsIContentPolicy.TYPE_DOCUMENT
+ )
+ .QueryInterface(Ci.nsIHttpChannel);
+
+ let httpStatus = null;
+ let contentType = "";
+ let headerCharset = null;
+
+ const { promise, resolve, reject } = Promise.withResolvers();
+
+ const byteChunks = [];
+ let totalLength = 0;
+
+ channel.asyncOpen({
+ onDataAvailable: (request, stream, _offset, count) => {
+ totalLength += count;
+ if (totalLength > maxContentLength) {
+ request.cancel(Cr.NS_ERROR_FILE_TOO_BIG);
+ } else {
+ byteChunks.push(lazy.NetUtil.readInputStream(stream, count));
+ }
+ },
+ onStartRequest: (request) => {
+ const http = request.QueryInterface(Ci.nsIHttpChannel);
+
+ try {
+ httpStatus = http.responseStatus;
+ } catch (ex) {
+ httpStatus = null;
+ }
+
+ try {
+ contentType = http.getResponseHeader("content-type");
+ } catch (ex) {}
+
+ if (contentType && !lazy.NetworkHelper.isTextMimeType(contentType.split(";")[0].trim())) {
+ request.cancel(Cr.NS_ERROR_FILE_UNKNOWN_TYPE);
+ }
+
+ // Save charset without quotes or spaces for TextDecoder
+ const match = contentType.match(/charset=["' ]*([^;"' ]+)/i);
+ if (match) {
+ headerCharset = match[1];
+ }
+
+ // Enforce max length if provided by server
+ try {
+ const headerLen = Number(http.getResponseHeader("content-length"));
+ if (Number.isFinite(headerLen) && headerLen > maxContentLength) {
+ request.cancel(Cr.NS_ERROR_FILE_TOO_BIG);
+ }
+ } catch (ex) {}
+ },
+ onStopRequest: (_request, status) => {
+ if (!Components.isSuccessCode(status)) {
+ reject(Components.Exception("Failed to fetch document", status));
+ return;
+ }
+
+ const bytes = new Uint8Array(totalLength);
+ let writeOffset = 0;
+ for (const chunk of byteChunks) {
+ bytes.set(new Uint8Array(chunk), writeOffset);
+ writeOffset += chunk.byteLength;
+ }
+
+ let effectiveCharset = "utf-8";
+
+ const mimeType = contentType ? contentType.split(";")[0].trim().toLowerCase() : "";
+ if (mimeType === "text/html") {
+ effectiveCharset = this.sniffCharset(bytes, headerCharset);
+ } else if (headerCharset) {
+ const norm = this.normalizeAndValidateEncodingLabel(headerCharset);
+ if (norm) {
+ effectiveCharset = norm;
+ }
+ }
+
+ let decoded;
+ try {
+ decoded = new TextDecoder(effectiveCharset).decode(bytes);
+ } catch (e) {
+ decoded = new TextDecoder("utf-8").decode(bytes);
+ }
+
+ resolve({ text: decoded, status: httpStatus, contentType });
+ },
+ });
+
+ return promise;
+ }
+
+ /**
+ * Sniff an effective charset for the given response bytes using the HTML standard's precedence:
+ * 1) Byte Order Mark (BOM)
+ * 2) or http-equiv in the first 8KB of the document
+ * 3) HTTP Content-Type header charset (if provided and valid)
+ * 4) Default to utf-8
+ *
+ * @param {Uint8Array} bytes - The raw response bytes.
+ * @param {string} headerCharset - The charset from the Content-Type header.
+ * @returns {string} A validated, effective charset label for TextDecoder.
+ */
+ sniffCharset(bytes, headerCharset = "") {
+ // 1. BOM detection (highest priority)
+ if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
+ return "utf-8";
+ }
+ if (bytes.length >= 2) {
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) {
+ return "utf-16be";
+ }
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) {
+ return "utf-16le";
+ }
+ }
+
+ // 2. Scan the first 8KB for a meta-declared charset. This is checked before
+ // the HTTP header as a heuristic for misconfigured servers where the HTML
+ // is more likely to be correct.
+ try {
+ const headLen = Math.min(bytes.length, 8192);
+ const head = new TextDecoder("windows-1252").decode(bytes.subarray(0, headLen));
+
+ const metaCharsetRegex = /]*content\s*=\s*["'][^"']*charset\s*=\s*([a-z0-9_-]+)/i;
+ match = head.match(httpEquivRegex);
+ }
+
+ if (match && match[1]) {
+ const norm = this.normalizeAndValidateEncodingLabel(match[1]);
+ if (norm) {
+ return norm;
+ }
+ }
+ } catch (e) {
+ // Ignore errors during meta scan and fall through.
+ }
+
+ // 3. Use charset from HTTP header if it's valid.
+ if (headerCharset) {
+ const norm = this.normalizeAndValidateEncodingLabel(headerCharset);
+ if (norm) {
+ return norm;
+ }
+ }
+
+ // 4. Default to UTF-8 if no other charset is found.
+ return "utf-8";
+ }
+
+ /**
+ * Normalizes a charset label and validates it is supported by TextDecoder.
+ *
+ * @param {string} label - The raw encoding label from headers or meta tags.
+ * @returns {string|null} The normalized, validated label, or null if invalid.
+ */
+ normalizeAndValidateEncodingLabel(label) {
+ const l = (label || "").trim();
+ if (!l) {
+ return null;
+ }
+ try {
+ // TextDecoder constructor handles aliases and validation.
+ return new TextDecoder(l).encoding;
+ } catch (e) {
+ // The label was invalid or unsupported.
+ }
+ return null;
+ }
+}
diff --git a/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs b/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs
new file mode 100644
index 000000000..148ef3d0d
--- /dev/null
+++ b/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs
@@ -0,0 +1,506 @@
+// 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/.
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+ TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs",
+ ZenWindowSync: "resource:///modules/zen/ZenWindowSync.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["browser/zen-live-folders.ftl"])
+);
+
+const DEFAULT_FETCH_INTERVAL = 30 * 60 * 1000;
+const providers = [
+ {
+ path: "resource:///modules/zen/RssLiveFolder.sys.mjs",
+ module: "nsRssLiveFolderProvider",
+ },
+ {
+ path: "resource:///modules/zen/GithubLiveFolder.sys.mjs",
+ module: "nsGithubLiveFolderProvider",
+ },
+];
+
+class nsZenLiveFoldersManager {
+ #isInitialized = false;
+ #saveFilename = "zen-live-folders.jsonlz4";
+ #file = null;
+
+ constructor() {
+ this.liveFolders = new Map();
+ this.registry = new Map();
+ this.dismissedItems = new Set();
+ this.folderRefs = new WeakMap();
+ }
+
+ get window() {
+ return lazy.ZenWindowSync.firstSyncedWindow;
+ }
+
+ async init() {
+ if (this.#isInitialized) {
+ return;
+ }
+
+ for (const provider of providers) {
+ const module = ChromeUtils.importESModule(provider.path, { global: "current" });
+ const ProviderClass = module[provider.module];
+ this.registry.set(ProviderClass.type, ProviderClass);
+ }
+
+ await this.#restoreState();
+ this.#initEventListeners();
+ this.#isInitialized = true;
+ }
+
+ // Event Handling
+ // --------------
+ #initEventListeners() {
+ lazy.ZenWindowSync.addSyncHandler(this.handleEvent.bind(this));
+ }
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "TabUngrouped":
+ case "TabClose": {
+ this.#onTabDismiss(aEvent);
+ break;
+ }
+ case "TabGroupRemoved": {
+ this.#onTabGroupRemoved(aEvent);
+ break;
+ }
+ case "command": {
+ this.#onCommand(aEvent);
+ break;
+ }
+ case "click": {
+ this.#onActionButtonClick(aEvent);
+ break;
+ }
+ }
+ }
+
+ #onCommand(event) {
+ switch (event.target.id) {
+ case "cmd_zenNewLiveFolder": {
+ const target = event.sourceEvent.target;
+ switch (target.getAttribute("data-l10n-id")) {
+ case "zen-live-folder-github-pull-requests": {
+ this.createFolder("github:pull-requests");
+ break;
+ }
+ case "zen-live-folder-github-issues": {
+ this.createFolder("github:issues");
+ break;
+ }
+ case "zen-live-folder-type-rss": {
+ this.createFolder("rss");
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ #onTabDismiss(event) {
+ const itemIdAttr = "zen-live-folder-item-id";
+ const itemId =
+ event.target.getAttribute(itemIdAttr) || event.detail?.getAttribute?.(itemIdAttr);
+
+ if (itemId) {
+ if (event.type === "TabUngrouped") {
+ const target = event.detail;
+ target.removeAttribute("zen-live-folder-item-id");
+
+ const showSublabel = target.hasAttribute("zen-show-sublabel");
+ if (showSublabel) {
+ target.removeAttribute("zen-show-sublabel");
+
+ const label = target.querySelector(".zen-tab-sublabel");
+ this.window.document.l10n.setArgs(label, {
+ tabSubtitle: "zen-default-pinned",
+ });
+ }
+ }
+
+ this.dismissedItems.add(itemId);
+ this.saveState();
+ }
+ }
+
+ #onActionButtonClick(event) {
+ const liveFolderId = event.target.getAttribute("live-folder-action");
+ this.getFolder(liveFolderId)?.onActionButtonClick(event.target.getAttribute("data-l10n-id"));
+ }
+
+ #onTabGroupRemoved(event) {
+ const tabGroup = event.target;
+ if (tabGroup.isLiveFolder) {
+ this.deleteFolder(tabGroup.id, false);
+ }
+ }
+
+ // Public API
+ // ----------
+ getFolder(id) {
+ return this.liveFolders.get(id);
+ }
+
+ async createFolder(type) {
+ const [provider, providerType] = type.split(":");
+ let ProviderClass = this.registry.get(provider);
+ if (!ProviderClass) {
+ return -1;
+ }
+
+ let url;
+ let label;
+ let icon;
+
+ switch (provider) {
+ case "rss": {
+ url = await ProviderClass.promptForFeedUrl(this.window);
+ if (!url) {
+ return -1;
+ }
+
+ const metadata = await ProviderClass.getMetadata(url, this.window);
+ label = metadata.label;
+ icon = metadata.icon;
+
+ break;
+ }
+ case "github": {
+ const [message] = await lazy.l10n.formatMessages([
+ { id: `zen-live-folder-github-${providerType}` },
+ ]);
+
+ label = message.attributes[0].value;
+ icon = "chrome://browser/skin/zen-icons/selectable/logo-github.svg";
+ break;
+ }
+ }
+
+ const folder = this.window.gZenFolders.createFolder([], {
+ label,
+ isLiveFolder: true,
+ });
+
+ if (icon) {
+ this.window.gZenFolders.setFolderUserIcon(folder, icon);
+ }
+
+ const config = {
+ state: this.#applyDefaultStateValues({
+ url,
+ type: providerType,
+ }),
+ };
+
+ let liveFolder = new ProviderClass({
+ state: config.state,
+ manager: this,
+ id: folder.id,
+ });
+
+ this.liveFolders.set(folder.id, liveFolder);
+ this.folderRefs.set(liveFolder, folder);
+
+ liveFolder.start();
+ this.saveState();
+
+ return folder.id;
+ }
+
+ deleteFolder(id, deleteFolder = true) {
+ const liveFolder = this.liveFolders.get(id);
+ if (!liveFolder) {
+ return false;
+ }
+
+ liveFolder.stop();
+ this.liveFolders.delete(id);
+
+ const prefix = `${id}:`;
+
+ // Remove the dismissed items associated with the folder from the set
+ this.dismissedItems = new Set(
+ Array.from(this.dismissedItems).filter((itemId) => !itemId.startsWith(prefix))
+ );
+
+ if (deleteFolder) {
+ const folder = this.getFolderForLiveFolder(liveFolder);
+ if (folder) {
+ folder.delete();
+ }
+ }
+
+ this.saveState();
+ return true;
+ }
+
+ // Live Folder Updates
+ // -------------------
+ onLiveFolderFetch(liveFolder, items) {
+ const folder = this.getFolderForLiveFolder(liveFolder);
+ if (!folder) {
+ return;
+ }
+
+ const errorId = typeof items === "string" ? items : null;
+ liveFolder.state.lastErrorId = errorId;
+
+ // Display on error on the folder, null reset the error status.
+ this.#applyLiveFolderError(liveFolder, errorId);
+
+ if (errorId) {
+ liveFolder.requestSave();
+ return;
+ }
+
+ // itemid -> id:itemid
+ const itemIds = new Set(items.map((item) => this.#makeCompositeId(liveFolder.id, item.id)));
+
+ const outdatedTabs = [];
+ const existingItemIds = new Set();
+
+ for (const tab of folder.tabs) {
+ const itemId = tab.getAttribute("zen-live-folder-item-id");
+ if (!itemId) {
+ continue;
+ }
+
+ if (!itemIds.has(itemId)) {
+ outdatedTabs.push(tab);
+ continue;
+ }
+
+ existingItemIds.add(itemId);
+ }
+
+ this.window.gBrowser.removeTabs(outdatedTabs);
+
+ // Remove the dismissed items that are no longer in the given list
+ for (const dismissedItemId of this.dismissedItems) {
+ if (dismissedItemId.startsWith(`${liveFolder.id}:`) && !itemIds.has(dismissedItemId)) {
+ this.dismissedItems.delete(dismissedItemId);
+ }
+ }
+
+ // Only add the items that are not already in the folder and was not dismissed by the user
+ const newItems = items
+ .filter((item) => {
+ const compositeId = this.#makeCompositeId(liveFolder.id, item.id);
+ return !existingItemIds.has(compositeId) && !this.dismissedItems.has(compositeId);
+ })
+ .map((item) => {
+ const tab = this.window.gBrowser.addTrustedTab(item.url, {
+ createLazyBrowser: true,
+ inBackground: true,
+ skipAnimation: true,
+ noInitialLabel: true,
+ lazyTabTitle: item.title,
+ });
+ // createLazyBrowser can't be pinned by default
+ this.window.gBrowser.pinTab(tab);
+ if (item.icon) {
+ this.window.gBrowser.setIcon(tab, item.icon);
+ if (tab.linkedBrowser) {
+ lazy.TabStateCache.update(tab.linkedBrowser.permanentKey, {
+ image: null,
+ });
+ }
+ }
+ tab.setAttribute("zen-live-folder-item-id", this.#makeCompositeId(liveFolder.id, item.id));
+ if (item.subtitle) {
+ tab.setAttribute("zen-show-sublabel", item.subtitle);
+ const tabLabel = tab.querySelector(".zen-tab-sublabel");
+ this.window.document.l10n.setArgs(tabLabel, {
+ tabSubtitle: item.subtitle,
+ });
+ }
+
+ return tab;
+ });
+
+ // Wait for tabs to (hopefully) be initialized on all windows
+ lazy.setTimeout(() => {
+ folder.addTabs(newItems);
+ this.saveState();
+ }, 0);
+ }
+
+ // Helpers
+ // -------
+ #applyDefaultStateValues(state) {
+ state.interval ||= DEFAULT_FETCH_INTERVAL;
+ state.lastFetched ||= 0;
+ state.options ||= {};
+
+ return state;
+ }
+
+ #applyLiveFolderError(liveFolder, errorId = null) {
+ const folder = this.getFolderForLiveFolder(liveFolder);
+ if (!folder?.isLiveFolder) {
+ return;
+ }
+
+ const btn = folder.resetButton;
+ if (!btn) {
+ return;
+ }
+
+ if (errorId) {
+ btn.setAttribute("data-l10n-id", errorId);
+ btn.setAttribute("live-folder-action", liveFolder.id);
+ } else {
+ btn.setAttribute("data-l10n-id", "zen-folders-unload-all-tooltip");
+ btn.removeAttribute("live-folder-action");
+ }
+ }
+
+ getFolderForLiveFolder(liveFolder) {
+ if (this.folderRefs.has(liveFolder)) {
+ return this.folderRefs.get(liveFolder);
+ }
+
+ if (!this.window) {
+ return null;
+ }
+
+ const folder = lazy.ZenWindowSync.getItemFromWindow(this.window, liveFolder.id);
+ if (folder?.isZenFolder) {
+ this.folderRefs.set(liveFolder, folder);
+ }
+
+ return folder;
+ }
+
+ #makeCompositeId(folderId, itemId) {
+ return `${folderId}:${itemId}`;
+ }
+
+ // Persistence
+ // -----------
+ get #storePath() {
+ const profilePath = this.window.PathUtils.profileDir;
+ return this.window.PathUtils.join(profilePath, this.#saveFilename);
+ }
+
+ async #readStateFromDisk() {
+ this.#file = new lazy.JSONFile({
+ path: this.#storePath,
+ compression: "lz4",
+ });
+
+ await this.#file.load();
+ return this.#file.data;
+ }
+
+ #writeStateToDisk(data, soon = true) {
+ this.#file.data = data;
+ if (soon) {
+ this.#file.saveSoon();
+ } else {
+ this.#file._save();
+ }
+ }
+
+ saveState(soon = true) {
+ if (!this.#isInitialized) {
+ return;
+ }
+
+ let data = [];
+ for (let [id, liveFolder] of this.liveFolders) {
+ const prefix = `${id}:`;
+ const dismissedItems = Array.from(this.dismissedItems).filter((itemId) =>
+ itemId.startsWith(prefix)
+ );
+
+ const folder = this.getFolderForLiveFolder(liveFolder);
+ if (!folder) {
+ // Assume browser is quitting.
+ return;
+ }
+
+ const tabsState = [];
+ for (const tab of folder.tabs) {
+ const itemId = tab.getAttribute("zen-live-folder-item-id");
+ if (!itemId) {
+ continue;
+ }
+
+ tabsState.push({
+ itemId,
+ label: tab.getAttribute("zen-show-sublabel"),
+ icon: tab.iconImage.src,
+ });
+ }
+
+ // For UI manager to restore tabs state
+ liveFolder.tabsState = tabsState;
+
+ data.push({
+ id,
+ type: liveFolder.constructor.type,
+ data: liveFolder.serialize(),
+ dismissedItems,
+ tabsState,
+ });
+ }
+
+ this.#writeStateToDisk(data, soon);
+ }
+
+ async #restoreState() {
+ let data = await this.#readStateFromDisk();
+ if (!Array.isArray(data)) {
+ return;
+ }
+
+ await this.window.gZenWorkspaces.promiseInitialized;
+ const folders = this.window.gZenWorkspaces.allTabGroups;
+ for (let entry of data) {
+ let ProviderClass = this.registry.get(entry.type);
+ if (!ProviderClass) {
+ continue;
+ }
+
+ const folder = folders.find((x) => x.id === entry.id);
+ if (!folder) {
+ // No point restore if the live folder can't find its folder
+ continue;
+ }
+
+ entry.data.state = this.#applyDefaultStateValues(entry.data.state);
+ let liveFolder = new ProviderClass({
+ id: entry.id,
+ state: entry.data.state,
+ manager: this,
+ });
+
+ this.liveFolders.set(entry.id, liveFolder);
+ this.folderRefs.set(liveFolder, folder);
+
+ liveFolder.tabsState = entry.tabsState;
+ liveFolder.state.lastErrorId = entry.data.state.lastErrorId;
+ if (entry.dismissedItems && Array.isArray(entry.dismissedItems)) {
+ entry.dismissedItems.forEach((id) => this.dismissedItems.add(id));
+ }
+
+ liveFolder.start();
+ }
+ }
+}
+
+export const ZenLiveFoldersManager = new nsZenLiveFoldersManager();
diff --git a/src/zen/live-folders/ZenLiveFoldersUI.mjs b/src/zen/live-folders/ZenLiveFoldersUI.mjs
new file mode 100644
index 000000000..5bb8e33ee
--- /dev/null
+++ b/src/zen/live-folders/ZenLiveFoldersUI.mjs
@@ -0,0 +1,221 @@
+// 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/.
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ZenLiveFoldersManager: "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
+});
+
+class nsZenLiveFoldersUI {
+ init() {
+ const popup = window.document
+ .getElementById("context_zenLiveFolderOptions")
+ .querySelector("menupopup");
+
+ popup.addEventListener("command", (event) => {
+ const option = event.target;
+
+ const folderId = option.getAttribute("option-folder");
+ if (folderId) {
+ const folder = lazy.ZenLiveFoldersManager.getFolder(folderId);
+ if (folder && typeof folder.onOptionTrigger === "function") {
+ folder.onOptionTrigger(option);
+ }
+ }
+ });
+
+ window.gZenWorkspaces.promiseInitialized.finally(() => {
+ const manager = lazy.ZenLiveFoldersManager;
+
+ for (const liveFolder of manager.liveFolders.values()) {
+ this.#restoreUIStateForLiveFolder(liveFolder);
+ }
+ });
+ }
+
+ #restoreUIStateForLiveFolder(liveFolder) {
+ const folder = window.gZenWorkspaces.allTabGroups.find((x) => x.id === liveFolder.id);
+ if (!folder) {
+ return;
+ }
+
+ const btn = folder.resetButton;
+ if (!btn) {
+ return;
+ }
+
+ for (const { itemId, label } of liveFolder.tabsState) {
+ const tab = folder.tabs.find((t) => t.getAttribute("zen-live-folder-item-id") === itemId);
+ if (tab && label) {
+ const tabLabel = tab.querySelector(".zen-tab-sublabel");
+ tab.setAttribute("zen-show-sublabel", label);
+
+ window.document.l10n.setArgs(tabLabel, {
+ tabSubtitle: label,
+ });
+ }
+ }
+
+ const errorId = liveFolder.state.lastErrorId;
+ if (errorId) {
+ btn.setAttribute("data-l10n-id", errorId);
+ btn.setAttribute("live-folder-action", liveFolder.id);
+ return;
+ }
+
+ btn.setAttribute("data-l10n-id", "zen-folders-unload-all-tooltip");
+ btn.removeAttribute("live-folder-action");
+ }
+
+ #applyMenuItemAttributes(menuItem, option, folderId) {
+ menuItem.setAttribute("data-l10n-id", option.l10nId);
+
+ if (option.checked !== undefined) {
+ menuItem.setAttribute("checked", option.checked);
+ menuItem.setAttribute("type", option.type ?? "checkbox");
+ }
+
+ if (option.l10nArgs) {
+ menuItem.setAttribute("data-l10n-args", JSON.stringify(option.l10nArgs));
+ }
+
+ menuItem.setAttribute("option-folder", folderId);
+ menuItem.setAttribute("option-key", option.key);
+ if (option.disabled) {
+ menuItem.setAttribute("disabled", "true");
+ }
+ if (option.hidden) {
+ menuItem.setAttribute("hidden", "true");
+ }
+ }
+
+ #appendOptions(parentPopup, options, folderId) {
+ for (const option of options) {
+ if (option.type === "separator") {
+ parentPopup.appendChild(document.createXULElement("menuseparator"));
+ continue;
+ }
+
+ if (option.options) {
+ const menu = document.createXULElement("menu");
+ this.#applyMenuItemAttributes(menu, option, folderId);
+
+ const subPopup = document.createXULElement("menupopup");
+ this.#appendOptions(subPopup, option.options, folderId);
+
+ menu.appendChild(subPopup);
+ parentPopup.appendChild(menu);
+ continue;
+ }
+
+ const menuItem = document.createXULElement("menuitem");
+ this.#applyMenuItemAttributes(menuItem, option, folderId);
+
+ if (option.value !== undefined) {
+ menuItem.setAttribute("option-value", option.value);
+ }
+
+ parentPopup.appendChild(menuItem);
+ }
+ }
+
+ buildContextMenu(folder) {
+ const optionsElement = document.getElementById("context_zenLiveFolderOptions");
+
+ let hidden = true;
+ if (folder.isLiveFolder) {
+ const popup = optionsElement.querySelector("menupopup");
+ const liveFolder = lazy.ZenLiveFoldersManager.getFolder(folder.id);
+
+ const MINUTE_MS = 60 * 1000;
+ const HOUR_MS = 60 * MINUTE_MS;
+
+ let intervals = [];
+ for (let mins = 15; mins <= 30; mins *= 2) {
+ intervals.push({ mins });
+ }
+
+ for (let hours = 1; hours <= 8; hours *= 2) {
+ intervals.push({ hours });
+ }
+
+ intervals = intervals.map((entry) => {
+ const ms = "mins" in entry ? entry.mins * MINUTE_MS : entry.hours * HOUR_MS;
+
+ return {
+ l10nId:
+ "mins" in entry
+ ? "zen-live-folder-fetch-interval-mins"
+ : "zen-live-folder-fetch-interval-hours",
+ l10nArgs: entry,
+
+ type: "radio",
+ checked: liveFolder.state.interval === ms,
+
+ key: "setInterval",
+ value: ms,
+ };
+ });
+
+ const contextMenuItems = [
+ {
+ key: "lastFetched",
+ l10nId: liveFolder.state.lastErrorId || "zen-live-folder-last-fetched",
+ l10nArgs: { time: this.#timeAgo(liveFolder.state.lastFetched) },
+ disabled: true,
+ },
+ {
+ key: "setInterval",
+ l10nId: "zen-live-folder-option-fetch-interval",
+ options: intervals,
+ },
+ {
+ key: "refresh",
+ l10nId: "zen-live-folder-refresh",
+ },
+ { type: "separator" },
+ ...liveFolder.options,
+ ];
+
+ popup.innerHTML = "";
+
+ this.#appendOptions(popup, contextMenuItems, folder.id);
+ hidden = false;
+ }
+
+ optionsElement.hidden = hidden;
+ document.getElementById("live-folder-separator").hidden = hidden;
+ }
+
+ #timeAgo(date) {
+ if (date === 0) {
+ return "-";
+ }
+
+ const rtf = new Intl.RelativeTimeFormat(Services.locale.appLocaleAsBCP47, { numeric: "auto" });
+ const secondsDiff = (date - Date.now()) / 1000;
+ const absSeconds = Math.abs(secondsDiff);
+
+ const ranges = {
+ day: 86400,
+ hour: 3600,
+ minute: 60,
+ second: 1,
+ };
+
+ if (Number.isFinite(secondsDiff)) {
+ for (const [key, value] of Object.entries(ranges)) {
+ if (absSeconds >= value) {
+ return rtf.format(Math.round(secondsDiff / value), key);
+ }
+ }
+
+ return rtf.format(Math.round(secondsDiff), "second");
+ }
+
+ return "-";
+ }
+}
+
+window.gZenLiveFoldersUI = new nsZenLiveFoldersUI();
diff --git a/src/zen/live-folders/jar.inc.mn b/src/zen/live-folders/jar.inc.mn
new file mode 100644
index 000000000..693cd97d9
--- /dev/null
+++ b/src/zen/live-folders/jar.inc.mn
@@ -0,0 +1,5 @@
+# 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/.
+
+ content/browser/zen-components/ZenLiveFoldersUI.mjs (../../zen/live-folders/ZenLiveFoldersUI.mjs)
diff --git a/src/zen/live-folders/moz.build b/src/zen/live-folders/moz.build
new file mode 100644
index 000000000..37c474035
--- /dev/null
+++ b/src/zen/live-folders/moz.build
@@ -0,0 +1,10 @@
+# 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/.
+
+EXTRA_JS_MODULES.zen += [
+ "providers/GithubLiveFolder.sys.mjs",
+ "providers/RssLiveFolder.sys.mjs",
+ "ZenLiveFolder.sys.mjs",
+ "ZenLiveFoldersManager.sys.mjs",
+]
diff --git a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs
new file mode 100644
index 000000000..1464a0299
--- /dev/null
+++ b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs
@@ -0,0 +1,260 @@
+// 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 { nsZenLiveFolderProvider } from "resource:///modules/zen/ZenLiveFolder.sys.mjs";
+
+export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider {
+ static type = "github";
+
+ constructor({ id, state, manager }) {
+ super({ id, state, manager });
+
+ this.state.url = "https://github.com/issues/assigned";
+ this.state.type = state.type;
+
+ this.state.options = state.options ?? {};
+ this.state.repos = new Set(state.repos ?? []);
+ this.state.options.repoExcludes = new Set(state.options.repoExcludes ?? []);
+ }
+
+ async fetchItems() {
+ try {
+ const hasAnyFilterEnabled =
+ (this.state.options.authorMe ?? false) ||
+ (this.state.options.assignedMe ?? true) ||
+ (this.state.options.reviewRequested ?? false);
+
+ if (!hasAnyFilterEnabled) {
+ return "zen-live-folder-github-no-filter";
+ }
+
+ const searchParams = this.#buildSearchOptions();
+ const url = `${this.state.url}?${searchParams}`;
+
+ const { text, status } = await this.fetch(url);
+
+ // Assume no auth
+ if (status === 404) {
+ return "zen-live-folder-github-no-auth";
+ }
+
+ const document = new DOMParser().parseFromString(text, "text/html");
+ const issues = document.querySelectorAll(
+ "div[class^=IssueItem-module__defaultRepoContainer]"
+ );
+ const items = [];
+ const activeRepos = new Set();
+
+ if (issues.length) {
+ const authors = document.querySelectorAll("a[class^=IssueItem-module__authorCreatedLink]");
+ const titles = document.querySelectorAll("div[class^=Title-module__container]");
+ const links = document.querySelectorAll('[data-testid="issue-pr-title-link"]');
+
+ for (let i = 0; i < issues.length; i++) {
+ const [rawRepo, rawNumber] = issues[i].childNodes;
+ const author = authors[i]?.textContent;
+ const title = titles[i]?.textContent;
+ const issueUrl = links[i]?.href;
+
+ const repo = rawRepo.textContent?.trim();
+ if (repo) {
+ activeRepos.add(repo);
+ }
+
+ const numberMatch = rawNumber?.textContent?.match(/[0-9]+/);
+ const number = numberMatch?.[0] ?? "";
+
+ items.push({
+ title,
+ subtitle: author,
+ icon: "chrome://browser/content/zen-images/favicons/github.svg",
+ url: `https://github.com/${issueUrl}`,
+ id: `${repo}#${number}`,
+ });
+ }
+ }
+
+ this.state.repos = activeRepos;
+
+ return items;
+ } catch {
+ return "zen-live-folder-failed-fetch";
+ }
+ }
+
+ #buildSearchOptions() {
+ let searchParams = new URLSearchParams();
+ const options = [
+ {
+ value: "state:open",
+ enabled: true,
+ },
+ {
+ value: "sort:updated-desc",
+ enabled: true,
+ },
+ [
+ {
+ value: "is:pr",
+ enabled: this.state.type === "pull-requests",
+ },
+ {
+ value: "is:issue",
+ enabled: this.state.type === "issues",
+ },
+ ],
+ [
+ {
+ value: "author:@me",
+ enabled: this.state.options.authorMe ?? false,
+ },
+ {
+ value: "assignee:@me",
+ enabled: this.state.options.assignedMe ?? true,
+ },
+ {
+ value: "review-requested:@me",
+ enabled: this.state.options.reviewRequested ?? false,
+ },
+ ],
+ ];
+
+ const excluded = this.state.options.repoExcludes;
+ for (const repo of excluded) {
+ if (repo && repo.trim()) {
+ options.push({ value: `-repo:${repo.trim()}`, enabled: true });
+ }
+ }
+
+ let outputString = "";
+ for (const option of options) {
+ if (Array.isArray(option)) {
+ const enabledOptions = option.filter((x) => x.enabled).map((x) => x.value);
+ if (enabledOptions.length) {
+ outputString += ` (${enabledOptions.join(" OR ")}) `;
+ }
+ continue;
+ }
+
+ if (option.enabled) {
+ outputString += ` ${option.value} `;
+ }
+ }
+
+ searchParams.set("q", outputString);
+ return searchParams.toString();
+ }
+
+ get options() {
+ const excluded = this.state.options.repoExcludes;
+ const repoOptions = Array.from(this.state.repos.union(excluded))
+ .sort((a, b) => a.localeCompare(b))
+ .map((repo) => ({
+ l10nId: "zen-live-folder-github-option-repo",
+ l10nArgs: { repo },
+
+ key: "repoExclude",
+ value: repo,
+
+ type: "checkbox",
+ checked: !excluded.has(repo),
+ }));
+
+ if (repoOptions.length) {
+ repoOptions.push({ type: "separator" });
+ }
+
+ repoOptions.push({
+ l10nId: "zen-live-folder-github-option-repo-list-note",
+ disabled: true,
+ });
+
+ return [
+ {
+ l10nId: "zen-live-folder-github-option-author-self",
+ key: "authorMe",
+ checked: this.state.options.authorMe ?? false,
+ },
+ {
+ l10nId: "zen-live-folder-github-option-assigned-self",
+ key: "assignedMe",
+ checked: this.state.options.assignedMe ?? true,
+ },
+ {
+ l10nId: "zen-live-folder-github-option-review-requested",
+ key: "reviewRequested",
+ checked: this.state.options.reviewRequested ?? false,
+ hidden: this.state.type === "issues",
+ },
+ { type: "separator" },
+ {
+ l10nId: "zen-live-folder-github-option-repo-filter",
+ key: "repoExclude",
+ options: repoOptions,
+ // 1 repo + separator + note = 3 options, so if we have less than 4 options it means we don't have any repo to exclude
+ disabled: repoOptions.length < 4,
+ },
+ ];
+ }
+
+ onOptionTrigger(option) {
+ super.onOptionTrigger(option);
+
+ const key = option.getAttribute("option-key");
+ const checked = option.getAttribute("checked") === "true";
+ if (!this.options.some((x) => x.key === key)) {
+ return;
+ }
+
+ if (key === "repoExclude") {
+ const repo = option.getAttribute("option-value");
+ if (!repo) {
+ return;
+ }
+
+ const excluded = this.state.options.repoExcludes;
+ if (checked) {
+ excluded.delete(repo);
+ } else {
+ excluded.add(repo);
+ }
+
+ this.state.options.repoExcludes = excluded;
+ } else {
+ this.state.options[key] = checked;
+ }
+
+ this.refresh();
+ this.requestSave();
+ }
+
+ async onActionButtonClick(errorId) {
+ super.onActionButtonClick(errorId);
+
+ switch (errorId) {
+ case "zen-live-folder-github-no-auth": {
+ const tab = this.manager.window.gBrowser.addTrustedTab("https://github.com/login");
+ this.manager.window.gBrowser.selectedTab = tab;
+ break;
+ }
+ case "zen-live-folder-github-no-filter": {
+ this.refresh();
+ break;
+ }
+ }
+ }
+
+ serialize() {
+ return {
+ state: {
+ ...this.state,
+ repos: Array.from(this.state.repos),
+ options: {
+ ...this.state.options,
+ repoExcludes: Array.from(this.state.options.repoExcludes),
+ },
+ },
+ };
+ }
+}
diff --git a/src/zen/live-folders/providers/RssLiveFolder.sys.mjs b/src/zen/live-folders/providers/RssLiveFolder.sys.mjs
new file mode 100644
index 000000000..ec303a0a4
--- /dev/null
+++ b/src/zen/live-folders/providers/RssLiveFolder.sys.mjs
@@ -0,0 +1,274 @@
+// 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 { nsZenLiveFolderProvider } from "resource:///modules/zen/ZenLiveFolder.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["browser/zen-live-folders.ftl"])
+);
+
+export class nsRssLiveFolderProvider extends nsZenLiveFolderProvider {
+ static type = "rss";
+
+ constructor({ id, state, manager }) {
+ super({ id, state, manager });
+
+ this.state.url = state.url;
+ this.state.maxItems = state.maxItems ?? 10;
+ this.state.timeRange = state.timeRange ?? 0;
+ }
+
+ async fetchItems() {
+ try {
+ const { text } = await this.fetch(this.state.url);
+
+ const doc = new DOMParser().parseFromString(text, "text/xml");
+
+ const cutoffTime = Date.now() - this.state.timeRange;
+
+ const isAtom = doc.querySelector("feed > entry") !== null;
+ const selector = isAtom ? "entry" : "item";
+ const elements = doc.querySelectorAll(selector);
+
+ const items = Array.from(elements)
+ .map((item) => {
+ const title = item.querySelector("title")?.textContent || "";
+
+ const linkNode = item.querySelector("link");
+ const url =
+ isAtom && linkNode ? linkNode.getAttribute("href") : linkNode?.textContent || "";
+
+ const guid = item.querySelector(isAtom ? "id" : "guid")?.textContent;
+ const id = guid || url;
+
+ const dateStr = item.querySelector(isAtom ? "updated" : "pubDate")?.textContent;
+ const date = dateStr ? new Date(dateStr) : null;
+
+ return { title, url, id, date };
+ })
+ .filter((item) => {
+ if (!item.url || !item.date) {
+ return false;
+ }
+ if (!this.state.timeRange) {
+ return true;
+ }
+ return !isNaN(item.date.getTime()) && item.date.getTime() >= cutoffTime;
+ })
+ .slice(0, this.state.maxItems);
+ for (let item of items) {
+ if (item.url) {
+ try {
+ const url = new URL(item.url);
+ const favicon = await lazy.PlacesUtils.favicons.getFaviconForPage(
+ Services.io.newURI(url.href)
+ );
+ item.icon =
+ favicon?.dataURI.spec ||
+ this.manager.window.gZenEmojiPicker.getSVGURL("logo-rss.svg");
+ } catch {
+ // Ignore errors related to fetching favicons for individual items
+ }
+ }
+ }
+ return items;
+ } catch {
+ return "zen-live-folder-failed-fetch";
+ }
+ }
+
+ _buildRadioOption({ key, value, l10nId, l10nArgs }) {
+ return {
+ type: "radio",
+ key,
+ value,
+ l10nId,
+ l10nArgs,
+ checked: this.state[key] === value,
+ };
+ }
+
+ _buildItemLimitOptions() {
+ const entries = [5, 10, 25, 50];
+ return entries.map((entry) => {
+ return this._buildRadioOption({
+ key: "maxItems",
+ value: entry,
+ l10nId: "zen-live-folder-rss-option-item-limit-num",
+ l10nArgs: { limit: entry },
+ });
+ });
+ }
+
+ _buildTimeRangeOptions() {
+ const HOUR_MS = 60 * 60 * 1000;
+ const DAY_MS = 24 * HOUR_MS;
+
+ const entries = [
+ { hours: 1, ms: 1 * HOUR_MS },
+ { hours: 6, ms: 6 * HOUR_MS },
+ { hours: 12, ms: 12 * HOUR_MS },
+ { hours: 24, ms: 24 * HOUR_MS },
+ { days: 3, ms: 3 * DAY_MS },
+ ];
+
+ return [
+ this._buildRadioOption({
+ key: "timeRange",
+ value: 0,
+ l10nId: "zen-live-folder-time-range-all-time",
+ }),
+ { type: "separator" },
+ ...entries.map((entry) => {
+ const isDays = "days" in entry;
+
+ return this._buildRadioOption({
+ key: "timeRange",
+ value: entry.ms,
+ l10nId: isDays ? "zen-live-folder-time-range-days" : "zen-live-folder-time-range-hours",
+ l10nArgs: isDays ? { days: entry.days } : { hours: entry.hours },
+ });
+ }),
+ ];
+ }
+
+ get options() {
+ return [
+ {
+ l10nId: "zen-live-folder-rss-option-feed-url",
+ key: "feedURL",
+ },
+ {
+ l10nId: "zen-live-folder-rss-option-item-limit",
+ key: "maxItems",
+ options: this._buildItemLimitOptions(),
+ },
+ {
+ l10nId: "zen-live-folder-rss-option-time-range",
+ key: "timeRange",
+ options: this._buildTimeRangeOptions(),
+ },
+ ];
+ }
+
+ // static so it can be easily accessed by the manager without having to create the live folder first
+ static async getMetadata(url, window) {
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ return { label: "", icon: window.gZenEmojiPicker.getSVGURL("logo-rss.svg") };
+ }
+
+ const text = await response.text();
+ const doc = new DOMParser().parseFromString(text, "text/xml");
+
+ const isAtom = doc.querySelector("feed") !== null;
+ const title = (
+ isAtom
+ ? doc.querySelector("feed > title")?.textContent
+ : doc.querySelector("rss > channel > title, channel > title")?.textContent
+ )?.trim();
+
+ const linkNode = isAtom
+ ? doc.querySelector("feed > link[rel='alternate'][href], feed > link[href]")
+ : doc.querySelector("rss > channel > link, channel > link");
+ const feedLink =
+ (isAtom ? linkNode?.getAttribute("href") : linkNode?.textContent)?.trim() || "";
+
+ const faviconPageUrl = feedLink ? new URL(feedLink, url).href : url;
+ let favicon = await lazy.PlacesUtils.favicons.getFaviconForPage(
+ Services.io.newURI(faviconPageUrl)
+ );
+
+ return {
+ label: title || "",
+ icon: favicon?.dataURI.spec || window.gZenEmojiPicker.getSVGURL("logo-rss.svg"),
+ };
+ } catch (e) {
+ return { label: "", icon: window.gZenEmojiPicker.getSVGURL("logo-rss.svg") };
+ }
+ }
+
+ static async promptForFeedUrl(window, initialUrl = "") {
+ const input = { value: initialUrl };
+ const [prompt] = await lazy.l10n.formatValues(["zen-live-folder-rss-prompt-feed-url"]);
+ const promptOk = Services.prompt.prompt(window, prompt, null, input, null, {
+ value: null,
+ });
+
+ if (!promptOk) {
+ return null;
+ }
+
+ try {
+ const raw = (input.value ?? "").trim();
+ const parsed = new URL(raw);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw new Error();
+ }
+
+ return parsed.href;
+ } catch {
+ window.gZenUIManager.showToast("zen-live-folder-rss-invalid-url-title", {
+ descriptionId: "zen-live-folder-rss-invalid-url-description",
+ timeout: 6000,
+ });
+ }
+
+ return null;
+ }
+
+ async getMetadata() {
+ return nsRssLiveFolderProvider.getMetadata(this.state.url, this.manager.window);
+ }
+
+ async onOptionTrigger(option) {
+ super.onOptionTrigger(option);
+
+ const key = option.getAttribute("option-key");
+ const value = option.getAttribute("option-value");
+
+ if (!this.options.some((x) => x.key === key)) {
+ return;
+ }
+
+ switch (key) {
+ case "feedURL": {
+ const url = await nsRssLiveFolderProvider.promptForFeedUrl(
+ this.manager.window,
+ this.state.url
+ );
+ if (url) {
+ this.state.url = url;
+ this.refresh();
+ }
+ break;
+ }
+ case "maxItems":
+ case "timeRange": {
+ const parsedValue = Number.parseInt(value);
+ if (!Number.isNaN(parsedValue)) {
+ this.state[key] = parsedValue;
+ this.refresh();
+ }
+ break;
+ }
+ }
+
+ this.requestSave();
+ }
+
+ serialize() {
+ return {
+ state: this.state,
+ };
+ }
+}
diff --git a/src/zen/moz.build b/src/zen/moz.build
index d52de380d..6e54f9573 100644
--- a/src/zen/moz.build
+++ b/src/zen/moz.build
@@ -10,6 +10,7 @@ DIRS += [
"common",
"drag-and-drop",
"glance",
+ "live-folders",
"mods",
"tests",
"urlbar",
diff --git a/src/zen/sessionstore/ZenSessionManager.sys.mjs b/src/zen/sessionstore/ZenSessionManager.sys.mjs
index 174bc87cd..321b6312a 100644
--- a/src/zen/sessionstore/ZenSessionManager.sys.mjs
+++ b/src/zen/sessionstore/ZenSessionManager.sys.mjs
@@ -8,6 +8,7 @@ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
+ ZenLiveFoldersManager: "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs",
@@ -521,6 +522,7 @@ export class nsZenSessionManager {
} else {
this.#file._save();
}
+ lazy.ZenLiveFoldersManager.saveState(soon);
this.#debounceRegeneration();
this.log(`Saving Zen session data with ${sidebar.tabs?.length || 0} tabs`);
}
diff --git a/src/zen/sessionstore/ZenWindowSync.sys.mjs b/src/zen/sessionstore/ZenWindowSync.sys.mjs
index 958ae6395..61f0c3e1f 100644
--- a/src/zen/sessionstore/ZenWindowSync.sys.mjs
+++ b/src/zen/sessionstore/ZenWindowSync.sys.mjs
@@ -42,6 +42,7 @@ const EVENTS = [
"TabAddedToEssentials",
"TabRemovedFromEssentials",
+ "TabUngrouped",
"TabGroupUpdate",
"TabGroupCreate",
"TabGroupRemoved",
@@ -146,7 +147,7 @@ class nsZenWindowSync {
/**
* @returns {Window|null} The first opened browser window, or null if none exist.
*/
- get #firstSyncedWindow() {
+ get firstSyncedWindow() {
for (let window of this.#browserWindows) {
return window;
}
@@ -475,6 +476,27 @@ class nsZenWindowSync {
this.#maybeSyncAttributeChange(aOriginalItem, aTargetItem, "zen-workspace-id");
this.#syncItemPosition(aOriginalItem, aTargetItem, aWindow);
}
+ if (aOriginalItem.hasAttribute("zen-live-folder-item-id")) {
+ this.#maybeSyncAttributeChange(aOriginalItem, aTargetItem, "zen-live-folder-item-id");
+ this.#maybeSyncAttributeChange(aOriginalItem, aTargetItem, "zen-show-sublabel");
+ this.#syncTabSubtitle(aWindow, aOriginalItem, aTargetItem);
+ } else if (aTargetItem.hasAttribute("zen-live-folder-item-id")) {
+ aTargetItem.removeAttribute("zen-live-folder-item-id");
+ if (aTargetItem.hasAttribute("zen-show-sublabel")) {
+ this.#syncTabSubtitle(aWindow, aOriginalItem, aTargetItem);
+ aTargetItem.removeAttribute("zen-show-sublabel");
+ }
+ }
+ }
+
+ #syncTabSubtitle(aWindow, aOriginalItem, aTargetItem) {
+ const subLabel = aOriginalItem.getAttribute("zen-show-sublabel");
+ const targetLabel = aTargetItem.querySelector(".zen-tab-sublabel");
+ if (targetLabel) {
+ aWindow.document.l10n.setArgs(targetLabel, {
+ tabSubtitle: subLabel || "zen-default-pinned",
+ });
+ }
}
/**
@@ -1041,7 +1063,7 @@ class nsZenWindowSync {
(tab) => !tab.hasAttribute("zen-empty-tab")
);
const selectedTab = aWindow.gBrowser.selectedTab;
- let win = this.#firstSyncedWindow;
+ let win = this.firstSyncedWindow;
const moveAllTabsToWindow = async (allowSelected = false) => {
const { gBrowser, gZenWorkspaces } = win;
win.focus();
@@ -1369,6 +1391,14 @@ class nsZenWindowSync {
return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_ICON | SYNC_FLAG_LABEL);
}
+ on_TabUngrouped() {
+ // No need to sync anything when a tab is ungrouped, since on_TabMove will take
+ // care of moving the tab to the correct position. We still need to listen to this
+ // in order to throw sync events for other components such as live folders to
+ // update their state, but we don't need to do anything here.
+ return Promise.resolve();
+ }
+
on_ZenTabRemovedFromSplit(aEvent) {
const tab = aEvent.target;
const window = tab.ownerGlobal;
diff --git a/src/zen/tabs/ZenPinnedTabManager.mjs b/src/zen/tabs/ZenPinnedTabManager.mjs
index 0dc7abdba..b1282ce3b 100644
--- a/src/zen/tabs/ZenPinnedTabManager.mjs
+++ b/src/zen/tabs/ZenPinnedTabManager.mjs
@@ -758,6 +758,15 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature {
pinHasChangedUrl(tab) {
if (tab.hasAttribute("zen-pinned-changed")) {
+ const showSublabel = tab.hasAttribute("zen-show-sublabel");
+ if (showSublabel) {
+ tab.removeAttribute("zen-show-sublabel");
+
+ const label = tab.querySelector(".zen-tab-sublabel");
+ window.document.l10n.setArgs(label, {
+ tabSubtitle: "zen-default-pinned",
+ });
+ }
return;
}
if (tab.group?.hasAttribute("split-view-group")) {
diff --git a/src/zen/tabs/zen-tabs/vertical-tabs.css b/src/zen/tabs/zen-tabs/vertical-tabs.css
index bb9dff0c4..000b0ab22 100644
--- a/src/zen/tabs/zen-tabs/vertical-tabs.css
+++ b/src/zen/tabs/zen-tabs/vertical-tabs.css
@@ -472,7 +472,7 @@
display: none;
}
-.tab-reset-pin-label {
+.zen-tab-sublabel {
pointer-events: none;
transition:
opacity 0.1s ease-in-out,
@@ -480,11 +480,18 @@
max-height 0.1s ease-in-out;
max-height: 0px;
opacity: 0;
- transform: translateY(-5px);
+ transform: translateY(-4px);
font-size: x-small;
margin: 0;
}
+.tabbrowser-tab[zen-show-sublabel] {
+ & .tab-label-container .zen-tab-sublabel {
+ max-height: 10px;
+ opacity: 0.5;
+ }
+}
+
#navigator-toolbox[zen-sidebar-expanded="true"] {
--zen-toolbox-min-width: fit-content;
@@ -637,7 +644,7 @@
}
&[zen-pinned-changed="true"]:not([zen-essential]) .tab-reset-pin-button:hover {
- & ~ .tab-label-container .tab-reset-pin-label {
+ & ~ .tab-label-container .zen-tab-sublabel {
max-height: 10px;
opacity: 0.6;
}
@@ -877,11 +884,11 @@
#tabbrowser-tabs {
& .tabbrowser-tab {
- &[pinned]:not([pending="true"]) .tab-close-button {
+ &[pinned]:not([pending="true"]:not([folder-active="true"])) .tab-close-button {
display: none !important;
}
- &[pinned]:not([pending="true"]):not([zen-essential]) {
+ &[pinned]:is(:not([pending="true"]), [folder-active="true"]):not([zen-essential]) {
&:hover .tab-reset-button,
&[visuallyselected] .tab-reset-button {
display: block;
diff --git a/src/zen/tests/live-folders/browser.toml b/src/zen/tests/live-folders/browser.toml
new file mode 100644
index 000000000..e4055420b
--- /dev/null
+++ b/src/zen/tests/live-folders/browser.toml
@@ -0,0 +1,7 @@
+# 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/.
+
+["browser_live_folder.js"]
+["browser_rss_live_folder.js"]
+["browser_github_live_folder.js"]
diff --git a/src/zen/tests/live-folders/browser_github_live_folder.js b/src/zen/tests/live-folders/browser_github_live_folder.js
new file mode 100644
index 000000000..5a66ee928
--- /dev/null
+++ b/src/zen/tests/live-folders/browser_github_live_folder.js
@@ -0,0 +1,165 @@
+/* Any copyright is dedicated to the Public Domain.
+ https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+ nsGithubLiveFolderProvider: "resource:///modules/zen/GithubLiveFolder.sys.mjs",
+});
+
+function getGithubProviderForTest(sandbox, customOptions = {}) {
+ const defaultOptions = {
+ authorMe: true,
+ assignedMe: false,
+ reviewRequested: false,
+ ...customOptions,
+ };
+
+ const mockManager = {
+ saveState: sandbox.spy(),
+ };
+
+ const initialState = {
+ interval: 60,
+ maxItems: 10,
+ lastFetched: 0,
+ type: customOptions.type,
+ options: defaultOptions,
+ };
+
+ let instance = new nsGithubLiveFolderProvider({
+ id: "test-github-folder",
+ state: initialState,
+ manager: mockManager,
+ });
+
+ sandbox.stub(instance, "fetch");
+ return instance;
+}
+
+add_task(async function test_fetch_items_url_construction() {
+ info("should construct the correct GitHub search URL based on default options");
+
+ let sandbox = sinon.createSandbox();
+
+ let instance = getGithubProviderForTest(sandbox, {
+ authorMe: true,
+ assignedMe: false,
+ reviewRequested: false,
+ type: "pull-requests",
+ });
+
+ instance.fetch.resolves({
+ status: 200,
+ text: "",
+ });
+
+ await instance.fetchItems();
+
+ Assert.ok(instance.fetch.calledOnce, "Fetch should be called once");
+
+ const fetchedUrl = new URL(instance.fetch.firstCall.args[0]);
+ const searchParams = fetchedUrl.searchParams;
+
+ Assert.ok(fetchedUrl.href.startsWith("https://github.com/issues/assigned"));
+
+ const query = searchParams.get("q");
+ Assert.ok(query.includes("state:open"), "Should include state:open");
+ Assert.ok(query.includes("is:pr"), "Should include is:PR");
+ Assert.ok(query.includes("author:@me"), "Should include author:@me");
+ Assert.ok(!query.includes("assignee:@me"), "Should NOT include assignee:@me");
+ Assert.ok(!query.includes("review-requested:@me"), "Should NOT include review-requested");
+
+ sandbox.restore();
+});
+
+add_task(async function test_fetch_items_url_complex_options() {
+ info("should construct query with multiple enabled options");
+
+ let sandbox = sinon.createSandbox();
+
+ let instance = getGithubProviderForTest(sandbox, {
+ authorMe: true,
+ assignedMe: true,
+ reviewRequested: true,
+ });
+
+ instance.fetch.resolves({
+ status: 200,
+ text: "",
+ });
+
+ await instance.fetchItems();
+
+ const fetchedUrl = new URL(instance.fetch.firstCall.args[0]);
+ const query = fetchedUrl.searchParams.get("q");
+
+ Assert.ok(query.includes("author:@me"), "Should include author");
+ Assert.ok(query.includes("assignee:@me"), "Should include assignee");
+ Assert.ok(query.includes("review-requested:@me"), "Should include review-requested");
+
+ Assert.ok(query.includes(" OR "), "Should contain OR operators");
+ sandbox.restore();
+});
+
+add_task(async function test_html_parsing_logic() {
+ info("should parse HTML and return structured items");
+
+ let sandbox = sinon.createSandbox();
+ let instance = getGithubProviderForTest(sandbox);
+
+ const mockHtml = `
+
+
+
+
mozilla/zen#101
+
UserA
+
Fix the login bug
+
+
+
+
mozilla/zen#102
+
UserB
+
Add dark mode
+
+
+
+
+ `;
+
+ instance.fetch.resolves({
+ text: mockHtml,
+ status: 200,
+ });
+
+ const items = await instance.fetchItems();
+
+ Assert.equal(items.length, 2, "Should find 2 items");
+
+ Assert.equal(items[0].title, "Fix the login bug");
+ Assert.equal(items[0].subtitle, "UserA");
+ Assert.equal(items[0].id, "mozilla/zen#101");
+ Assert.equal(items[0].url, "https://github.com/issues/101");
+
+ Assert.equal(items[1].title, "Add dark mode");
+ Assert.equal(items[1].subtitle, "UserB");
+ Assert.equal(items[1].id, "mozilla/zen#102");
+ Assert.equal(items[1].url, "https://github.com/pull/102");
+
+ sandbox.restore();
+});
+
+add_task(async function test_fetch_network_error() {
+ info("should gracefully handle network exceptions");
+
+ let sandbox = sinon.createSandbox();
+ let instance = getGithubProviderForTest(sandbox);
+
+ instance.fetch.rejects(new Error("Network down"));
+
+ const errorId = await instance.fetchItems();
+ Assert.equal(errorId, "zen-live-folder-failed-fetch", "Should return an error on failed fetch");
+
+ sandbox.restore();
+});
diff --git a/src/zen/tests/live-folders/browser_live_folder.js b/src/zen/tests/live-folders/browser_live_folder.js
new file mode 100644
index 000000000..2c66ac6c3
--- /dev/null
+++ b/src/zen/tests/live-folders/browser_live_folder.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+ nsZenLiveFolderProvider: "resource:///modules/zen/ZenLiveFolder.sys.mjs",
+});
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+describe("Zen Live Folder Scheduling", () => {
+ let instance;
+ let sandbox;
+ let mockManager;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ mockManager = {
+ saveState: sandbox.spy(),
+ onLiveFolderFetch: sandbox.spy(),
+ };
+ });
+
+ afterEach(() => {
+ if (instance) {
+ instance.stop();
+ }
+ sandbox.restore();
+ });
+
+ it("should fetch correctly at an interval", async () => {
+ const INTERVAL = 250;
+
+ instance = new nsZenLiveFolderProvider({
+ id: "test-folder",
+ manager: mockManager,
+ state: {
+ interval: INTERVAL,
+ lastFetched: Date.now(),
+ },
+ });
+
+ const fetchStub = sandbox.stub(instance, "fetchItems").resolves(["item1"]);
+ sandbox.stub(instance, "getMetadata").returns({});
+
+ instance.start();
+
+ sinon.assert.notCalled(fetchStub);
+ await sleep(INTERVAL / 2);
+ sinon.assert.notCalled(fetchStub);
+
+ await sleep(INTERVAL * 2);
+ Assert.ok(fetchStub.callCount > 1, "Should have fetched more than once");
+
+ sinon.assert.called(mockManager.saveState);
+ sinon.assert.called(mockManager.onLiveFolderFetch);
+ });
+
+ it("should fetch immediately if overdue", async () => {
+ const INTERVAL = 500;
+
+ instance = new nsZenLiveFolderProvider({
+ id: "test-folder-overdue",
+ manager: mockManager,
+ state: {
+ interval: INTERVAL,
+ lastFetched: Date.now() - 3600000,
+ },
+ });
+
+ const fetchStub = sandbox.stub(instance, "fetchItems").resolves(["item1"]);
+ sandbox.stub(instance, "getMetadata").returns({});
+
+ instance.start();
+
+ await sleep(20);
+ sinon.assert.calledOnce(fetchStub);
+ });
+});
diff --git a/src/zen/tests/live-folders/browser_rss_live_folder.js b/src/zen/tests/live-folders/browser_rss_live_folder.js
new file mode 100644
index 000000000..ad2ceb60b
--- /dev/null
+++ b/src/zen/tests/live-folders/browser_rss_live_folder.js
@@ -0,0 +1,229 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+ nsRssLiveFolderProvider: "resource:///modules/zen/RssLiveFolder.sys.mjs",
+});
+
+function getRssProviderForTest(sandbox, customState = {}) {
+ const defaultState = {
+ url: "https://example.com/feed.xml",
+ interval: 60,
+ maxItems: 10,
+ timeRange: 24 * 60 * 60 * 1000, // 24 Hours
+ lastFetched: 0,
+ ...customState,
+ };
+
+ const mockManager = {
+ saveState: sandbox.spy(),
+ };
+
+ const instance = new nsRssLiveFolderProvider({
+ id: "test-rss",
+ state: defaultState,
+ manager: mockManager,
+ });
+
+ sandbox.stub(instance, "fetch");
+ sandbox.stub(instance, "getMetadata").resolves({});
+
+ return instance;
+}
+
+add_task(async function test_rss_parsing() {
+ info("should parse standard RSS 2.0 feeds correctly");
+
+ let sandbox = sinon.createSandbox();
+ let instance = getRssProviderForTest(sandbox);
+
+ const rssXml = `
+
+
+ Tech News
+ -
+ Mozilla Releases Zen
+ https://mozilla.org/zen
+ guid-123
+ ${new Date().toUTCString()}
+
+ -
+ Another Article
+ https://example.com/article
+ ${new Date().toUTCString()}
+
+
+
+ `;
+
+ instance.fetch.resolves({
+ text: rssXml,
+ });
+
+ const items = await instance.fetchItems();
+ Assert.equal(items.length, 2, "Should find 2 items");
+
+ // Check mapping
+ Assert.equal(items[0].title, "Mozilla Releases Zen");
+ Assert.equal(items[0].url, "https://mozilla.org/zen");
+ Assert.equal(items[0].id, "guid-123");
+
+ // Check fallback for ID
+ // In the second item, no guid is present, but is.
+ Assert.equal(items[1].title, "Another Article");
+ Assert.equal(items[1].id, "https://example.com/article");
+
+ sandbox.restore();
+});
+
+add_task(async function test_atom_parsing() {
+ info("should parse Atom feeds correctly");
+
+ let sandbox = sinon.createSandbox();
+ let instance = getRssProviderForTest(sandbox);
+
+ const atomXml = `
+
+ Atom Feed
+
+ Atom Entry 1
+
+ urn:uuid:12345
+ ${new Date().toISOString()}
+
+
+ `;
+
+ instance.fetch.resolves({
+ text: atomXml,
+ });
+
+ const items = await instance.fetchItems();
+
+ Assert.equal(items.length, 1);
+ Assert.equal(items[0].title, "Atom Entry 1");
+ Assert.equal(items[0].url, "https://example.com/atom1");
+ Assert.equal(items[0].id, "urn:uuid:12345");
+
+ sandbox.restore();
+});
+
+add_task(async function test_time_range_filtering() {
+ info("should filter out items older than timeRange");
+
+ let sandbox = sinon.createSandbox();
+
+ const ONE_HOUR = 60 * 60 * 1000;
+ let instance = getRssProviderForTest(sandbox, { timeRange: ONE_HOUR });
+
+ const now = Date.now();
+ const recentDate = new Date(now - 10 * 60 * 1000).toUTCString(); // 10 mins ago
+ const oldDate = new Date(now - ONE_HOUR * 2).toUTCString(); // 2 hours ago
+
+ const rssXml = `
+
+
+ -
+ Recent News
+ ${recentDate}
+ http://a.com
+
+ -
+ Old News
+ ${oldDate}
+ http://b.com
+
+
+
+ `;
+
+ instance.fetch.resolves({
+ text: rssXml,
+ });
+
+ const items = await instance.fetchItems();
+
+ Assert.equal(items.length, 1, "Should only return 1 item");
+ Assert.equal(items[0].title, "Recent News", "Should keep the recent item");
+
+ sandbox.restore();
+});
+
+add_task(async function test_max_items_limit() {
+ info("should respect maxItems limit");
+
+ let sandbox = sinon.createSandbox();
+ let instance = getRssProviderForTest(sandbox, { maxItems: 2 });
+
+ const date = new Date().toUTCString();
+ const rssXml = `
+
+
+ - 11${date}
+ - 22${date}
+ - 33${date}
+
+
+ `;
+
+ instance.fetch.resolves({
+ text: rssXml,
+ });
+
+ const items = await instance.fetchItems();
+
+ Assert.equal(items.length, 2, "Should be capped at 2 items");
+ Assert.equal(items[0].title, "1");
+ Assert.equal(items[1].title, "2");
+
+ sandbox.restore();
+});
+
+add_task(async function test_invalid_dates() {
+ info("should ignore items with invalid dates");
+
+ let sandbox = sinon.createSandbox();
+ let instance = getRssProviderForTest(sandbox);
+
+ const rssXml = `
+
+
+ -
+ Bad Date
+ http://bad.com
+ ThisIsNotADate
+
+ -
+ No Date
+ http://nodate.com
+
+
+
+ `;
+
+ instance.fetch.resolves({
+ text: rssXml,
+ });
+
+ const items = await instance.fetchItems();
+
+ Assert.equal(items.length, 0, "Items with invalid/missing dates should be filtered");
+ sandbox.restore();
+});
+
+add_task(async function test_fetch_network_error() {
+ info("should return empty array on network error");
+
+ let sandbox = sinon.createSandbox();
+ let instance = getRssProviderForTest(sandbox);
+
+ instance.fetch.rejects(new Error("Network down"));
+
+ const items = await instance.fetchItems();
+ Assert.equal(items, "zen-live-folder-failed-fetch", "Should return an error on failed fetch");
+
+ sandbox.restore();
+});
diff --git a/src/zen/tests/moz.build b/src/zen/tests/moz.build
index c8c60de2c..d8529fe67 100644
--- a/src/zen/tests/moz.build
+++ b/src/zen/tests/moz.build
@@ -7,6 +7,7 @@ BROWSER_CHROME_MANIFESTS += [
"container_essentials/browser.toml",
"folders/browser.toml",
"glance/browser.toml",
+ "live-folders/browser.toml",
"pinned/browser.toml",
"split_view/browser.toml",
"tabs/browser.toml",
diff --git a/src/zen/zen.globals.mjs b/src/zen/zen.globals.mjs
index f082f84f1..058a4d413 100644
--- a/src/zen/zen.globals.mjs
+++ b/src/zen/zen.globals.mjs
@@ -34,6 +34,7 @@ export default [
"gZenFolders",
"gZenMediaController",
"gZenGlanceManager",
+ "gZenLiveFoldersUI",
"gZenThemePicker",