diff --git a/config/i18n.json b/config/i18n.json index bcc91352f1..4d9efc3224 100644 --- a/config/i18n.json +++ b/config/i18n.json @@ -1417,17 +1417,21 @@ "Clear": "Clear Wish List", "ChoosyVoltron": "choosy_voltron", "ChoosyVoltronDescription": "choosy_voltron is an auto-updated collection of PvE and PvP wish list rolls from Mercules904 and pandapaxxy, along with some trash list rolls.", - "ExternalSource": "Optionally, supply the URL(s) for a wish list (pipe | separated)", + "ExternalSource": "Add another wish list", + "ExternalSourcePlaceholder": "Paste wish list URL here", "Header": "Wish List", "Import": "Load Wish List Rolls", "ImportFailed": "No wish list information found.", "ImportError": "Error loading wish list: {{error}}", "ImportNoFile": "No file selected.", "InvalidExternalSource": "Please enter a valid URL for your external wish list source. The URL must start with one of the following:", + "JustAnotherTeam": "Just Another Team", "LastUpdated": "Last updated: {{lastUpdatedDate}} at {{lastUpdatedTime}}", "Num": "{{num, number}} rolls in your wish list", + "NumRolls": "{{num, number}} rolls", "PreMadeFiles": "Use A Pre-Made Wish List", - "UpdateExternalSource": "Update Wish List Source", + "SourceAlreadyAdded": "Wish List already added", + "UpdateExternalSource": "Add Wish List", "Untitled": "Untitled Wish List", "Voltron": "voltron (default)", "VoltronDescription": "voltron is an auto-updated collection of PvE and PvP wish list rolls from across the Destiny community. We ship with this by default.", diff --git a/src/app/settings/WishListSettings.m.scss b/src/app/settings/WishListSettings.m.scss new file mode 100644 index 0000000000..59b84f53e3 --- /dev/null +++ b/src/app/settings/WishListSettings.m.scss @@ -0,0 +1,3 @@ +.tooltipDiv { + display: inline-block; +} diff --git a/src/app/settings/WishListSettings.m.scss.d.ts b/src/app/settings/WishListSettings.m.scss.d.ts new file mode 100644 index 0000000000..c81067abd1 --- /dev/null +++ b/src/app/settings/WishListSettings.m.scss.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'tooltipDiv': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/src/app/settings/WishListSettings.tsx b/src/app/settings/WishListSettings.tsx index 8a53b323ac..5c5dab8021 100644 --- a/src/app/settings/WishListSettings.tsx +++ b/src/app/settings/WishListSettings.tsx @@ -1,54 +1,38 @@ import { settingSelector } from 'app/dim-api/selectors'; -import { t } from 'app/i18next-t'; +import { ConfirmButton } from 'app/dim-ui/ConfirmButton'; +import { PressTip } from 'app/dim-ui/PressTip'; +import Switch from 'app/dim-ui/Switch'; +import { I18nKey, t } from 'app/i18next-t'; import { showNotification } from 'app/notifications/notifications'; +import { AppIcon, deleteIcon } from 'app/shell/icons'; import { wishListGuideLink } from 'app/shell/links'; import { useThunkDispatch } from 'app/store/thunk-dispatch'; import { errorMessage } from 'app/utils/errors'; +import { builtInWishlists, validateWishListURLs, wishListAllowedHosts } from 'app/wishlists/utils'; import { fetchWishList, transformAndStoreWishList } from 'app/wishlists/wishlist-fetch'; import { toWishList } from 'app/wishlists/wishlist-file'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { DropzoneOptions } from 'react-dropzone'; import { useSelector } from 'react-redux'; import FileUpload from '../dim-ui/FileUpload'; import HelpLink from '../dim-ui/HelpLink'; import { clearWishLists } from '../wishlists/actions'; import { wishListsLastFetchedSelector, wishListsSelector } from '../wishlists/selectors'; - -// config/content-security-policy.js must be edited alongside this list -export const wishListAllowedHosts = ['raw.githubusercontent.com', 'gist.githubusercontent.com']; -export function isValidWishListUrlDomain(url: string) { - try { - const parsedUrl = new URL(url); // throws if invalid - if (parsedUrl.protocol !== 'https:') { - return false; - } - return wishListAllowedHosts.includes(parsedUrl.host); - } catch (e) { - return false; - } -} - -const voltronLocation = - 'https://raw.githubusercontent.com/48klocs/dim-wish-list-sources/master/voltron.txt'; -const choosyVoltronLocation = - 'https://raw.githubusercontent.com/48klocs/dim-wish-list-sources/master/choosy_voltron.txt'; +import styles from './WishListSettings.m.scss'; export default function WishListSettings() { const dispatch = useThunkDispatch(); - const wishListSource = useSelector(settingSelector('wishListSource')); - const voltronNotSelected = wishListSource !== voltronLocation; - const choosyVoltronNotSelected = wishListSource !== choosyVoltronLocation; + const settingsWishListSource = useSelector(settingSelector('wishListSource')); const wishListLastUpdated = useSelector(wishListsLastFetchedSelector); const wishList = useSelector(wishListsSelector).wishListAndInfo; const numWishListRolls = wishList.wishListRolls.length; - const [liveWishListSource, setLiveWishListSource] = useState(wishListSource); useEffect(() => { dispatch(fetchWishList()); }, [dispatch]); - useEffect(() => { - setLiveWishListSource(wishListSource); - }, [wishListSource]); + const activeWishlistUrls = settingsWishListSource + ? settingsWishListSource.split('|').map((url) => url.trim()) + : []; const reloadWishList = async (reloadWishListSource: string | undefined) => { try { @@ -62,19 +46,15 @@ export default function WishListSettings() { } }; - const wishListUpdateEvent = async () => { - const newWishListSource = liveWishListSource?.trim(); - - await reloadWishList(newWishListSource); - }; - const loadWishList: DropzoneOptions['onDrop'] = (acceptedFiles) => { - dispatch(clearWishLists()); - const reader = new FileReader(); reader.onload = async () => { if (reader.result && typeof reader.result === 'string') { - const wishListAndInfo = toWishList(reader.result); + const wishListAndInfo = toWishList([undefined, reader.result]); + if (wishListAndInfo.wishListRolls.length) { + dispatch(clearWishLists()); + } + // Still attempt to store even with 0 rolls to show an error message dispatch(transformAndStoreWishList(wishListAndInfo)); } }; @@ -92,20 +72,30 @@ export default function WishListSettings() { dispatch(clearWishLists()); }; - const resetToChoosyVoltron = () => { - setLiveWishListSource(choosyVoltronLocation); - reloadWishList(choosyVoltronLocation); + const changeUrl = (url: string, enabled: boolean) => { + const toAddOrRemove = validateWishListURLs(url); + const newUrls = enabled + ? [...activeWishlistUrls, ...toAddOrRemove.filter((url) => !activeWishlistUrls.includes(url))] + : [...activeWishlistUrls.filter((url) => !toAddOrRemove.includes(url))]; + reloadWishList(newUrls.join('|')); }; - const resetToVoltron = () => { - setLiveWishListSource(voltronLocation); - reloadWishList(voltronLocation); + const addUrlDisabled = (url: string) => { + const urls = validateWishListURLs(url); + if (!urls.length) { + return `${t('WishListRoll.InvalidExternalSource')}\n${wishListAllowedHosts + .map((h) => `https://${h}`) + .join('\n')}`; + } + if (!urls.some((url) => !activeWishlistUrls.includes(url))) { + return t('WishListRoll.SourceAlreadyAdded'); + } + return false; }; - const updateWishListSourceState = (e: React.ChangeEvent) => { - const newSource = e.target.value; - setLiveWishListSource(newSource); - }; + const disabledBuiltinLists = builtInWishlists.filter( + (list) => !activeWishlistUrls.includes(list.url), + ); return (
@@ -113,64 +103,6 @@ export default function WishListSettings() { {t('WishListRoll.Header')} -
- -
- -
-
{t('WishListRoll.PreMadeFiles')}
- {voltronNotSelected && ( - <> -
- -
-
{t('WishListRoll.VoltronDescription')}
- {choosyVoltronNotSelected &&

} - - )} - {choosyVoltronNotSelected && ( - <> -

- -
-
{t('WishListRoll.ChoosyVoltronDescription')}
- - )} -
- -
-
{t('WishListRoll.ExternalSource')}
-
- -
-
- -
- - {wishListLastUpdated && ( -
- {t('WishListRoll.LastUpdated', { - lastUpdatedDate: wishListLastUpdated.toLocaleDateString(), - lastUpdatedTime: wishListLastUpdated.toLocaleTimeString(), - })} -
- )} -
{numWishListRolls > 0 && (
@@ -184,17 +116,167 @@ export default function WishListSettings() { {t('WishListRoll.Clear')}
- {wishList.infos.map(({ title, description, numRolls }, idx) => ( -
-
- {title || t('WishListRoll.Untitled')}{' '} - {wishList.infos.length > 1 && ({numRolls})} -
-
{description}
+ {wishListLastUpdated && ( +
+ {t('WishListRoll.LastUpdated', { + lastUpdatedDate: wishListLastUpdated.toLocaleDateString(), + lastUpdatedTime: wishListLastUpdated.toLocaleTimeString(), + })}
- ))} + )}
)} + + {activeWishlistUrls.map((url) => { + const loadedData = wishList.infos.find((info) => info.url === url); + const builtinEntry = builtInWishlists.find((list) => list.url === url); + if (builtinEntry) { + return ( + changeUrl(url, checked)} + /> + ); + } else { + return ( + changeUrl(url, false)} + /> + ); + } + })} + + {disabledBuiltinLists.map((list) => ( + changeUrl(list.url, checked)} + /> + ))} + + changeUrl(url, true)} + /> + +
+ +
); } + +function BuiltinWishlist({ + name, + title, + description, + rollsCount, + checked, + onChange, +}: { + name: I18nKey; + title: string | undefined; + description: string | undefined; + rollsCount: number | undefined; + checked: boolean; + onChange: (checked: boolean) => void; +}) { + return ( +
+
+ + +
+ {rollsCount !== undefined && t('WishListRoll.NumRolls', { num: rollsCount })} + {(title || description) && ( +
+ {title} +
+ {description} +
+ )} +
+ ); +} + +function UrlWishlist({ + url, + title, + description, + rollsCount, + onRemove, +}: { + url: string; + title: string | undefined; + description: string | undefined; + rollsCount: number | undefined; + onRemove: () => void; +}) { + return ( +
+
+ + + + +
+ {!title &&
{url}
} + {rollsCount !== undefined && t('WishListRoll.NumRolls', { num: rollsCount })} + {description &&
{description}
} +
+ ); +} + +function NewUrlWishlist({ + addWishlistDisabled, + onAddWishlist, +}: { + addWishlistDisabled: (url: string) => string | false; + onAddWishlist: (url: string) => void; +}) { + const [newWishlistSource, setNewWishlistSource] = useState(''); + const canAddError = addWishlistDisabled(newWishlistSource); + const disabled = canAddError !== false; + return ( +
+
{t('WishListRoll.ExternalSource')}
+
+ setNewWishlistSource(e.target.value)} + placeholder={t('WishListRoll.ExternalSourcePlaceholder')} + /> +
+
+ + { + onAddWishlist(newWishlistSource); + setNewWishlistSource(''); + }} + /> + +
+
+ ); +} diff --git a/src/app/vendors/selectors.ts b/src/app/vendors/selectors.ts index e2a8f4e4bc..a93d1e3a93 100644 --- a/src/app/vendors/selectors.ts +++ b/src/app/vendors/selectors.ts @@ -143,7 +143,7 @@ export const vendorItemFilterSelector = currySelector( subVendorsForCharacterSelector.selector, querySelector, searchFilterSelector, - (state: RootState) => settingSelector('vendorsHideSilverItems')(state), + settingSelector<'vendorsHideSilverItems'>('vendorsHideSilverItems'), (ownedItemHashes, showUnacquiredOnly, subVendors, query, itemFilter, hideSilver) => { const filters: VendorFilterFunction[] = []; const silverFilter = filterToNoSilver(); diff --git a/src/app/wishlists/types.ts b/src/app/wishlists/types.ts index 4d8f455135..587aced373 100644 --- a/src/app/wishlists/types.ts +++ b/src/app/wishlists/types.ts @@ -48,6 +48,8 @@ export interface WishListAndInfo { } export interface WishListInfo { + /** The wish list URL. If undefined, this is a local wish list. */ + url: string | undefined; title?: string; description?: string; /** The number of rolls from this wish list that actually made it in. */ diff --git a/src/app/wishlists/utils.ts b/src/app/wishlists/utils.ts new file mode 100644 index 0000000000..06942f71d6 --- /dev/null +++ b/src/app/wishlists/utils.ts @@ -0,0 +1,32 @@ +import { I18nKey, tl } from 'app/i18next-t'; + +export const builtInWishlists: { name: I18nKey; url: string }[] = [ + { + name: tl('WishListRoll.Voltron'), + url: 'https://raw.githubusercontent.com/48klocs/dim-wish-list-sources/master/voltron.txt', + }, + { + name: tl('WishListRoll.JustAnotherTeam'), + url: 'https://raw.githubusercontent.com/dsf000z/JAT-wishlists-bundler/main/bundles/DIM-strict/just-another-team-mnk.txt', + }, +]; + +// config/content-security-policy.js must be edited alongside this list +export const wishListAllowedHosts = ['raw.githubusercontent.com', 'gist.githubusercontent.com']; +export function validateWishListURLs(url: string): string[] { + return url + .split('|') + .map((url) => url.trim()) + .filter((url) => { + try { + const parsedUrl = new URL(url); // throws if invalid + if (parsedUrl.protocol !== 'https:' || !wishListAllowedHosts.includes(parsedUrl.host)) { + return false; + } + } catch (e) { + return false; + } + + return true; + }); +} diff --git a/src/app/wishlists/wishlist-fetch.ts b/src/app/wishlists/wishlist-fetch.ts index a9cc386169..bf5f11eb11 100644 --- a/src/app/wishlists/wishlist-fetch.ts +++ b/src/app/wishlists/wishlist-fetch.ts @@ -1,16 +1,17 @@ import { settingsSelector } from 'app/dim-api/selectors'; import { t } from 'app/i18next-t'; import { showNotification } from 'app/notifications/notifications'; -import { isValidWishListUrlDomain, wishListAllowedHosts } from 'app/settings/WishListSettings'; import { setSettingAction } from 'app/settings/actions'; import { settingsReady } from 'app/settings/settings'; import { get } from 'app/storage/idb-keyval'; import { ThunkResult } from 'app/store/types'; import { errorLog, infoLog } from 'app/utils/log'; +import _ from 'lodash'; import { loadWishLists, touchWishLists } from './actions'; import type { WishListsState } from './reducer'; import { wishListsSelector } from './selectors'; import { WishListAndInfo } from './types'; +import { validateWishListURLs } from './utils'; import { toWishList } from './wishlist-file'; function hoursAgo(dateToCheck?: Date): number { @@ -45,32 +46,22 @@ export function fetchWishList(newWishlistSource?: string): ThunkResult { } // Pipe | seperated URLs - const wishlistUrlsToFetch = wishlistToFetch.split('|').map((url) => url.trim()); - - // there's a source if we reached this far, but check if it's invalid - if (wishlistUrlsToFetch.some((list) => !isValidWishListUrlDomain(list))) { - showNotification({ - type: 'warning', - title: t('WishListRoll.Header'), - body: `${t('WishListRoll.InvalidExternalSource')}\n${wishListAllowedHosts - .map((h) => `https://${h}`) - .join('\n')}`, - duration: 10000, - }); - return; - } + const wishlistUrlsToFetch = validateWishListURLs(wishlistToFetch); const { lastFetched: wishListLastUpdated, - wishListAndInfo: { source: loadedWishListSource }, + wishListAndInfo: { source: loadedWishListSource, wishListRolls: loadedWishListRolls }, } = wishListsSelector(getState()); + const wishListURLsChanged = + loadedWishListSource !== undefined && loadedWishListSource !== wishlistToFetch; + // Throttle updates if: if ( // this isn't a settings update, and !newWishlistSource && // if the intended fetch target is already the source of the loaded list - (loadedWishListSource === undefined || loadedWishListSource === wishlistToFetch) && + !wishListURLsChanged && // we already checked the wishlist today hoursAgo(wishListLastUpdated) < 24 ) { @@ -105,22 +96,26 @@ export function fetchWishList(newWishlistSource?: string): ThunkResult { return; } - const wishListAndInfo = toWishList(...wishListTexts); - wishListAndInfo.source = wishlistToFetch; + const wishLists: [string, string][] = wishlistUrlsToFetch.map((url, idx) => [ + url, + wishListTexts[idx], + ]); - const existingWishLists = wishListsSelector(getState()); + const wishListAndInfo = toWishList(...wishLists); + wishListAndInfo.source = wishlistToFetch; // Only update if the length changed. The wish list may actually be different - we don't do a deep check - // but this is good enough to avoid re-doing the work over and over. if ( - existingWishLists?.wishListAndInfo?.wishListRolls?.length !== - wishListAndInfo.wishListRolls.length + loadedWishListRolls?.length !== wishListAndInfo.wishListRolls.length || + wishListURLsChanged ) { await dispatch(transformAndStoreWishList(wishListAndInfo)); } else { infoLog('wishlist', 'Refreshed wishlist, but it matched the one we already have'); dispatch(touchWishLists()); } + await dispatch(transformAndStoreWishList(wishListAndInfo)); }; } @@ -151,6 +146,17 @@ function loadWishListAndInfoFromIndexedDB(): ThunkResult { return; } + // Previously we didn't save the URLs together with the source info, + // but we want this now. + if (wishListState?.wishListAndInfo.source) { + const urls = _.once(() => validateWishListURLs(wishListState.wishListAndInfo.source!)); + for (const [idx, entry] of wishListState.wishListAndInfo.infos.entries()) { + if (entry.url === undefined) { + entry.url = urls()[idx]; + } + } + } + if (wishListState?.wishListAndInfo?.wishListRolls?.length) { dispatch(loadWishLists(wishListState)); } diff --git a/src/app/wishlists/wishlist-file.test.ts b/src/app/wishlists/wishlist-file.test.ts index c3ee40eee9..8090676979 100644 --- a/src/app/wishlists/wishlist-file.test.ts +++ b/src/app/wishlists/wishlist-file.test.ts @@ -15,5 +15,5 @@ const cases: [wishlist: string, result: WishListRoll][] = [ ]; test.each(cases)('parse wishlist line: %s', (wishlist, result) => { - expect(toWishList(wishlist).wishListRolls[0]).toStrictEqual(result); + expect(toWishList([undefined, wishlist]).wishListRolls[0]).toStrictEqual(result); }); diff --git a/src/app/wishlists/wishlist-file.ts b/src/app/wishlists/wishlist-file.ts index 9c4b9d7016..21899101be 100644 --- a/src/app/wishlists/wishlist-file.ts +++ b/src/app/wishlists/wishlist-file.ts @@ -22,7 +22,9 @@ const notesLabel = '//notes:'; * one or more wish list text files, deduplicating within * and between lists. */ -export function toWishList(...fileTexts: string[]): WishListAndInfo { +export function toWishList( + ...files: [url: string | undefined, contents: string][] +): WishListAndInfo { const stopTimer = timer('Parse wish list'); try { const wishList: WishListAndInfo = { @@ -32,8 +34,9 @@ export function toWishList(...fileTexts: string[]): WishListAndInfo { const seen = new Set(); - for (const fileText of fileTexts) { + for (const [url, fileText] of files) { const info: WishListInfo = { + url, title: undefined, description: undefined, numRolls: 0, diff --git a/src/locale/en.json b/src/locale/en.json index 2307845ffb..8a1dad80a2 100644 --- a/src/locale/en.json +++ b/src/locale/en.json @@ -1398,23 +1398,23 @@ "WishListRoll": { "BestRatedTip": "This perk exactly matches a weapon roll on your wishlist.", "BestRatedTip_plural": "These perks exactly match a weapon roll on your wishlist.", - "ChoosyVoltron": "choosy_voltron", - "ChoosyVoltronDescription": "choosy_voltron is an auto-updated collection of PvE and PvP wish list rolls from Mercules904 and pandapaxxy, along with some trash list rolls.", "Clear": "Clear Wish List", "CopiedLine": "Wish List roll copied to clipboard", "CopyLine": "Copy Selected Perks as Wish List Roll", - "ExternalSource": "Optionally, supply the URL(s) for a wish list (pipe | separated)", + "ExternalSource": "Add another wish list", + "ExternalSourcePlaceholder": "Paste wish list URL here", "Header": "Wish List", "Import": "Load Wish List Rolls", "ImportError": "Error loading wish list: {{error}}", "ImportFailed": "No wish list information found.", "ImportNoFile": "No file selected.", "InvalidExternalSource": "Please enter a valid URL for your external wish list source. The URL must start with one of the following:", + "JustAnotherTeam": "Just Another Team", "LastUpdated": "Last updated: {{lastUpdatedDate}} at {{lastUpdatedTime}}", "Num": "{{num, number}} rolls in your wish list", - "PreMadeFiles": "Use A Pre-Made Wish List", - "Untitled": "Untitled Wish List", - "UpdateExternalSource": "Update Wish List Source", + "NumRolls": "{{num, number}} rolls", + "SourceAlreadyAdded": "Wish List already added", + "UpdateExternalSource": "Add Wish List", "Voltron": "voltron (default)", "VoltronDescription": "voltron is an auto-updated collection of PvE and PvP wish list rolls from across the Destiny community. We ship with this by default.", "WishListNotes": "Wish List Notes:",