mirror of
https://github.com/zen-browser/desktop.git
synced 2026-02-22 11:26:41 +00:00
Merge branch 'dev' of https://github.com/zen-browser/desktop into dev
This commit is contained in:
98
locales/en-US/browser/browser/zen-live-folders.ftl
Normal file
98
locales/en-US/browser/browser/zen-live-folders.ftl
Normal file
@@ -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.
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,3 +51,4 @@
|
||||
<script type="module" src="chrome://browser/content/zen-components/ZenFolders.mjs"></script>
|
||||
<script type="module" src="chrome://browser/content/zen-components/ZenDownloadAnimation.mjs"></script>
|
||||
<script type="module" src="chrome://browser/content/zen-components/ZenEmojiPicker.mjs"></script>
|
||||
<script type="module" src="chrome://browser/content/zen-components/ZenLiveFoldersUI.mjs"></script>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,4 +63,6 @@
|
||||
<command id="cmd_zenCloseUnpinnedTabs" />
|
||||
|
||||
<command id="cmd_zenNewNavigatorUnsynced" />
|
||||
|
||||
<command id="cmd_zenNewLiveFolder" />
|
||||
</commandset>
|
||||
|
||||
@@ -9,4 +9,5 @@
|
||||
<link rel="localization" href="browser/zen-menubar.ftl"/>
|
||||
<link rel="localization" href="browser/zen-vertical-tabs.ftl"/>
|
||||
<link rel="localization" href="browser/zen-folders.ftl"/>
|
||||
<link rel="localization" href="browser/zen-live-folders.ftl"/>
|
||||
</linkset>
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
<menupopup id="zenCreateNewPopup">
|
||||
<menu data-l10n-id="zen-panel-ui-live-folder-create" id="zen-panel-ui-live-folder-create">
|
||||
<menupopup>
|
||||
<menuitem
|
||||
data-l10n-id="zen-live-folder-github-pull-requests"
|
||||
command="cmd_zenNewLiveFolder"
|
||||
image="chrome://browser/content/zen-images/favicons/github.svg" />
|
||||
<menuitem
|
||||
data-l10n-id="zen-live-folder-github-issues"
|
||||
command="cmd_zenNewLiveFolder"
|
||||
image="chrome://browser/content/zen-images/favicons/github.svg" />
|
||||
<menuitem
|
||||
data-l10n-id="zen-live-folder-type-rss"
|
||||
command="cmd_zenNewLiveFolder"
|
||||
image="chrome://browser/skin/zen-icons/selectable/logo-rss.svg"/>
|
||||
</menupopup>
|
||||
</menu>
|
||||
<menuseparator/>
|
||||
<menuitem data-l10n-id="zen-panel-ui-workspaces-create" command="cmd_zenOpenWorkspaceCreation" image="chrome://browser/skin/zen-icons/duplicate-tab.svg" />
|
||||
<menuitem data-l10n-id="zen-panel-ui-folder-create" command="cmd_zenOpenFolderCreation" image="chrome://browser/skin/zen-icons/folder.svg" />
|
||||
<menuseparator/>
|
||||
@@ -35,6 +52,12 @@
|
||||
</menupopup>
|
||||
|
||||
<menupopup id="zenFolderActions">
|
||||
<menu id="context_zenLiveFolderOptions"
|
||||
data-l10n-id="zen-live-folder-options"
|
||||
hidden="true">
|
||||
<menupopup />
|
||||
</menu>
|
||||
<menuseparator id="live-folder-separator" hidden="true"/>
|
||||
<menuitem id="context_zenFolderRename" data-l10n-id="zen-folders-panel-rename-folder"/>
|
||||
<menuitem id="context_zenFolderChangeIcon" data-l10n-id="tab-context-zen-edit-icon"/>
|
||||
<menuseparator />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
<hbox class="tab-secondary-label">
|
||||
<label class="tab-icon-sound-label tab-icon-sound-pip-label" data-l10n-id="browser-tab-audio-pip" role="presentation"/>
|
||||
</hbox>
|
||||
+ <label class="tab-reset-pin-label" data-l10n-id="tab-reset-pin-label" role="presentation"/>
|
||||
+ <label class="zen-tab-sublabel" data-l10n-id="zen-tab-sublabel" data-l10n-args='{"tabSubtitle": "zen-default-pinned"}' role="presentation"/>
|
||||
</vbox>
|
||||
<image class="tab-note-icon" role="presentation"/>
|
||||
<image class="tab-close-button close-icon" role="button" data-l10n-id="tabbrowser-close-tabs-button" data-l10n-args='{"tabCount": 1}' keyNav="false"/>
|
||||
|
||||
@@ -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/.
|
||||
<svg fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><path d="M256 32C132.3 32 32 134.9 32 261.7c0 101.5 64.2 187.5 153.2 217.9a17.56 17.56 0 003.8.4c8.3 0 11.5-6.1 11.5-11.4 0-5.5-.2-19.9-.3-39.1a102.4 102.4 0 01-22.6 2.7c-43.1 0-52.9-33.5-52.9-33.5-10.2-26.5-24.9-33.6-24.9-33.6-19.5-13.7-.1-14.1 1.4-14.1h.1c22.5 2 34.3 23.8 34.3 23.8 11.2 19.6 26.2 25.1 39.6 25.1a63 63 0 0025.6-6c2-14.8 7.8-24.9 14.2-30.7-49.7-5.8-102-25.5-102-113.5 0-25.1 8.7-45.6 23-61.6-2.3-5.8-10-29.2 2.2-60.8a18.64 18.64 0 015-.5c8.1 0 26.4 3.1 56.6 24.1a208.21 208.21 0 01112.2 0c30.2-21 48.5-24.1 56.6-24.1a18.64 18.64 0 015 .5c12.2 31.6 4.5 55 2.2 60.8 14.3 16.1 23 36.6 23 61.6 0 88.2-52.4 107.6-102.3 113.3 8 7.1 15.2 21.1 15.2 42.5 0 30.7-.3 55.5-.3 63 0 5.4 3.1 11.5 11.4 11.5a19.35 19.35 0 004-.4C415.9 449.2 480 363.1 480 261.7 480 134.9 379.7 32 256 32z"/></svg>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/.
|
||||
<svg fill="none" stroke="context-fill" stroke-width="1.5" stroke-opacity="context-fill-opacity" height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><path d="m 9,13.569 c -0.552,0 -1,-0.449 -1,-1 0,-0.551 0.448,-1 1,-1 0.552,0 1,0.449 1,1 0,0.551 -0.448,1 -1,1 z M 9,6.5 V 10 M 7.638,3.495 2.213,12.891 c -0.605,1.048 0.151,2.359 1.362,2.359 h 10.85 c 1.211,0 1.967,-1.31 1.362,-2.359 L 10.362,3.495 C 9.757,2.447 8.243,2.447 7.638,3.495 Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="18" width="18" viewBox="0 0 18 18"><g stroke-linecap="round" stroke-width="1.5" fill="none" stroke="context-fill" stroke-opacity="context-fill-opacity" stroke-linejoin="round"><path d="M7.638,3.495L2.213,12.891c-.605,1.048,.151,2.359,1.362,2.359H14.425c1.211,0,1.967-1.31,1.362-2.359L10.362,3.495c-.605-1.048-2.119-1.048-2.724,0Z"></path><line x1="9" y1="6.5" x2="9" y2="10"></line><path d="M9,13.569c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z" fill="context-fill" data-stroke="none" stroke="none"></path></g></svg>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -58,6 +58,7 @@ window.gZenUIManager = {
|
||||
|
||||
gZenMediaController.init();
|
||||
gZenVerticalTabsManager.init();
|
||||
gZenLiveFoldersUI.init();
|
||||
|
||||
this._initCreateNewPopup();
|
||||
this._debloatContextMenus();
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
5
src/zen/live-folders/LiveFoldersComponents.manifest
Normal file
5
src/zen/live-folders/LiveFoldersComponents.manifest
Normal file
@@ -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
|
||||
337
src/zen/live-folders/ZenLiveFolder.sys.mjs
Normal file
337
src/zen/live-folders/ZenLiveFolder.sys.mjs
Normal file
@@ -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) <meta charset> 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 = /<meta\s+charset\s*=\s*["']?([a-z0-9_-]+)/i;
|
||||
let match = head.match(metaCharsetRegex);
|
||||
|
||||
if (!match) {
|
||||
const httpEquivRegex =
|
||||
/<meta\s+http-equiv\s*=\s*["']?content-type["']?[^>]*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;
|
||||
}
|
||||
}
|
||||
506
src/zen/live-folders/ZenLiveFoldersManager.sys.mjs
Normal file
506
src/zen/live-folders/ZenLiveFoldersManager.sys.mjs
Normal file
@@ -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();
|
||||
221
src/zen/live-folders/ZenLiveFoldersUI.mjs
Normal file
221
src/zen/live-folders/ZenLiveFoldersUI.mjs
Normal file
@@ -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();
|
||||
5
src/zen/live-folders/jar.inc.mn
Normal file
5
src/zen/live-folders/jar.inc.mn
Normal file
@@ -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)
|
||||
10
src/zen/live-folders/moz.build
Normal file
10
src/zen/live-folders/moz.build
Normal file
@@ -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",
|
||||
]
|
||||
260
src/zen/live-folders/providers/GithubLiveFolder.sys.mjs
Normal file
260
src/zen/live-folders/providers/GithubLiveFolder.sys.mjs
Normal file
@@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
274
src/zen/live-folders/providers/RssLiveFolder.sys.mjs
Normal file
274
src/zen/live-folders/providers/RssLiveFolder.sys.mjs
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ DIRS += [
|
||||
"common",
|
||||
"drag-and-drop",
|
||||
"glance",
|
||||
"live-folders",
|
||||
"mods",
|
||||
"tests",
|
||||
"urlbar",
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1045,7 +1067,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();
|
||||
@@ -1373,6 +1395,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;
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
7
src/zen/tests/live-folders/browser.toml
Normal file
7
src/zen/tests/live-folders/browser.toml
Normal file
@@ -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"]
|
||||
165
src/zen/tests/live-folders/browser_github_live_folder.js
Normal file
165
src/zen/tests/live-folders/browser_github_live_folder.js
Normal file
@@ -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: "<html></html>",
|
||||
});
|
||||
|
||||
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: "<html></html>",
|
||||
});
|
||||
|
||||
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 = `
|
||||
<html>
|
||||
<body>
|
||||
<div>
|
||||
<div class="IssueItem-module__defaultRepoContainer"><span>mozilla/zen</span><span>#101</span></div>
|
||||
<a class="IssueItem-module__authorCreatedLink">UserA</a>
|
||||
<div class="Title-module__container">Fix the login bug</div>
|
||||
<a data-testid="issue-pr-title-link" href="issues/101"></a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="IssueItem-module__defaultRepoContainer"><span>mozilla/zen</span><span>#102</span></div>
|
||||
<a class="IssueItem-module__authorCreatedLink">UserB</a>
|
||||
<div class="Title-module__container">Add dark mode</div>
|
||||
<a data-testid="issue-pr-title-link" href="pull/102"></a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
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();
|
||||
});
|
||||
83
src/zen/tests/live-folders/browser_live_folder.js
Normal file
83
src/zen/tests/live-folders/browser_live_folder.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
229
src/zen/tests/live-folders/browser_rss_live_folder.js
Normal file
229
src/zen/tests/live-folders/browser_rss_live_folder.js
Normal file
@@ -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 = `
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Tech News</title>
|
||||
<item>
|
||||
<title>Mozilla Releases Zen</title>
|
||||
<link>https://mozilla.org/zen</link>
|
||||
<guid>guid-123</guid>
|
||||
<pubDate>${new Date().toUTCString()}</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Another Article</title>
|
||||
<link>https://example.com/article</link>
|
||||
<pubDate>${new Date().toUTCString()}</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
|
||||
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 <link> 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 = `
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Atom Feed</title>
|
||||
<entry>
|
||||
<title>Atom Entry 1</title>
|
||||
<link href="https://example.com/atom1" />
|
||||
<id>urn:uuid:12345</id>
|
||||
<updated>${new Date().toISOString()}</updated>
|
||||
</entry>
|
||||
</feed>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Recent News</title>
|
||||
<pubDate>${recentDate}</pubDate>
|
||||
<link>http://a.com</link>
|
||||
</item>
|
||||
<item>
|
||||
<title>Old News</title>
|
||||
<pubDate>${oldDate}</pubDate>
|
||||
<link>http://b.com</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item><title>1</title><link>1</link><pubDate>${date}</pubDate></item>
|
||||
<item><title>2</title><link>2</link><pubDate>${date}</pubDate></item>
|
||||
<item><title>3</title><link>3</link><pubDate>${date}</pubDate></item>
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Bad Date</title>
|
||||
<link>http://bad.com</link>
|
||||
<pubDate>ThisIsNotADate</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>No Date</title>
|
||||
<link>http://nodate.com</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -34,6 +34,7 @@ export default [
|
||||
"gZenFolders",
|
||||
"gZenMediaController",
|
||||
"gZenGlanceManager",
|
||||
"gZenLiveFoldersUI",
|
||||
|
||||
"gZenThemePicker",
|
||||
|
||||
|
||||
Reference in New Issue
Block a user