1
0
mirror of https://github.com/XFox111/TabsAsideExtension.git synced 2026-04-22 07:58:01 +03:00

feat: Minor 3.1.0 (#150)

* Some features are now optional (#148)

* fix(dev): yarn.lock tree fix

* feat: bookmarks moved to optional permissions

* fix: analytics not working in firefox

* feat!: ability to turn off analytics (uses permissions on firefox)

* feat: analytics tracker for bookmark export

* feat: add privacy policy link in about section

* docs: privacy policy update

* feat: ability to chain multiple dialogs

* fix(loc): analytics option translation

* feat: settings review dialog

* fix: background script fails to load because of frontend code

* chore: use analytics permission as storage value

* fix: inverted analytics value

* feat!: option to disable thumbnail capture

* fix(ci): sed typo

* fix: minor fixes

* fix(firefox): web-ext lint error fix

* chore(ci): switch web-ext action

* chore(lint): fix eslint warnings

* chore(deps): monthly dependency bump (September 2025) (#149)

* chore: 3.1.0 version bump

* chore: minor cleanup

* fix: allow analytics checkbox stays inactive after denying permission on firefox

* fix(deps): yarn.lock rebuild

* fix: type assertion for userId

* fix: settings review dialog not showing if welcome dialog is not required

* fix: analytics and thumbnail capture toggles react incorrectly if permission is denied
This commit is contained in:
2025-09-09 12:24:01 +03:00
committed by GitHub
parent 735089eb59
commit e21022d985
46 changed files with 2510 additions and 2369 deletions
+5
View File
@@ -59,6 +59,11 @@ jobs:
working-directory: ./node_modules/@dnd-kit/core/dist working-directory: ./node_modules/@dnd-kit/core/dist
if: ${{ matrix.target == 'firefox' }} if: ${{ matrix.target == 'firefox' }}
# Patch for firefox analytics (see https://github.com/wxt-dev/wxt/pull/1808)
- run: sed -i 's|if (location.pathname === "/background.js")|if (location.pathname === "/background.js" \|\| location.pathname === "/_generated_background_page.html")|' index.mjs
working-directory: ./node_modules/@wxt-dev/analytics/dist
if: ${{ matrix.target == 'firefox' }}
- run: yarn zip -b ${{ matrix.target }} - run: yarn zip -b ${{ matrix.target }}
- name: Drop build artifacts (${{ matrix.target }}) - name: Drop build artifacts (${{ matrix.target }})
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@main
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
extver=`jq -r ".version" package.json` extver=`jq -r ".version" package.json`
echo "version=$extver" >> "$GITHUB_OUTPUT" echo "version=$extver" >> "$GITHUB_OUTPUT"
- uses: dev-build-deploy/release-me@v0.18.0 - uses: dev-build-deploy/release-me@v0.18.2
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
prefix: v prefix: v
+9 -3
View File
@@ -51,6 +51,11 @@ jobs:
working-directory: ./node_modules/@dnd-kit/core/dist working-directory: ./node_modules/@dnd-kit/core/dist
if: ${{ matrix.target == 'firefox' }} if: ${{ matrix.target == 'firefox' }}
# Patch for firefox analytics (see https://github.com/wxt-dev/wxt/pull/1808)
- run: sed -i 's|if (location.pathname === "/background.js")|if (location.pathname === "/background.js" \|\| location.pathname === "/_generated_background_page.html")|' index.mjs
working-directory: ./node_modules/@wxt-dev/analytics/dist
if: ${{ matrix.target == 'firefox' }}
- run: yarn zip -b ${{ matrix.target }} - run: yarn zip -b ${{ matrix.target }}
- name: Drop artifacts (${{ matrix.target }}) - name: Drop artifacts (${{ matrix.target }})
@@ -62,9 +67,10 @@ jobs:
- name: web-ext lint - name: web-ext lint
if: ${{ matrix.target == 'firefox' }} if: ${{ matrix.target == 'firefox' }}
uses: freaktechnik/web-ext-lint@main uses: kewisch/action-web-ext@main
with: with:
extension-root: ./.output/firefox-mv3 cmd: lint
self-hosted: false source: ./.output/firefox-mv3
channel: listed
- run: yarn npm audit - run: yarn npm audit
+35 -2
View File
@@ -6,7 +6,7 @@
- Thumbnails of saved tabs - Thumbnails of saved tabs
3. This extension uses Google Analytics to collect usage statistics and improve the extension. 3. This extension uses Google Analytics to collect usage statistics and improve the extension.
4. This extension uses analytics to collect following data: 4. This extension uses analytics to collect following data:
- Random UUID to identify the user - Random UUID to distinguish unique users
- Browser name and version - Browser name and version
- Operating system name and version - Operating system name and version
- System architecture - System architecture
@@ -14,7 +14,40 @@
- Extension language - Extension language
- User settings - User settings
- Number of saved collections - Number of saved collections
- Action identifiers (e.g. "page_view", "extension_installed", "item_created", etc.) - Events, related to user's actions:
- `bmc_clicked` (when "Buy me a Coffee" button is clicked)
- `collection_list` (when extension's options page is opened)
- `cta_dismissed` (when "Like this extension?" prompt is closed)
- `extension_installed` (when extension is installed or updated)
- `feedback_clicked` (when "Leave feedback" button is clicked)
- `item_created` (when new collection or group is created using dialog window)
- `item_edited` (when collection or group is edited)
- `options_page` (when extension's options page is opened)
- `page_view` (when extension's page is opened)
- `save` (when "Save all tabs" or "Save selected tabs" buttons are clicked)
- `set_aside` (when "Set all tabs aside" or "Set selected tabs aside" buttons are clicked)
- `used_drag_and_drop` (when items inside collection list were reordered)
- `visit_blog_button_click` (when "Read dev blog" button is clicked)
- `bookmarks_saved` (when "Export to bookmarks" option is clicked)
- Events, related to extension errors:
- `background_error` (when error inside background service has occured)
- `cloud_get_error` (when failed to retrieve collections from the cloud storage)
- `conflict_resolve_with_cloud_error` (when failed to retrieve collections from the cloud storage during storage conflict resolution)
- `cloud_save_error` (when failed to save collections to the cloud storage)
- `messaging_error` (when failed to send a message to extenion's background service)
- `notification_error` (when failed to display a toast notification)
4. Following events, beside their name, include additional information, such as:
- `item_created` and `item_edited`:
- Type of the affected item (`collection` or `group`)
- `extension_installed`:
- Reason for update (`install`, `update`, or `browser_update`)
- Previously installed extension's version, if applicable
- `page_view`:
- Type of the page (`options_page` or `collection_list`)
- All extension's error events:
- Error name
- Error message
- Error call stack
4. This extension does not collect or use any personally identifiable information (PII) or sensitive data for purposes other than its core functionality. 4. This extension does not collect or use any personally identifiable information (PII) or sensitive data for purposes other than its core functionality.
5. This extension uses cloud storage built into your browser to store its data. 5. This extension uses cloud storage built into your browser to store its data.
6. Refer to your browser's developer regarding the privacy policy of the cloud storage used by your browser. 6. Refer to your browser's developer regarding the privacy policy of the cloud storage used by your browser.
-24
View File
@@ -1,24 +0,0 @@
import { googleAnalytics4 } from "@wxt-dev/analytics/providers/google-analytics-4";
import { WxtAppConfig } from "wxt/sandbox";
import { userPropertiesStorage } from "./features/analytics";
export default defineAppConfig({
analytics:
{
debug: import.meta.env.DEV,
enabled: storage.defineItem("local:analytics", {
fallback: true
}),
userId: storage.defineItem("local:userId", {
init: () => crypto.randomUUID()
}),
userProperties: userPropertiesStorage,
providers:
[
googleAnalytics4({
apiSecret: import.meta.env.WXT_GA4_API_SECRET,
measurementId: import.meta.env.WXT_GA4_MEASUREMENT_ID
})
]
}
} as WxtAppConfig);
+2 -1
View File
@@ -13,7 +13,8 @@ export const githubLinks =
repo: githubLink(), repo: githubLink(),
release: githubLink(`releases/tag/v${Package.version}`), release: githubLink(`releases/tag/v${Package.version}`),
license: githubLink("blob/main/LICENSE"), license: githubLink("blob/main/LICENSE"),
translationGuide: githubLink("wiki/Contribution-Guidelines#contributing-to-translations") translationGuide: githubLink("wiki/Contribution-Guidelines#contributing-to-translations"),
privacy: githubLink("blob/main/PRIVACY.md")
}; };
export const storeLink: string = export const storeLink: string =
+75 -26
View File
@@ -1,5 +1,5 @@
import { track, trackError } from "@/features/analytics"; import { track, trackError } from "@/features/analytics";
import { collectionCount, getCollections, saveCollections } from "@/features/collectionStorage"; import { collectionCount, getCollections, thumbnailCaptureEnabled, saveCollections } from "@/features/collectionStorage";
import { migrateStorage } from "@/features/migration"; import { migrateStorage } from "@/features/migration";
import { showWelcomeDialog } from "@/features/v3welcome/utils/showWelcomeDialog"; import { showWelcomeDialog } from "@/features/v3welcome/utils/showWelcomeDialog";
import { SettingsValue } from "@/hooks/useSettings"; import { SettingsValue } from "@/hooks/useSettings";
@@ -13,13 +13,15 @@ import watchTabSelection from "@/utils/watchTabSelection";
import { Tabs, Windows } from "wxt/browser"; import { Tabs, Windows } from "wxt/browser";
import { Unwatch } from "wxt/storage"; import { Unwatch } from "wxt/storage";
import { openCollection, openGroup } from "./sidepanel/utils/opener"; import { openCollection, openGroup } from "./sidepanel/utils/opener";
import { setSettingsReviewNeeded } from "@/features/settingsReview/utils";
import { RemoveListenerCallback } from "@webext-core/messaging";
export default defineBackground(() => export default defineBackground(() =>
{ {
try try
{ {
const logger = getLogger("background"); const logger = getLogger("background");
const graphicsCache: GraphicsStorage = {}; let graphicsCache: GraphicsStorage = {};
let listLocation: SettingsValue<"listLocation"> = "sidebar"; let listLocation: SettingsValue<"listLocation"> = "sidebar";
logger("Background script started"); logger("Background script started");
@@ -36,6 +38,8 @@ export default defineBackground(() =>
const previousMajor: number = previousVersion ? parseInt(previousVersion.split(".")[0]) : 0; const previousMajor: number = previousVersion ? parseInt(previousVersion.split(".")[0]) : 0;
await setSettingsReviewNeeded(reason, previousVersion);
if (reason === "update" && previousMajor < 3) if (reason === "update" && previousMajor < 3)
{ {
await migrateStorage(); await migrateStorage();
@@ -44,31 +48,11 @@ export default defineBackground(() =>
} }
}); });
browser.tabs.onUpdated.addListener((_, __, tab) =>
{
if (!tab.url)
return;
graphicsCache[tab.url] = {
preview: graphicsCache[tab.url]?.preview,
capture: graphicsCache[tab.url]?.capture,
icon: tab.favIconUrl ?? graphicsCache[tab.url]?.icon
};
});
browser.commands.onCommand.addListener( browser.commands.onCommand.addListener(
(command, tab) => performContextAction(command, tab!.windowId!) (command, tab) => performContextAction(command, tab!.windowId!)
); );
onMessage("getGraphicsCache", () => graphicsCache); onMessage("getGraphicsCache", () => graphicsCache);
onMessage("addThumbnail", ({ data }) =>
{
graphicsCache[data.url] = {
preview: data.thumbnail,
capture: graphicsCache[data.url]?.capture,
icon: graphicsCache[data.url]?.icon
};
});
onMessage("refreshCollections", () => { }); onMessage("refreshCollections", () => { });
if (import.meta.env.FIREFOX) if (import.meta.env.FIREFOX)
@@ -80,6 +64,21 @@ export default defineBackground(() =>
setupTabCaputre(); setupTabCaputre();
async function setupTabCaputre(): Promise<void> async function setupTabCaputre(): Promise<void>
{ {
let unwatchAddThumbnail: RemoveListenerCallback | null = null;
let captureInterval: NodeJS.Timeout | null = null;
const captureFavicon = (_: any, __: any, tab: Tabs.Tab): void =>
{
if (!tab.url)
return;
graphicsCache[tab.url] = {
preview: graphicsCache[tab.url]?.preview,
capture: graphicsCache[tab.url]?.capture,
icon: tab.favIconUrl ?? graphicsCache[tab.url]?.icon
};
};
const tryCaptureTab = async (tab: Tabs.Tab): Promise<void> => const tryCaptureTab = async (tab: Tabs.Tab): Promise<void> =>
{ {
if (!tab.url || tab.status !== "complete" || !tab.active) if (!tab.url || tab.status !== "complete" || !tab.active)
@@ -113,11 +112,61 @@ export default defineBackground(() =>
} }
}; };
setInterval(() => const updateCapture = async (captureThumbnails: boolean): Promise<void> =>
{ {
browser.tabs.query({ active: true }) const scriptingGranted: boolean = await browser.permissions.contains({ permissions: ["scripting"] });
.then(tabs => tabs.forEach(tab => tryCaptureTab(tab)));
}, 1000); if (captureThumbnails)
{
if (scriptingGranted)
await browser.scripting.registerContentScripts([
{
id: "capture-script",
matches: ["<all_urls>"],
runAt: "document_idle",
js: ["capture.js"]
}
]);
unwatchAddThumbnail = onMessage("addThumbnail", ({ data }) =>
{
graphicsCache[data.url] = {
preview: data.thumbnail,
capture: graphicsCache[data.url]?.capture,
icon: graphicsCache[data.url]?.icon
};
});
captureInterval = setInterval(() =>
{
browser.tabs.query({ active: true })
.then(tabs => tabs.forEach(tab => tryCaptureTab(tab)));
}, 1000);
browser.tabs.onUpdated.addListener(captureFavicon);
}
else
{
if (scriptingGranted)
await browser.scripting.unregisterContentScripts({
ids: ["capture-script"]
});
unwatchAddThumbnail?.();
if (captureInterval)
clearInterval(captureInterval);
browser.tabs.onUpdated.removeListener(captureFavicon);
graphicsCache = {};
}
};
if (await thumbnailCaptureEnabled.getValue())
updateCapture(true);
thumbnailCaptureEnabled.watch(updateCapture);
} }
setupContextMenu(); setupContextMenu();
@@ -4,11 +4,7 @@ import { sendMessage } from "@/utils/messaging";
// This content script is injected into each browser tab. // This content script is injected into each browser tab.
// It's purpose is to retrive an OpenGraph thumbnail URL from the metadata // It's purpose is to retrive an OpenGraph thumbnail URL from the metadata
export default defineContentScript({ export default defineUnlistedScript({ main });
matches: ["<all_urls>"],
runAt: "document_idle",
main
});
const logger = getLogger("contentScript"); const logger = getLogger("contentScript");
@@ -34,5 +34,12 @@ export const useOptionsStyles = makeStyles({
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
gap: tokens.spacingHorizontalS gap: tokens.spacingHorizontalS
},
group:
{
display: "flex",
flexFlow: "column",
alignItems: "flex-start",
gap: tokens.spacingVerticalSNudge
} }
}); });
+2 -1
View File
@@ -34,7 +34,8 @@ export default function AboutSection(): React.ReactElement
<Body1 as="p"> <Body1 as="p">
<Link { ...extLink(websiteLink) }>{ i18n.t("options_page.about.links.website") }</Link><br /> <Link { ...extLink(websiteLink) }>{ i18n.t("options_page.about.links.website") }</Link><br />
<Link { ...extLink(githubLinks.repo) }>{ i18n.t("options_page.about.links.source") }</Link><br /> <Link { ...extLink(githubLinks.repo) }>{ i18n.t("options_page.about.links.source") }</Link><br />
<Link { ...extLink(githubLinks.release) }>{ i18n.t("options_page.about.links.changelog") }</Link> <Link { ...extLink(githubLinks.release) }>{ i18n.t("options_page.about.links.changelog") }</Link><br />
<Link { ...extLink(githubLinks.privacy) }>{ i18n.t("options_page.about.links.privacy") }</Link>
</Body1> </Body1>
<div className={ cls.horizontalButtons }> <div className={ cls.horizontalButtons }>
@@ -2,6 +2,7 @@ import useSettings, { SettingsValue } from "@/hooks/useSettings";
import { Button, Checkbox, Dropdown, Field, Option, OptionOnSelectData } from "@fluentui/react-components"; import { Button, Checkbox, Dropdown, Field, Option, OptionOnSelectData } from "@fluentui/react-components";
import { KeyCommand20Regular } from "@fluentui/react-icons"; import { KeyCommand20Regular } from "@fluentui/react-icons";
import { useOptionsStyles } from "../hooks/useOptionsStyles"; import { useOptionsStyles } from "../hooks/useOptionsStyles";
import { analyticsPermission } from "@/features/analytics";
export default function GeneralSection(): React.ReactElement export default function GeneralSection(): React.ReactElement
{ {
@@ -14,8 +15,23 @@ export default function GeneralSection(): React.ReactElement
const [listLocation, setListLocation] = useSettings("listLocation"); const [listLocation, setListLocation] = useSettings("listLocation");
const [contextAction, setContextAction] = useSettings("contextAction"); const [contextAction, setContextAction] = useSettings("contextAction");
const [allowAnalytics, setAllowAnalytics] = useState<boolean | null>(null);
const cls = useOptionsStyles(); const cls = useOptionsStyles();
useEffect(() =>
{
analyticsPermission.getValue().then(setAllowAnalytics);
return analyticsPermission.watch(setAllowAnalytics);
}, []);
const updateAnalytics = (enabled: boolean): void =>
{
setAllowAnalytics(null);
analyticsPermission.setValue(enabled)
.catch(() => setAllowAnalytics(!enabled));
};
const openShortcutsPage = (): Promise<any> => const openShortcutsPage = (): Promise<any> =>
browser.tabs.create({ browser.tabs.create({
url: "chrome://extensions/shortcuts", url: "chrome://extensions/shortcuts",
@@ -60,6 +76,12 @@ export default function GeneralSection(): React.ReactElement
label={ i18n.t("options_page.general.options.unload_tabs") } label={ i18n.t("options_page.general.options.unload_tabs") }
checked={ dismissOnLoad ?? false } checked={ dismissOnLoad ?? false }
onChange={ (_, e) => setDismissOnLoad(e.checked as boolean) } /> onChange={ (_, e) => setDismissOnLoad(e.checked as boolean) } />
<Checkbox
label={ i18n.t("options_page.general.options.allow_analytics") }
checked={ allowAnalytics ?? true }
disabled={ allowAnalytics === null }
onChange={ (_, e) => updateAnalytics(e.checked as boolean) } />
</section> </section>
<Field label={ i18n.t("options_page.general.options.list_locations.title") }> <Field label={ i18n.t("options_page.general.options.list_locations.title") }>
+53 -3
View File
@@ -1,18 +1,20 @@
import { useDialog } from "@/contexts/DialogProvider"; import { useDialog } from "@/contexts/DialogProvider";
import { cloudDisabled, setCloudStorage } from "@/features/collectionStorage"; import { clearGraphicsStorage, cloudDisabled, setCloudStorage, thumbnailCaptureEnabled } from "@/features/collectionStorage";
import { useDangerStyles } from "@/hooks/useDangerStyles"; import { useDangerStyles } from "@/hooks/useDangerStyles";
import useStorageInfo from "@/hooks/useStorageInfo"; import useStorageInfo from "@/hooks/useStorageInfo";
import { Button, Field, MessageBar, MessageBarBody, MessageBarTitle, ProgressBar } from "@fluentui/react-components"; import { Button, Field, InfoLabel, LabelProps, MessageBar, MessageBarBody, MessageBarTitle, ProgressBar, Switch } from "@fluentui/react-components";
import { ArrowDownload20Regular, ArrowUpload20Regular } from "@fluentui/react-icons"; import { ArrowDownload20Regular, ArrowUpload20Regular } from "@fluentui/react-icons";
import { useOptionsStyles } from "../hooks/useOptionsStyles"; import { useOptionsStyles } from "../hooks/useOptionsStyles";
import exportData from "../utils/exportData"; import exportData from "../utils/exportData";
import importData from "../utils/importData"; import importData from "../utils/importData";
import { Unwatch } from "wxt/storage";
export default function StorageSection(): React.ReactElement export default function StorageSection(): React.ReactElement
{ {
const { bytesInUse, storageQuota, usedStorageRatio } = useStorageInfo(); const { bytesInUse, storageQuota, usedStorageRatio } = useStorageInfo();
const [importResult, setImportResult] = useState<boolean | null>(null); const [importResult, setImportResult] = useState<boolean | null>(null);
const [isCloudDisabled, setCloudDisabled] = useState<boolean>(null!); const [isCloudDisabled, setCloudDisabled] = useState<boolean>(null!);
const [isThumbnailCaptureEnabled, setThumbnailCaptureEnabled] = useState<boolean | null>(null);
const dialog = useDialog(); const dialog = useDialog();
const cls = useOptionsStyles(); const cls = useOptionsStyles();
@@ -20,10 +22,35 @@ export default function StorageSection(): React.ReactElement
useEffect(() => useEffect(() =>
{ {
thumbnailCaptureEnabled.getValue().then(setThumbnailCaptureEnabled);
cloudDisabled.getValue().then(setCloudDisabled); cloudDisabled.getValue().then(setCloudDisabled);
return cloudDisabled.watch(setCloudDisabled);
const unwatchCloud: Unwatch = cloudDisabled.watch(setCloudDisabled);
const unwatchThumbnails: Unwatch = thumbnailCaptureEnabled.watch(setThumbnailCaptureEnabled);
return () =>
{
unwatchCloud();
unwatchThumbnails();
};
}, []); }, []);
const handleSetThumbnailCapture = (enabled: boolean): void =>
{
setThumbnailCaptureEnabled(null);
thumbnailCaptureEnabled.setValue(enabled)
.catch(() => setThumbnailCaptureEnabled(!enabled));
};
const handleClearThumbnails = (): void =>
dialog.pushPrompt({
title: i18n.t("options_page.storage.clear_thumbnails.title"),
content: i18n.t("options_page.storage.clear_thumbnails.prompt"),
confirmText: i18n.t("common.actions.delete"),
destructive: true,
onConfirm: () => clearGraphicsStorage()
});
const handleImport = (): void => const handleImport = (): void =>
dialog.pushPrompt({ dialog.pushPrompt({
title: i18n.t("options_page.storage.import_prompt.title"), title: i18n.t("options_page.storage.import_prompt.title"),
@@ -51,6 +78,29 @@ export default function StorageSection(): React.ReactElement
return ( return (
<> <>
<div className={ cls.group }>
<Switch
checked={ isThumbnailCaptureEnabled ?? true }
disabled={ isThumbnailCaptureEnabled === null }
onChange={ (_, e) => handleSetThumbnailCapture(e.checked as boolean) }
label={ {
children: (_: any, props: LabelProps) =>
<InfoLabel
{ ...props }
label={ i18n.t("options_page.storage.thumbnail_capture") }
info={
<p>
{ i18n.t("options_page.storage.thumbnail_capture_notice1") }<br /><br />
{ i18n.t("options_page.storage.thumbnail_capture_notice2") }
</p>
} />
} } />
<Button onClick={ handleClearThumbnails } className={ dangerCls.buttonSubtle } appearance="subtle">
{ i18n.t("options_page.storage.clear_thumbnails.action") }
</Button>
</div>
{ isCloudDisabled === false && { isCloudDisabled === false &&
<Field <Field
label={ i18n.t("options_page.storage.capacity.title") } label={ i18n.t("options_page.storage.capacity.title") }
+2 -1
View File
@@ -1,5 +1,6 @@
import App from "@/App.tsx"; import App from "@/App.tsx";
import "@/assets/global.css"; import "@/assets/global.css";
import { trackPage } from "@/features/analytics";
import { Tab, TabList } from "@fluentui/react-components"; import { Tab, TabList } from "@fluentui/react-components";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { useOptionsStyles } from "./hooks/useOptionsStyles.ts"; import { useOptionsStyles } from "./hooks/useOptionsStyles.ts";
@@ -14,7 +15,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
</App> </App>
); );
analytics.page("options_page"); trackPage("options_page");
function OptionsPage(): React.ReactElement function OptionsPage(): React.ReactElement
{ {
+8 -2
View File
@@ -1,5 +1,6 @@
import App from "@/App.tsx"; import App from "@/App.tsx";
import "@/assets/global.css"; import "@/assets/global.css";
import { trackPage } from "@/features/analytics";
import { useLocalMigration } from "@/features/migration"; import { useLocalMigration } from "@/features/migration";
import useWelcomeDialog from "@/features/v3welcome/hooks/useWelcomeDialog"; import useWelcomeDialog from "@/features/v3welcome/hooks/useWelcomeDialog";
import { Divider, makeStyles } from "@fluentui/react-components"; import { Divider, makeStyles } from "@fluentui/react-components";
@@ -7,6 +8,8 @@ import ReactDOM from "react-dom/client";
import CollectionsProvider from "./contexts/CollectionsProvider"; import CollectionsProvider from "./contexts/CollectionsProvider";
import CollectionListView from "./layouts/collections/CollectionListView"; import CollectionListView from "./layouts/collections/CollectionListView";
import Header from "./layouts/header/Header"; import Header from "./layouts/header/Header";
import { useSettingsReviewDialog } from "@/features/settingsReview";
import useDialogTrain from "@/hooks/useDialogTrain";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<App> <App>
@@ -15,14 +18,17 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
); );
document.title = i18n.t("manifest.name"); document.title = i18n.t("manifest.name");
analytics.page("collection_list"); trackPage("collection_list");
function MainPage(): React.ReactElement function MainPage(): React.ReactElement
{ {
const cls = useStyles(); const cls = useStyles();
useLocalMigration(); useLocalMigration();
useWelcomeDialog(); useDialogTrain(
useWelcomeDialog,
useSettingsReviewDialog
);
return ( return (
<CollectionsProvider> <CollectionsProvider>
@@ -1,10 +1,21 @@
import { CollectionItem, TabItem } from "@/models/CollectionModels"; import { CollectionItem, TabItem } from "@/models/CollectionModels";
import sendNotification from "@/utils/sendNotification"; import sendNotification from "@/utils/sendNotification";
import { Bookmarks } from "wxt/browser"; import { Bookmarks, Permissions } from "wxt/browser";
import { getCollectionTitle } from "./getCollectionTitle"; import { getCollectionTitle } from "./getCollectionTitle";
import { track } from "@/features/analytics";
export default async function exportCollectionToBookmarks(collection: CollectionItem) export default async function exportCollectionToBookmarks(collection: CollectionItem)
{ {
const permissions: Permissions.AnyPermissions = await browser.permissions.getAll();
if (!permissions.permissions?.includes("bookmarks"))
{
const granted: boolean = await browser.permissions.request({ permissions: ["bookmarks"] });
if (!granted)
return;
}
const rootFolder: Bookmarks.BookmarkTreeNode = await browser.bookmarks.create({ const rootFolder: Bookmarks.BookmarkTreeNode = await browser.bookmarks.create({
title: getCollectionTitle(collection) title: getCollectionTitle(collection)
}); });
@@ -31,6 +42,8 @@ export default async function exportCollectionToBookmarks(collection: Collection
} }
} }
track("bookmarks_saved");
await sendNotification({ await sendNotification({
title: i18n.t("notifications.bookmark_saved.title"), title: i18n.t("notifications.bookmark_saved.title"),
message: i18n.t("notifications.bookmark_saved.message"), message: i18n.t("notifications.bookmark_saved.message"),
+1 -1
View File
@@ -50,7 +50,7 @@ export default defineConfig([
"@stylistic/semi": ["error", "always"], "@stylistic/semi": ["error", "always"],
"@stylistic/block-spacing": ["warn", "always"], "@stylistic/block-spacing": ["warn", "always"],
"@stylistic/arrow-spacing": ["warn", { before: true, after: true }], "@stylistic/arrow-spacing": ["warn", { before: true, after: true }],
"@stylistic/indent": ["warn", "tab"], "@stylistic/indent": ["warn", "tab", { assignmentOperator: "off" }],
"@stylistic/quotes": ["error", "double"], "@stylistic/quotes": ["error", "double"],
"@stylistic/comma-spacing": ["warn"], "@stylistic/comma-spacing": ["warn"],
"@stylistic/comma-dangle": ["warn", "never"], "@stylistic/comma-dangle": ["warn", "never"],
+55 -3
View File
@@ -1,3 +1,55 @@
export { default as userPropertiesStorage } from "./utils/userPropertiesStorage"; import { analytics } from "./utils/analytics";
export { default as trackError } from "./utils/trackError"; import analyticsPermission from "./utils/analyticsPermission";
export { default as track } from "./utils/track"; import { getUserProperties, userId } from "./utils/getUserProperties";
export { analyticsPermission };
export async function track(eventName: string, eventProperties?: Record<string, string>): Promise<void>
{
try
{
if (!await analyticsPermission.getValue())
return;
analytics.track(eventName, eventProperties);
}
catch (ex)
{
console.error("Failed to send analytics event", ex);
}
}
export async function trackError(name: string, error: Error): Promise<void>
{
try
{
if (!await analyticsPermission.getValue())
return;
analytics.track(name, {
name: error.name,
message: error.message,
stack: error.stack ?? "no_stack"
});
}
catch (ex)
{
console.error("Failed to send error report", ex);
}
}
export async function trackPage(pageName: string): Promise<void>
{
try
{
if (!await analyticsPermission.getValue())
return;
analytics.identify(await userId.getValue() as string, await getUserProperties());
analytics.page(pageName);
}
catch (ex)
{
console.error("Failed to send page view", ex);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { createAnalytics } from "@wxt-dev/analytics";
import { googleAnalytics4 } from "@wxt-dev/analytics/providers/google-analytics-4";
export const analytics = createAnalytics({
providers:
[
googleAnalytics4({
apiSecret: import.meta.env.WXT_GA4_API_SECRET,
measurementId: import.meta.env.WXT_GA4_MEASUREMENT_ID
})
]
});
@@ -0,0 +1,77 @@
import { Unwatch, WatchCallback, WxtStorageItem } from "wxt/storage";
import { analytics } from "./analytics";
import { Permissions } from "wxt/browser";
const analyticsPermission: Pick<WxtStorageItem<boolean, Record<string, unknown>>, "getValue" | "watch" | "setValue"> =
{
getValue: async (): Promise<boolean> =>
{
const isGranted: boolean = import.meta.env.FIREFOX
? await browser.permissions.contains({
// @ts-expect-error Introduced in Firefox 139
data_collection: ["technicalAndInteraction"]
})
: await allowAnalytics.getValue();
analytics.setEnabled(isGranted);
return isGranted;
},
setValue: async (value: boolean) =>
{
if (!import.meta.env.FIREFOX)
{
await allowAnalytics.setValue(value);
return;
}
let result: boolean = false;
if (value)
result = await browser.permissions.request({
// @ts-expect-error Introduced in Firefox 139
data_collection: ["technicalAndInteraction"]
});
else
result = await browser.permissions.remove({
// @ts-expect-error Introduced in Firefox 139
data_collection: ["technicalAndInteraction"]
});
if (!result)
throw new Error("Permission request was denied");
},
watch: (cb: WatchCallback<boolean>): Unwatch =>
{
if (!import.meta.env.FIREFOX)
return allowAnalytics.watch(cb);
const listener = async (permissions: Permissions.Permissions): Promise<void> =>
{
// @ts-expect-error Introduced in Firefox 139
if (permissions.data_collection?.includes("technicalAndInteraction"))
{
// @ts-expect-error Introduced in Firefox 139
const isGranted: boolean = await browser.permissions.contains({ data_collection: ["technicalAndInteraction"] });
cb(isGranted, !isGranted);
}
};
browser.permissions.onAdded.addListener(listener);
browser.permissions.onRemoved.addListener(listener);
return (): void =>
{
browser.permissions.onAdded.removeListener(listener);
browser.permissions.onRemoved.removeListener(listener);
};
}
};
export default analyticsPermission;
const allowAnalytics = storage.defineItem<boolean>("local:analytics", {
fallback: true
});
@@ -0,0 +1,30 @@
import { cloudDisabled, collectionCount } from "@/features/collectionStorage";
import { settings } from "@/utils/settings";
export async function getUserProperties(): Promise<UserProperties>
{
const properties: UserProperties =
{
cloud_used: await cloudDisabled.getValue() ? "-1" : (await browser.storage.sync.getBytesInUse() / 102400).toString(),
collection_count: (await collectionCount.getValue()).toString()
};
for (const key of Object.keys(settings))
{
const value = await settings[key as keyof typeof settings].getValue();
properties[`option_${key}`] = value.valueOf().toString();
}
return properties;
}
export const userId = storage.defineItem("local:userId", {
init: () => crypto.randomUUID()
});
export type UserProperties =
{
collection_count: string;
cloud_used: string;
[key: `option_${string}`]: string;
};
-11
View File
@@ -1,11 +0,0 @@
export default function track(eventName: string, eventProperties?: Record<string, string>): void
{
try
{
analytics.track(eventName, eventProperties);
}
catch (ex)
{
console.error("Failed to send analytics event", ex);
}
}
-15
View File
@@ -1,15 +0,0 @@
export default function trackError(name: string, error: Error): void
{
try
{
analytics.track(name, {
name: error.name,
message: error.message,
stack: error.stack ?? "no_stack"
});
}
catch (ex)
{
console.error("Failed to send error report", ex);
}
}
@@ -1,35 +0,0 @@
import { cloudDisabled, collectionCount } from "@/features/collectionStorage";
import { settings } from "@/utils/settings";
import { WxtStorageItem } from "wxt/storage";
// @ts-expect-error we don't need to implement a full storage item
const userPropertiesStorage: WxtStorageItem<Record<string, string>, any> =
{
getValue: async (): Promise<UserProperties> =>
{
console.log("userPropertiesStorage.getValue");
const properties: UserProperties =
{
cloud_used: await cloudDisabled.getValue() ? "-1" : (await browser.storage.sync.getBytesInUse() / 102400).toString(),
collection_count: (await collectionCount.getValue()).toString()
};
for (const key of Object.keys(settings))
{
const value = await settings[key as keyof typeof settings].getValue();
properties[`option_${key}`] = value.valueOf().toString();
}
return properties;
},
setValue: async () => { }
};
export default userPropertiesStorage;
export type UserProperties =
{
collection_count: string;
cloud_used: string;
[key: `option_${string}`]: string;
};
+3
View File
@@ -5,6 +5,9 @@ export { default as getCollections } from "./utils/getCollections";
export { default as resoveConflict } from "./utils/resolveConflict"; export { default as resoveConflict } from "./utils/resolveConflict";
export { default as saveCollections } from "./utils/saveCollections"; export { default as saveCollections } from "./utils/saveCollections";
export { default as setCloudStorage } from "./utils/setCloudStorage"; export { default as setCloudStorage } from "./utils/setCloudStorage";
export { default as clearGraphicsStorage } from "./utils/clearGraphics";
export { default as thumbnailCaptureEnabled } from "./utils/thumbnailCaptureEnabled";
export const collectionCount = collectionStorage.count; export const collectionCount = collectionStorage.count;
export const graphics = collectionStorage.graphics; export const graphics = collectionStorage.graphics;
@@ -0,0 +1,6 @@
import { collectionStorage } from "./collectionStorage";
export default async function clearGraphicsStorage(): Promise<void>
{
await collectionStorage.graphics.removeValue();
}
@@ -0,0 +1,50 @@
import { Permissions } from "wxt/browser";
import { Unwatch, WatchCallback, WxtStorageItem } from "wxt/storage";
const thumbnailCaptureEnabled: Pick<WxtStorageItem<boolean, Record<string, unknown>>, "getValue" | "watch" | "setValue"> =
{
getValue: async (): Promise<boolean> =>
await browser.permissions.contains({ permissions: ["scripting"], origins: ["<all_urls>"] }),
watch: (cb: WatchCallback<boolean>): Unwatch =>
{
const listener = async (permissions: Permissions.Permissions): Promise<void> =>
{
if (permissions.permissions?.includes("scripting") || permissions.origins?.includes("<all_urls>"))
{
const isGranted: boolean = await browser.permissions.contains({ permissions: ["scripting"], origins: ["<all_urls>"] });
console.log("thumbnailCaptureEnabled changed", isGranted);
cb(isGranted, !isGranted);
}
};
browser.permissions.onAdded.addListener(listener);
browser.permissions.onRemoved.addListener(listener);
return (): void =>
{
browser.permissions.onAdded.removeListener(listener);
browser.permissions.onRemoved.removeListener(listener);
};
},
setValue: async (value: boolean): Promise<void> =>
{
let result: boolean = false;
if (value)
result = await browser.permissions.request({ permissions: ["scripting"], origins: ["<all_urls>"] });
else
{
result = await browser.permissions.remove({ origins: ["<all_urls>"] });
if (import.meta.env.DEV)
await browser.permissions.request({ origins: ["http://localhost/*"] });
}
if (!result)
throw new Error("Permission request was denied");
}
};
export default thumbnailCaptureEnabled;
@@ -0,0 +1,132 @@
import { githubLinks } from "@/data/links";
import { analyticsPermission } from "@/features/analytics";
import extLink from "@/utils/extLink";
import * as fui from "@fluentui/react-components";
import { settingsForReview } from "../utils/showSettingsReviewDialog";
import { reviewSettings } from "../utils/setSettingsReviewNeeded";
import { Unwatch } from "wxt/storage";
import { thumbnailCaptureEnabled } from "@/features/collectionStorage";
export default function SettingsReviewDialog(): React.ReactElement
{
const [allowAnalytics, setAllowAnalytics] = useState<boolean | null>(null);
const [captureThumbnails, setCaptureThumbnails] = useState<boolean | null>(null);
const [needsReview, setNeedsReview] = useState<string[]>([]);
const cls = useStyles();
useEffect(() =>
{
analyticsPermission.getValue().then(setAllowAnalytics);
thumbnailCaptureEnabled.getValue().then(setCaptureThumbnails);
settingsForReview.getValue().then(setNeedsReview);
const unwatchAnalytics: Unwatch = analyticsPermission.watch(setAllowAnalytics);
const unwatchThumbnails: Unwatch = thumbnailCaptureEnabled.watch(setCaptureThumbnails);
return () =>
{
unwatchAnalytics();
unwatchThumbnails();
};
}, []);
const updateAnalytics = (enabled: boolean): void =>
{
setAllowAnalytics(null);
analyticsPermission.setValue(enabled)
.catch(() => setAllowAnalytics(!enabled));
};
const updateThumbnails = (enabled: boolean): void =>
{
setCaptureThumbnails(null);
thumbnailCaptureEnabled.setValue(enabled)
.catch(() => setCaptureThumbnails(!enabled));
};
return (
<fui.DialogSurface>
<fui.DialogBody>
<fui.DialogTitle>{ i18n.t("features.settingsReview.title") }</fui.DialogTitle>
<fui.DialogContent className={ cls.content }>
{ needsReview.includes(reviewSettings.THUMBNAILS) &&
<div className={ cls.section }>
<fui.Switch
label={ i18n.t("options_page.storage.thumbnail_capture") }
checked={ captureThumbnails ?? true }
disabled={ captureThumbnails === null }
onChange={ (_, e) => updateThumbnails(e.checked as boolean) } />
<fui.MessageBar layout="multiline">
<fui.MessageBarBody className={ cls.msgBarBody }>
<fui.MessageBarTitle>
{ i18n.t("options_page.storage.thumbnail_capture_notice1") }
</fui.MessageBarTitle>
<fui.Text as="p">
{ i18n.t("options_page.storage.thumbnail_capture_notice2") }
</fui.Text>
</fui.MessageBarBody>
</fui.MessageBar>
</div>
}
{ needsReview.includes(reviewSettings.ANALYTICS) &&
<div className={ cls.section }>
<fui.Switch
label={ i18n.t("options_page.general.options.allow_analytics") }
checked={ allowAnalytics ?? true }
disabled={ allowAnalytics === null }
onChange={ (_, e) => updateAnalytics(e.checked as boolean) } />
<fui.MessageBar layout="multiline">
<fui.MessageBarBody className={ cls.msgBarBody }>
<fui.MessageBarTitle>
{ i18n.t("features.settingsReview.analytics.title") }
</fui.MessageBarTitle>
<fui.Text as="p">
{ i18n.t("features.settingsReview.analytics.p1") }
</fui.Text>
<fui.Text as="p" weight="semibold">
{ i18n.t("features.settingsReview.analytics.p2") }
</fui.Text>
<fui.Text as="p">
{ i18n.t("features.settingsReview.analytics.p3_text") } <fui.Link { ...extLink(githubLinks.privacy) }>{ i18n.t("features.settingsReview.analytics.p3_link") }</fui.Link>.
</fui.Text>
</fui.MessageBarBody>
</fui.MessageBar>
</div>
}
</fui.DialogContent>
<fui.DialogActions>
<fui.Button onClick={ () => browser.runtime.openOptionsPage() }>
{ i18n.t("features.settingsReview.action") }
</fui.Button>
<fui.DialogTrigger>
<fui.Button appearance="primary">{ i18n.t("common.actions.save") }</fui.Button>
</fui.DialogTrigger>
</fui.DialogActions>
</fui.DialogBody>
</fui.DialogSurface>
);
}
const useStyles = fui.makeStyles({
content:
{
display: "flex",
flexFlow: "column",
gap: fui.tokens.spacingVerticalL
},
section:
{
display: "flex",
flexFlow: "column",
gap: fui.tokens.spacingVerticalXS
},
msgBarBody:
{
display: "flex",
flexFlow: "column",
gap: fui.tokens.spacingVerticalXS,
marginBottom: fui.tokens.spacingVerticalXS
}
});
@@ -0,0 +1,25 @@
import { DialogContextType } from "@/contexts/DialogProvider";
import SettingsReviewDialog from "../components/SettingsReviewDialog";
import { settingsForReview } from "../utils/showSettingsReviewDialog";
export default function useSettingsReviewDialog(dialog: DialogContextType): Promise<void>
{
return new Promise<void>(res =>
{
settingsForReview.getValue().then(needsReview =>
{
if (needsReview.length > 0)
dialog.pushCustom(
<SettingsReviewDialog />,
undefined,
() =>
{
settingsForReview.removeValue();
res();
}
);
else
res();
});
});
}
+1
View File
@@ -0,0 +1 @@
export { default as useSettingsReviewDialog } from "./hooks/useSettingsReviewDialog";
+1
View File
@@ -0,0 +1 @@
export { default as setSettingsReviewNeeded } from "./setSettingsReviewNeeded";
@@ -0,0 +1,66 @@
import { analyticsPermission } from "@/features/analytics";
import { Runtime } from "wxt/browser";
import { settingsForReview } from "./showSettingsReviewDialog";
export default async function setSettingsReviewNeeded(installReason: Runtime.OnInstalledReason, previousVersion?: string): Promise<void>
{
const needsReview: string[] = await settingsForReview.getValue();
if (!needsReview.includes(reviewSettings.ANALYTICS) && await checkAnalyticsReviewNeeded(installReason, previousVersion))
needsReview.push(reviewSettings.ANALYTICS);
if (!needsReview.includes(reviewSettings.THUMBNAILS) && await checkThumbnailsReviewNeeded(installReason, previousVersion))
needsReview.push(reviewSettings.THUMBNAILS);
console.log("Settings needing review:", needsReview);
// Add more settings here as needed
if (needsReview.length > 0)
await settingsForReview.setValue(needsReview);
}
export const reviewSettings =
{
ANALYTICS: "analytics",
THUMBNAILS: "thumbnails"
};
async function checkAnalyticsReviewNeeded(installReason: Runtime.OnInstalledReason, previousVersion?: string): Promise<boolean>
{
if (installReason === "install")
return !await analyticsPermission.getValue();
if (installReason === "update")
{
const [major, minor, patch] = (previousVersion ?? "0.0.0").split(".").map(parseInt);
const cumulative: number = major * 10000 + minor * 100 + patch;
if (cumulative < 30100) // < 3.1.0
return true;
}
if (import.meta.env.DEV)
return true;
return false;
}
async function checkThumbnailsReviewNeeded(installReason: Runtime.OnInstalledReason, previousVersion?: string): Promise<boolean>
{
if (installReason === "install")
return true;
if (installReason === "update")
{
const [major, minor, patch] = (previousVersion ?? "0.0.0").split(".").map(parseInt);
const cumulative: number = major * 10000 + minor * 100 + patch;
if (cumulative < 30100) // < 3.1.0
return true;
}
if (import.meta.env.DEV)
return true;
return false;
}
@@ -0,0 +1,6 @@
export const settingsForReview = storage.defineItem<string[]>(
"local:settingsForReview",
{
fallback: []
}
);
+15 -7
View File
@@ -1,17 +1,25 @@
import { useDialog } from "@/contexts/DialogProvider"; import { DialogContextType } from "@/contexts/DialogProvider";
import WelcomeDialog from "../components/WelcomeDialog"; import WelcomeDialog from "../components/WelcomeDialog";
import { showWelcomeDialog } from "../utils/showWelcomeDialog"; import { showWelcomeDialog } from "../utils/showWelcomeDialog";
export default function useWelcomeDialog(): void export default function useWelcomeDialog(dialog: DialogContextType): Promise<void>
{ {
const dialog = useDialog(); return new Promise<void>(res =>
useEffect(() =>
{ {
showWelcomeDialog.getValue().then(showWelcome => showWelcomeDialog.getValue().then(showWelcome =>
{ {
if (showWelcome || import.meta.env.DEV) if (showWelcome || import.meta.env.DEV)
dialog.pushCustom(<WelcomeDialog />, undefined, () => showWelcomeDialog.removeValue()); dialog.pushCustom(
<WelcomeDialog />,
undefined,
() =>
{
showWelcomeDialog.removeValue();
res();
}
);
else
res();
}); });
}, []); });
} }
+18
View File
@@ -0,0 +1,18 @@
import { DialogContextType, useDialog } from "@/contexts/DialogProvider";
export default function useDialogTrain(...dialogs: ((dialog: DialogContextType) => Promise<void>)[]): void
{
const dialog = useDialog();
useEffect(() =>
{
(async () =>
{
for (const item of dialogs)
{
await item(dialog);
await new Promise(res => setTimeout(res, 250));
}
})();
}, []);
}
+18
View File
@@ -36,6 +36,15 @@ features:
text3: "Visit our dev blog to learn more about this update and all of its features!" text3: "Visit our dev blog to learn more about this update and all of its features!"
actions: actions:
visit_blog: "Read dev blog" visit_blog: "Read dev blog"
settingsReview:
title: "Review your settings"
action: "All settings"
analytics:
title: "These statistics will help us improve the extension"
p1: "We only collect usage statistics (number of collections, used features, etc.)"
p2: "We do not collect any of your data!"
p3_text: "See the full list of what we collect"
p3_link: "here"
notifications: notifications:
tabs_saved: tabs_saved:
@@ -74,6 +83,7 @@ options_page:
show_badge: "Show counter badge" show_badge: "Show counter badge"
show_notification: "Show notification when saving tabs using context menu" show_notification: "Show notification when saving tabs using context menu"
unload_tabs: "Do not load tabs after opening" unload_tabs: "Do not load tabs after opening"
allow_analytics: "Allow collection of anonymous statistics"
list_locations: list_locations:
title: "Open collection list in:" title: "Open collection list in:"
options: options:
@@ -121,6 +131,13 @@ options_page:
disable_prompt: disable_prompt:
text: "This action will disable collection synchronization between your devices. Extension's settings will still be synchronized." text: "This action will disable collection synchronization between your devices. Extension's settings will still be synchronized."
action: "Disable and reload the extension" action: "Disable and reload the extension"
thumbnail_capture: "Capture thumbnails and icons for saved tabs"
thumbnail_capture_notice1: "Requires permission to access content on visited websites"
thumbnail_capture_notice2: "Disabling this feature may improve performance on large collections"
clear_thumbnails:
action: "Clear saved thumbnails"
title: "Delete all saved thumbnails?"
prompt: "This action will remove all saved thumbnails, previews and icons for your saved tabs. This action cannot be undone."
about: about:
title: "About" title: "About"
developed_by: "Developed by Eugene Fox" developed_by: "Developed by Eugene Fox"
@@ -133,6 +150,7 @@ options_page:
website: "My website" website: "My website"
source: "Source code" source: "Source code"
changelog: "Changelog" changelog: "Changelog"
privacy: "Privacy policy"
collections: collections:
empty: "This collection is empty" empty: "This collection is empty"
+18
View File
@@ -36,6 +36,15 @@ features:
text3: "¡Visita nuestro blog de desarrollo para aprender más sobre esta actualización y todas sus características!" text3: "¡Visita nuestro blog de desarrollo para aprender más sobre esta actualización y todas sus características!"
actions: actions:
visit_blog: "Leer el blog de desarrollo" visit_blog: "Leer el blog de desarrollo"
settingsReview:
title: "Revisa tus ajustes"
action: "Todos los ajustes"
analytics:
title: "Estas estadísticas nos ayudarán a mejorar la extensión"
p1: "Solo recopilamos estadísticas de uso (número de colecciones, funciones utilizadas, etc.)"
p2: "¡No recopilamos ninguno de tus datos!"
p3_text: "Ver la lista completa de lo que recopilamos"
p3_link: "aquí"
notifications: notifications:
tabs_saved: tabs_saved:
@@ -74,6 +83,7 @@ options_page:
show_badge: "Mostrar insignia de contador" show_badge: "Mostrar insignia de contador"
show_notification: "Mostrar notificación al guardar pestañas usando el menú contextual" show_notification: "Mostrar notificación al guardar pestañas usando el menú contextual"
unload_tabs: "No cargar pestañas después de abrir" unload_tabs: "No cargar pestañas después de abrir"
allow_analytics: "Permitir la recopilación de estadísticas anónimas"
list_locations: list_locations:
title: "Abrir lista de colecciones en:" title: "Abrir lista de colecciones en:"
options: options:
@@ -121,6 +131,13 @@ options_page:
disable_prompt: disable_prompt:
text: "Esta acción deshabilitará la sincronización de colecciones entre tus dispositivos. La configuración de la extensión seguirá sincronizándose." text: "Esta acción deshabilitará la sincronización de colecciones entre tus dispositivos. La configuración de la extensión seguirá sincronizándose."
action: "Deshabilitar y recargar la extensión" action: "Deshabilitar y recargar la extensión"
thumbnail_capture: "Capturar miniaturas e íconos para las pestañas guardadas"
thumbnail_capture_notice1: "Requiere permiso para acceder al contenido de los sitios web visitados"
thumbnail_capture_notice2: "Deshabilitar esta función puede mejorar el rendimiento en colecciones grandes"
clear_thumbnails:
action: "Eliminar miniaturas guardadas"
title: "¿Eliminar todas las miniaturas guardadas?"
prompt: "Esta acción eliminará todas las miniaturas, vistas previas e íconos guardados para tus pestañas guardadas. Esta acción no se puede deshacer."
about: about:
title: "Acerca de" title: "Acerca de"
developed_by: "Desarrollado por Eugene Fox" developed_by: "Desarrollado por Eugene Fox"
@@ -133,6 +150,7 @@ options_page:
website: "Mi sitio web" website: "Mi sitio web"
source: "Código fuente" source: "Código fuente"
changelog: "Registro de cambios" changelog: "Registro de cambios"
privacy: "Política de privacidad"
collections: collections:
empty: "Esta colección está vacía" empty: "Esta colección está vacía"
+18
View File
@@ -36,6 +36,15 @@ features:
text3: "Visita il nostro blog per saperne di più su questo aggiornamento e tutte le sue funzionalità!" text3: "Visita il nostro blog per saperne di più su questo aggiornamento e tutte le sue funzionalità!"
actions: actions:
visit_blog: "Leggi il blog degli sviluppatori" visit_blog: "Leggi il blog degli sviluppatori"
settingsReview:
title: "Rivedi le tue impostazioni"
action: "Tutte le impostazioni"
analytics:
title: "Queste statistiche ci aiuteranno a migliorare l'estensione"
p1: "Raccogliamo solo statistiche di utilizzo (numero di collezioni, funzionalità utilizzate, ecc.)"
p2: "Non raccogliamo nessuno dei tuoi dati!"
p3_text: "Vedi l'elenco completo di ciò che raccogliamo"
p3_link: "qui"
notifications: notifications:
tabs_saved: tabs_saved:
@@ -74,6 +83,7 @@ options_page:
show_badge: "Mostra il badge del contatore" show_badge: "Mostra il badge del contatore"
show_notification: "Mostra notifica quando salvi le schede usando il menu contestuale" show_notification: "Mostra notifica quando salvi le schede usando il menu contestuale"
unload_tabs: "Non caricare le schede dopo l'apertura" unload_tabs: "Non caricare le schede dopo l'apertura"
allow_analytics: "Consenti la raccolta di statistiche anonime"
list_locations: list_locations:
title: "Apri elenco delle collezioni in:" title: "Apri elenco delle collezioni in:"
options: options:
@@ -121,6 +131,13 @@ options_page:
disable_prompt: disable_prompt:
text: "Questa azione disabiliterà la sincronizzazione delle collezioni tra i tuoi dispositivi. Le impostazioni dell'estensione saranno comunque sincronizzate." text: "Questa azione disabiliterà la sincronizzazione delle collezioni tra i tuoi dispositivi. Le impostazioni dell'estensione saranno comunque sincronizzate."
action: "Disabilita e ricarica l'estensione" action: "Disabilita e ricarica l'estensione"
thumbnail_capture: "Cattura miniature e icone per le schede salvate"
thumbnail_capture_notice1: "Richiede il permesso di accedere ai contenuti dei siti web visitati"
thumbnail_capture_notice2: "Disabilitare questa funzione può migliorare le prestazioni su collezioni di grandi dimensioni"
clear_thumbnails:
action: "Elimina miniature salvate"
title: "Eliminare tutte le miniature salvate?"
prompt: "Questa azione rimuoverà tutte le miniature, anteprime e icone salvate per le tue schede salvate. Questa azione non può essere annullata."
about: about:
title: "Informazioni" title: "Informazioni"
developed_by: "Sviluppato da Eugene Fox" developed_by: "Sviluppato da Eugene Fox"
@@ -133,6 +150,7 @@ options_page:
website: "Il mio sito web" website: "Il mio sito web"
source: "Codice sorgente" source: "Codice sorgente"
changelog: "Registro delle modifiche" changelog: "Registro delle modifiche"
privacy: "Politica sulla riservatezza"
collections: collections:
empty: "Questa collezione è vuota" empty: "Questa collezione è vuota"
+18
View File
@@ -36,6 +36,15 @@ features:
text3: "Odwiedź blog dewelopera (tylko w języku angielskim), aby dowiedzieć się więcej o tej aktualizacji i wszystkich jej funkcjach!" text3: "Odwiedź blog dewelopera (tylko w języku angielskim), aby dowiedzieć się więcej o tej aktualizacji i wszystkich jej funkcjach!"
actions: actions:
visit_blog: "Czytaj blog" visit_blog: "Czytaj blog"
settingsReview:
title: "Sprawdź ustawienia"
action: "Wszystkie ustawienia"
analytics:
title: "Ta statystyka pozwoli ulepszać rozszerzenie"
p1: "Zbieramy tylko statystyki użycia (liczba kolekcji, używane funkcje itp.)"
p2: "Nie zbieramy twoich danych osobowych!"
p3_text: "Pełną listę zbieranych danych można zobaczyć"
p3_link: "tutaj"
notifications: notifications:
tabs_saved: tabs_saved:
@@ -74,6 +83,7 @@ options_page:
show_badge: "Pokaż licznik" show_badge: "Pokaż licznik"
show_notification: "Pokaż powiadomienie przy zapisywaniu przez menu kontekstowe" show_notification: "Pokaż powiadomienie przy zapisywaniu przez menu kontekstowe"
unload_tabs: "Nie ładuj kart po otwarciu" unload_tabs: "Nie ładuj kart po otwarciu"
allow_analytics: "Zezwól na zbieranie anonimowej statystyki"
list_locations: list_locations:
title: "Otwieraj listę kolekcji w:" title: "Otwieraj listę kolekcji w:"
options: options:
@@ -121,6 +131,13 @@ options_page:
disable_prompt: disable_prompt:
text: "Ta akcja wyłączy synchronizację kolekcji między twoimi urządzeniami. Ustawienia nadal będą przechowywane w chmurze." text: "Ta akcja wyłączy synchronizację kolekcji między twoimi urządzeniami. Ustawienia nadal będą przechowywane w chmurze."
action: "Wyłącz i przeładuj rozszerzenie" action: "Wyłącz i przeładuj rozszerzenie"
thumbnail_capture: "Zapisuj podglądy i ikony dla zapisanych kart"
thumbnail_capture_notice1: "Wymagany dostęp do zawartości odwiedzanych stron internetowych"
thumbnail_capture_notice2: "Wyłączenie tej funkcji może poprawić wydajność przy dużej liczbie zapisanych kart"
clear_thumbnails:
action: "Usuń zapisane ikony"
title: "Usunąć podglądy i ikony?"
prompt: "Ta akcja usunie wszystkie podglądy i ikony twoich zapisanych kart. Tej akcji nie można cofnąć."
about: about:
title: "O rozszerzeniu" title: "O rozszerzeniu"
developed_by: "Wywoływacz: Eugeniusz Lis" developed_by: "Wywoływacz: Eugeniusz Lis"
@@ -133,6 +150,7 @@ options_page:
website: "Moja strona internetowa" website: "Moja strona internetowa"
source: "Kod źródłowy" source: "Kod źródłowy"
changelog: "Lista zmian" changelog: "Lista zmian"
privacy: "Polityka prywatności"
collections: collections:
empty: "Ta kolekcja jest pusta" empty: "Ta kolekcja jest pusta"
+18
View File
@@ -36,6 +36,15 @@ features:
text3: "Visite nosso blog de desenvolvimento para saber mais sobre esta atualização e todos os seus recursos!" text3: "Visite nosso blog de desenvolvimento para saber mais sobre esta atualização e todos os seus recursos!"
actions: actions:
visit_blog: "Ler blog de desenvolvimento" visit_blog: "Ler blog de desenvolvimento"
settingsReview:
title: "Revise suas configurações"
action: "Todas as configurações"
analytics:
title: "Estas estatísticas nos ajudarão a melhorar a extensão"
p1: "Nós coletamos apenas estatísticas de uso (número de coleções, recursos usados, etc.)"
p2: "Nós não coletamos nenhum dos seus dados!"
p3_text: "Veja a lista completa do que coletamos"
p3_link: "aqui"
notifications: notifications:
tabs_saved: tabs_saved:
@@ -74,6 +83,7 @@ options_page:
show_badge: "Mostrar contador no ícone" show_badge: "Mostrar contador no ícone"
show_notification: "Mostrar notificação ao salvar abas pelo menu de contexto" show_notification: "Mostrar notificação ao salvar abas pelo menu de contexto"
unload_tabs: "Não carregar abas após abrir" unload_tabs: "Não carregar abas após abrir"
allow_analytics: "Permitir coleta de estatísticas anônimas"
list_locations: list_locations:
title: "Abrir lista de coleções em:" title: "Abrir lista de coleções em:"
options: options:
@@ -121,6 +131,13 @@ options_page:
disable_prompt: disable_prompt:
text: "Esta ação desativará a sincronização de coleções entre seus dispositivos. As configurações da extensão ainda serão sincronizadas." text: "Esta ação desativará a sincronização de coleções entre seus dispositivos. As configurações da extensão ainda serão sincronizadas."
action: "Desativar e recarregar a extensão" action: "Desativar e recarregar a extensão"
thumbnail_capture: "Capturar miniaturas e ícones para as abas salvas"
thumbnail_capture_notice1: "Requer permissão para acessar o conteúdo dos sites visitados"
thumbnail_capture_notice2: "Desativar esse recurso pode melhorar o desempenho em coleções grandes"
clear_thumbnails:
action: "Eliminar miniaturas guardadas"
title: "Excluir todas as miniaturas salvas?"
prompt: "Esta ação removerá todas as miniaturas, pré-visualizações e ícones salvos para suas abas salvas. Esta ação não pode ser desfeita."
about: about:
title: "Sobre" title: "Sobre"
developed_by: "Desenvolvido por Eugene Fox" developed_by: "Desenvolvido por Eugene Fox"
@@ -133,6 +150,7 @@ options_page:
website: "Meu site" website: "Meu site"
source: "Código-fonte" source: "Código-fonte"
changelog: "Registro de alterações" changelog: "Registro de alterações"
privacy: "Política de Privacidade"
collections: collections:
empty: "Esta coleção está vazia" empty: "Esta coleção está vazia"
+18
View File
@@ -36,6 +36,15 @@ features:
text3: "Посетите блог разработчика (только на английском), чтобы узнать больше об этом обновлении и всех его функциях!" text3: "Посетите блог разработчика (только на английском), чтобы узнать больше об этом обновлении и всех его функциях!"
actions: actions:
visit_blog: "Читать блог" visit_blog: "Читать блог"
settingsReview:
title: "Проверьте настройки"
action: "Все настройки"
analytics:
title: "Эта статистика позволит улучшать расширение"
p1: "Мы собираем только статистику использования (количество коллекций, используемые функции и т.д.)"
p2: "Мы не собираем ваши личные данные!"
p3_text: "Полный список собираемых данных можно посмотреть"
p3_link: "здесь"
notifications: notifications:
tabs_saved: tabs_saved:
@@ -74,6 +83,7 @@ options_page:
show_badge: "Показывать счетчик" show_badge: "Показывать счетчик"
show_notification: "Показывать уведомление при сохранении через контекстное меню" show_notification: "Показывать уведомление при сохранении через контекстное меню"
unload_tabs: "Не загружать вкладки после открытия" unload_tabs: "Не загружать вкладки после открытия"
allow_analytics: "Разрешить сбор анонимной статистики"
list_locations: list_locations:
title: "Открывать список коллекций в:" title: "Открывать список коллекций в:"
options: options:
@@ -121,6 +131,13 @@ options_page:
disable_prompt: disable_prompt:
text: "Это действие отключит синхронизацию коллекций между вашими устройствами. Настройки расширения продолжат храниться в облаке." text: "Это действие отключит синхронизацию коллекций между вашими устройствами. Настройки расширения продолжат храниться в облаке."
action: "Отключить и перезагрузить расширение" action: "Отключить и перезагрузить расширение"
thumbnail_capture: "Сохранять превью и иконки для сохранённых вкладок"
thumbnail_capture_notice1: "Необходим доступ к содержанию посещенных веб-сайтов"
thumbnail_capture_notice2: "Отключение этой функции может улучшить производительность при большом количестве сохраненных вкладок"
clear_thumbnails:
action: "Удалить сохранённые иконки"
title: "Удалить превью и иконки?"
prompt: "Это действие удалит все превью и иконки у ваших сохраненных вкладок. Это действие не может быть отменено."
about: about:
title: "О расширении" title: "О расширении"
developed_by: "Разработчик: Евгений Лис" developed_by: "Разработчик: Евгений Лис"
@@ -133,6 +150,7 @@ options_page:
website: "Мой веб-сайт" website: "Мой веб-сайт"
source: "Исходный код" source: "Исходный код"
changelog: "Список изменений" changelog: "Список изменений"
privacy: "Политика конфиденциальности"
collections: collections:
empty: "Эта коллекция пуста" empty: "Эта коллекция пуста"
+18
View File
@@ -36,6 +36,15 @@ features:
text3: "Відвідайте блог розробника (тільки англійською), щоб дізнатися більше про це оновлення та всі його функції!" text3: "Відвідайте блог розробника (тільки англійською), щоб дізнатися більше про це оновлення та всі його функції!"
actions: actions:
visit_blog: "Читати блог" visit_blog: "Читати блог"
settingsReview:
title: "Перевірте налаштування"
action: "Всi налаштування"
analytics:
title: "Ця статистика дозволить покращувати розширення"
p1: "Ми збираємо лише статистику використання (кількість колекцій, використовувані функції тощо)"
p2: "Ми не збираємо ваші особисті дані!"
p3_text: "Повний список зібраних даних можна подивитися"
p3_link: "тут"
notifications: notifications:
tabs_saved: tabs_saved:
@@ -74,6 +83,7 @@ options_page:
show_badge: "Показувати лічильник" show_badge: "Показувати лічильник"
show_notification: "Показувати сповіщення при збереженні через контекстне меню" show_notification: "Показувати сповіщення при збереженні через контекстне меню"
unload_tabs: "Не завантажувати вкладки після відкриття" unload_tabs: "Не завантажувати вкладки після відкриття"
allow_analytics: "Дозволити збір анонімної статистики"
list_locations: list_locations:
title: "Відкривати список колекцій у:" title: "Відкривати список колекцій у:"
options: options:
@@ -121,6 +131,13 @@ options_page:
disable_prompt: disable_prompt:
text: "Ця дія вимкне синхронізацію колекцій між вашими пристроями. Налаштування продовжать зберігатися у хмарі." text: "Ця дія вимкне синхронізацію колекцій між вашими пристроями. Налаштування продовжать зберігатися у хмарі."
action: "Вимкнути та перезавантажити розширення" action: "Вимкнути та перезавантажити розширення"
thumbnail_capture: "Зберігати превью і іконки для збережених вкладок"
thumbnail_capture_notice1: "Необхідний доступ до вмісту відвіданих веб-сайтів"
thumbnail_capture_notice2: "Вимкнення цієї функції може покращити продуктивність при великій кількості збережених вкладок"
clear_thumbnails:
action: "Видалити збережені іконки"
title: "Видалити превью і іконки?"
prompt: "Ця дія видалить всі превью і іконки у ваших збережених вкладках. Цю дію не можна скасувати."
about: about:
title: "О розширенні" title: "О розширенні"
developed_by: "Розробник: Євген Лис" developed_by: "Розробник: Євген Лис"
@@ -133,6 +150,7 @@ options_page:
website: "Мій веб-сайт" website: "Мій веб-сайт"
source: "Вихідний код" source: "Вихідний код"
changelog: "Список змін" changelog: "Список змін"
privacy: "Політика конфіденційності"
collections: collections:
empty: "Ця колекція пуста" empty: "Ця колекція пуста"
+18
View File
@@ -36,6 +36,15 @@ features:
text3: "访问我们的开发博客以了解有关此更新及其所有功能的更多信息!" text3: "访问我们的开发博客以了解有关此更新及其所有功能的更多信息!"
actions: actions:
visit_blog: "阅读开发博客" visit_blog: "阅读开发博客"
settingsReview:
title: "检查您的设置"
action: "所有设置"
analytics:
title: "这些统计数据将帮助我们改进扩展"
p1: "我们只收集使用统计数据(收藏数量、使用的功能等)"
p2: "我们不会收集您的任何数据!"
p3_text: ""
p3_link: "请参阅我们收集内容的完整列表"
notifications: notifications:
tabs_saved: tabs_saved:
@@ -74,6 +83,7 @@ options_page:
show_badge: "显示计数徽章" show_badge: "显示计数徽章"
show_notification: "使用上下文菜单保存标签时显示通知" show_notification: "使用上下文菜单保存标签时显示通知"
unload_tabs: "打开后不加载标签" unload_tabs: "打开后不加载标签"
allow_analytics: "允许收集匿名统计数据"
list_locations: list_locations:
title: "在以下位置打开收藏列表:" title: "在以下位置打开收藏列表:"
options: options:
@@ -121,6 +131,13 @@ options_page:
disable_prompt: disable_prompt:
text: "此操作将禁用设备之间的收藏同步。扩展设置仍将同步。" text: "此操作将禁用设备之间的收藏同步。扩展设置仍将同步。"
action: "禁用并重新加载扩展" action: "禁用并重新加载扩展"
thumbnail_capture: "为已保存的标签保存缩略图和图标"
thumbnail_capture_notice1: "需要访问已访问网站内容的权限"
thumbnail_capture_notice2: "当你有大量集合时,禁用此功能可能会提高性能"
clear_thumbnails:
action: "删除已保存的图标"
title: "删除缩略图和图标?"
prompt: "此操作将删除您已保存标签页的所有缩略图和图标。此操作无法撤消。"
about: about:
title: "关于" title: "关于"
developed_by: "由尤金·福克斯开发" developed_by: "由尤金·福克斯开发"
@@ -133,6 +150,7 @@ options_page:
website: "我的网站" website: "我的网站"
source: "源代码" source: "源代码"
changelog: "更新日志" changelog: "更新日志"
privacy: "隐私政策"
collections: collections:
empty: "此收藏为空" empty: "此收藏为空"
+16 -17
View File
@@ -1,7 +1,7 @@
{ {
"name": "tabs-aside", "name": "tabs-aside",
"private": true, "private": true,
"version": "3.0.0", "version": "3.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wxt", "dev": "wxt",
@@ -16,31 +16,30 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fluentui/react-components": "^9.68.1", "@fluentui/react-components": "^9.70.0",
"@fluentui/react-icons": "^2.0.307", "@fluentui/react-icons": "^2.0.309",
"@webext-core/messaging": "^2.3.0", "@webext-core/messaging": "^2.3.0",
"@wxt-dev/analytics": "^0.4.1", "@wxt-dev/analytics": "^0.4.1",
"@wxt-dev/i18n": "^0.2.4", "@wxt-dev/i18n": "^0.2.4",
"lzutf8": "^0.6.3", "lzutf8": "^0.6.3",
"react": "^18.3.1", "react": "~18.3.1",
"react-dom": "^18.3.1" "react-dom": "~18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/css": "^0.10.0", "@eslint/css": "^0.11.0",
"@eslint/js": "^9.32.0", "@eslint/js": "^9.35.0",
"@eslint/json": "^0.13.1", "@eslint/json": "^0.13.2",
"@stylistic/eslint-plugin": "^5.2.2", "@stylistic/eslint-plugin": "^5.3.1",
"@types/react": "^18.3.1", "@types/react": "~18.3.1",
"@types/react-dom": "^18.3.1", "@types/react-dom": "~18.3.1",
"@types/scheduler": "0.23.0", "@wxt-dev/module-react": "^1.1.5",
"@wxt-dev/module-react": "^1.1.3", "eslint": "^9.35.0",
"eslint": "^9.32.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^16.3.0", "globals": "^16.3.0",
"scheduler": "0.23.0", "scheduler": "0.23.0",
"typescript": "^5.8.3", "typescript": "^5.9.2",
"typescript-eslint": "^8.38.0", "typescript-eslint": "^8.42.0",
"vite": "^7.0.6", "vite": "^7.1.5",
"wxt": "~0.19.29" "wxt": "~0.19.29"
}, },
"packageManager": "yarn@4.9.2" "packageManager": "yarn@4.9.2"
+15 -4
View File
@@ -2,7 +2,7 @@ import { ConfigEnv, defineConfig, UserManifest } from "wxt";
// See https://wxt.dev/api/config.html // See https://wxt.dev/api/config.html
export default defineConfig({ export default defineConfig({
modules: ["@wxt-dev/module-react", "@wxt-dev/i18n/module", "@wxt-dev/analytics/module"], modules: ["@wxt-dev/module-react", "@wxt-dev/i18n/module"],
vite: () => ({ vite: () => ({
build: build:
{ {
@@ -37,10 +37,15 @@ export default defineConfig({
"tabs", "tabs",
"notifications", "notifications",
"contextMenus", "contextMenus",
"bookmarks",
"tabGroups" "tabGroups"
], ],
optional_permissions:
[
"bookmarks",
"scripting"
],
commands: commands:
{ {
show_collections: show_collections:
@@ -71,7 +76,7 @@ export default defineConfig({
} }
}, },
host_permissions: ["<all_urls>"] optional_host_permissions: ["<all_urls>"]
}; };
if (browser === "firefox") if (browser === "firefox")
@@ -80,7 +85,13 @@ export default defineConfig({
gecko: gecko:
{ {
id: "tabsaside@xfox111.net", id: "tabsaside@xfox111.net",
strict_min_version: "139.0" strict_min_version: "139.0",
// @ts-expect-error Introduced in Firefox 139
data_collection_permissions: {
required: ["browsingActivity"],
optional: ["technicalAndInteraction"]
}
} }
}; };
+1600 -2205
View File
File diff suppressed because it is too large Load Diff