diff --git a/public/locales/en/app.json b/public/locales/en/app.json index 3cf14b332..82ba213de 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -45,6 +45,9 @@ "apiAddressForm": { "placeholder": "Enter a URL (http://127.0.0.1:5001) or a Multiaddr (/ip4/127.0.0.1/tcp/5001)" }, + "publicGatewayForm": { + "placeholder": "Enter a URL (https://dweb.link)" + }, "terms": { "address": "Address", "addresses": "Addresses", @@ -75,6 +78,7 @@ "pins": "Pins", "pinStatus": "Pin Status", "publicKey": "Public key", + "publicGateway": "Public Gateway", "rateIn": "Rate in", "rateOut": "Rate out", "repo": "Repo", diff --git a/public/locales/en/files.json b/public/locales/en/files.json index 9e379bca4..9d441b5b7 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -10,7 +10,8 @@ "individualFilesOnly": "Only available for individual files", "noPDFSupport": "Your browser does not support PDFs. Please download the PDF to view it:", "downloadPDF": "Download PDF", - "downloadInstead": "Try <1>downloading it instead.", + "openWithPublicGateway": "Try opening it instead with your <1>public gateway.", + "openWithLocalAndPublicGateway": "Try opening it instead with your <1>local gateway or <3>public gateway.", "cantBePreviewed": "Sorry, this file can’t be previewed", "addByPath": "From IPFS", "newFolder": "New folder", @@ -109,6 +110,6 @@ "filesDescription": "Total size of data in the current directory (if a subdirectory, the size of all data in Files is also displayed)", "more": "More", "files": "Files", - "cidNotFileNorDir": "The current link isn't a file, nor a directory. Try to <0>inspect it instead.", + "cidNotFileNorDir": "The current link isn't a file, nor a directory. Try to <1>inspect it instead.", "sortBy": "Sort items by {name}" } diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 43b83d6aa..291f1524d 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -22,6 +22,7 @@ "translationProjectLink": "Join the IPFS Translation Project" }, "apiDescription": "<0>If your node is configured with a <1>custom API address, including a port other than the default 5001, enter it here.", + "publicGatewayDescription": "<0>Choose which <1>public gateway you want to use when generating shareable links.", "cliDescription": "<0>Enable this option to display a \"view code\" <1> icon next to common IPFS commands. Clicking it opens a modal with that command's CLI code, so you can paste it into the IPFS command-line interface in your terminal.", "cliModal": { "extraNotesJsonConfig": "If you've made changes to the config in this page's code editor that you'd like to save, click the download icon next to the copy button to download it as a JSON file." diff --git a/src/bundles/config.js b/src/bundles/config.js index dbc65a0b9..91c79c621 100644 --- a/src/bundles/config.js +++ b/src/bundles/config.js @@ -2,7 +2,6 @@ import memoize from 'p-memoize' import toUri from 'multiaddr-to-uri' import { createAsyncResourceBundle, createSelector } from 'redux-bundler' -const DEFAULT_URI = 'https://ipfs.io' const LOCAL_HOSTNAMES = ['127.0.0.1', '[::1]', '0.0.0.0', '[::]'] const bundle = createAsyncResourceBundle({ @@ -22,7 +21,9 @@ const bundle = createAsyncResourceBundle({ } const config = JSON.parse(conf) - const url = getURLFromAddress('Gateway', config) || DEFAULT_URI + + const publicGateway = store.selectPublicGateway() + const url = getURLFromAddress('Gateway', config) || publicGateway // Normalize local hostnames to localhost // to leverage subdomain gateway, if present @@ -38,7 +39,7 @@ const bundle = createAsyncResourceBundle({ } if (!await checkIfGatewayUrlIsAccessible(url)) { - store.doSetAvailableGateway(DEFAULT_URI) + store.doSetAvailableGateway(publicGateway) } // stringy json for quick compares @@ -54,12 +55,14 @@ bundle.selectConfigObject = createSelector( bundle.selectApiUrl = createSelector( 'selectConfigObject', - (config) => getURLFromAddress('API', config) || DEFAULT_URI + 'selectPublicGateway', + (config, publicGateway) => getURLFromAddress('API', config) || publicGateway ) bundle.selectGatewayUrl = createSelector( 'selectConfigObject', - (config) => getURLFromAddress('Gateway', config) || DEFAULT_URI + 'selectPublicGateway', + (config, publicGateway) => getURLFromAddress('Gateway', config) || publicGateway ) bundle.selectAvailableGatewayUrl = createSelector( diff --git a/src/bundles/files/actions.js b/src/bundles/files/actions.js index 3fd133532..be57c9194 100644 --- a/src/bundles/files/actions.js +++ b/src/bundles/files/actions.js @@ -156,6 +156,7 @@ const getPins = async function * (ipfs) { * @typedef {Object} ConfigSelectors * @property {function():string} selectApiUrl * @property {function():string} selectGatewayUrl + * @property {function():string} selectPublicGateway * * @typedef {Object} UnkonwActions * @property {function(string):Promise} doUpdateHash @@ -421,9 +422,10 @@ const actions = () => ({ * Generates sharable link for the provided files. * @param {FileStat[]} files */ - doFilesShareLink: (files) => perform(ACTIONS.SHARE_LINK, async (ipfs) => { + doFilesShareLink: (files) => perform(ACTIONS.SHARE_LINK, async (ipfs, { store }) => { // ensureMFS deliberately omitted here, see https://github.com/ipfs/ipfs-webui/issues/1744 for context. - return await getShareableLink(files, ipfs) + const publicGateway = store.selectPublicGateway() + return await getShareableLink(files, publicGateway, ipfs) }), /** diff --git a/src/bundles/gateway.js b/src/bundles/gateway.js index 47152e0b2..cbd488f57 100644 --- a/src/bundles/gateway.js +++ b/src/bundles/gateway.js @@ -1,17 +1,54 @@ +import { readSetting, writeSetting } from './local-storage' + +export const DEFAULT_GATEWAY = 'https://dweb.link' + +const readPublicGatewaySetting = () => { + const setting = readSetting('ipfsPublicGateway') + return setting || DEFAULT_GATEWAY +} + +const init = () => ({ + availableGateway: null, + publicGateway: readPublicGatewaySetting() +}) + +export const checkValidHttpUrl = (value) => { + let url + + try { + url = new URL(value) + } catch (_) { + return false + } + + return url.protocol === 'http:' || url.protocol === 'https:' +} + const bundle = { name: 'gateway', - reducer: (state = { availableGateway: null }, action) => { + reducer: (state = init(), action) => { if (action.type === 'SET_AVAILABLE_GATEWAY') { return { ...state, availableGateway: action.payload } } + if (action.type === 'SET_PUBLIC_GATEWAY') { + return { ...state, publicGateway: action.payload } + } + return state }, doSetAvailableGateway: url => ({ dispatch }) => dispatch({ type: 'SET_AVAILABLE_GATEWAY', payload: url }), - selectAvailableGateway: (state) => state?.gateway?.availableGateway + doUpdatePublicGateway: (address) => async ({ dispatch }) => { + await writeSetting('ipfsPublicGateway', address) + dispatch({ type: 'SET_PUBLIC_GATEWAY', payload: address }) + }, + + selectAvailableGateway: (state) => state?.gateway?.availableGateway, + + selectPublicGateway: (state) => state?.gateway?.publicGateway } export default bundle diff --git a/src/bundles/ipfs-provider.js b/src/bundles/ipfs-provider.js index fc86c63c1..ca1bbc08f 100644 --- a/src/bundles/ipfs-provider.js +++ b/src/bundles/ipfs-provider.js @@ -6,6 +6,7 @@ import first from 'it-first' import last from 'it-last' import * as Enum from './enum' import { perform } from './task' +import { readSetting, writeSetting } from './local-storage' /* TODO: restore when no longer bundle standalone ipld with ipld-explorer * context: https://github.com/ipfs/ipld-explorer-components/pull/289 @@ -273,45 +274,6 @@ const readHTTPClientOptions = (value) => { } } -/** - * Reads setting from the `localStorage` with a given `id` as JSON. If JSON - * parse is failed setting is interpreted as a string value. - * @param {string} id - * @returns {string|object|null} - */ -const readSetting = (id) => { - /** @type {string|null} */ - let setting = null - if (window.localStorage) { - try { - setting = window.localStorage.getItem(id) - } catch (error) { - console.error(`Error reading '${id}' value from localStorage`, error) - } - - try { - return JSON.parse(setting || '') - } catch (_) { - // res was probably a string, so pass it on. - return setting - } - } - - return setting -} - -/** - * @param {string} id - * @param {string|number|boolean|object} value - */ -const writeSetting = (id, value) => { - try { - window.localStorage.setItem(id, JSON.stringify(value)) - } catch (error) { - console.error(`Error writing '${id}' value to localStorage`, error) - } -} - /** @type {IPFSService|null} */ let ipfs = null diff --git a/src/bundles/local-storage.js b/src/bundles/local-storage.js new file mode 100644 index 000000000..51487e530 --- /dev/null +++ b/src/bundles/local-storage.js @@ -0,0 +1,38 @@ +/** + * Reads setting from the `localStorage` with a given `id` as JSON. If JSON + * parse is failed setting is interpreted as a string value. + * @param {string} id + * @returns {string|object|null} + */ +export const readSetting = (id) => { + /** @type {string|null} */ + let setting = null + if (window.localStorage) { + try { + setting = window.localStorage.getItem(id) + } catch (error) { + console.error(`Error reading '${id}' value from localStorage`, error) + } + + try { + return JSON.parse(setting || '') + } catch (_) { + // res was probably a string, so pass it on. + return setting + } + } + + return setting +} + +/** + * @param {string} id + * @param {string|number|boolean|object} value + */ +export const writeSetting = (id, value) => { + try { + window.localStorage.setItem(id, JSON.stringify(value)) + } catch (error) { + console.error(`Error writing '${id}' value to localStorage`, error) + } +} diff --git a/src/components/api-address-form/ApiAddressForm.js b/src/components/api-address-form/ApiAddressForm.js index f6f5901fd..ad75735b9 100644 --- a/src/components/api-address-form/ApiAddressForm.js +++ b/src/components/api-address-form/ApiAddressForm.js @@ -48,7 +48,13 @@ const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress, ipfsInitFai value={value} />
- +
) diff --git a/src/components/public-gateway-form/PublicGatewayForm.js b/src/components/public-gateway-form/PublicGatewayForm.js new file mode 100644 index 000000000..20752f142 --- /dev/null +++ b/src/components/public-gateway-form/PublicGatewayForm.js @@ -0,0 +1,82 @@ +import React, { useState, useEffect } from 'react' +import { connect } from 'redux-bundler-react' +import { withTranslation } from 'react-i18next' +import Button from '../button/Button' +import { checkValidHttpUrl, DEFAULT_GATEWAY } from '../../bundles/gateway' + +const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => { + const [value, setValue] = useState(publicGateway) + const initialIsValidGatewayUrl = !checkValidHttpUrl(value) + const [showFailState, setShowFailState] = useState(initialIsValidGatewayUrl) + const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl) + + // Updates the border of the input to indicate validity + useEffect(() => { + setShowFailState(!isValidGatewayUrl) + }, [isValidGatewayUrl]) + + // Updates the border of the input to indicate validity + useEffect(() => { + const isValid = checkValidHttpUrl(value) + setIsValidGatewayUrl(isValid) + setShowFailState(!isValid) + }, [value]) + + const onChange = (event) => setValue(event.target.value) + + const onSubmit = async (event) => { + event.preventDefault() + doUpdatePublicGateway(value) + } + + const onReset = async (event) => { + event.preventDefault() + setValue(DEFAULT_GATEWAY) + doUpdatePublicGateway(DEFAULT_GATEWAY) + } + + const onKeyPress = (event) => { + if (event.key === 'Enter') { + onSubmit(event) + } + } + + return ( +
+ +
+ + +
+
+ ) +} + +export default connect( + 'doUpdatePublicGateway', + 'selectPublicGateway', + withTranslation('app')(PublicGatewayForm) +) diff --git a/src/files/file-preview/FilePreview.js b/src/files/file-preview/FilePreview.js index 110a0a69d..fbb7264a5 100644 --- a/src/files/file-preview/FilePreview.js +++ b/src/files/file-preview/FilePreview.js @@ -22,7 +22,7 @@ const Drag = ({ name, size, cid, path, children }) => { } const Preview = (props) => { - const { t, name, cid, size, availableGatewayUrl: gatewayUrl, read, onDownload } = props + const { t, name, cid, size, availableGatewayUrl, publicGateway, read, onDownload } = props const [content, setContent] = useState(null) const [hasMoreContent, setHasMoreContent] = useState(false) const [buffer, setBuffer] = useState(null) @@ -51,7 +51,7 @@ const Preview = (props) => { }, // eslint-disable-next-line react-hooks/exhaustive-deps []) - const src = `${gatewayUrl}/ipfs/${cid}?filename=${encodeURIComponent(name)}` + const src = `${availableGatewayUrl}/ipfs/${cid}?filename=${encodeURIComponent(name)}` const className = 'mw-100 mt3 bg-snow-muted pa2 br2 border-box' switch (type) { @@ -89,13 +89,22 @@ const Preview = (props) => { ) default: { + const srcPublic = `${publicGateway}/ipfs/${cid}?filename=${encodeURIComponent(name)}` + const cantPreview = (

{t('cantBePreviewed')} 😢

- - Try downloading it instead. - + { availableGatewayUrl === publicGateway + ? + Try opening it instead with your public gateway. + + : + Try opening it instead with your local gateway or public gateway. + + + } +

) @@ -143,5 +152,6 @@ Preview.propTypes = { export default connect( 'selectAvailableGatewayUrl', + 'selectPublicGateway', withTranslation('files')(Preview) ) diff --git a/src/lib/files.js b/src/lib/files.js index 580c285b0..d35947336 100644 --- a/src/lib/files.js +++ b/src/lib/files.js @@ -122,10 +122,11 @@ export async function getDownloadLink (files, gatewayUrl, apiUrl, ipfs) { /** * @param {FileStat[]} files + * @param {string} gatewayUrl * @param {IPFSService} ipfs * @returns {Promise} */ -export async function getShareableLink (files, ipfs) { +export async function getShareableLink (files, gatewayUrl, ipfs) { let cid let filename @@ -138,7 +139,7 @@ export async function getShareableLink (files, ipfs) { cid = await makeCIDFromFiles(files, ipfs) } - return `https://ipfs.io/ipfs/${cid}${filename || ''}` + return `${gatewayUrl}/ipfs/${cid}${filename || ''}` } /** diff --git a/src/settings/SettingsPage.js b/src/settings/SettingsPage.js index dc4ef6adc..aab39c9cb 100644 --- a/src/settings/SettingsPage.js +++ b/src/settings/SettingsPage.js @@ -15,6 +15,7 @@ import LanguageSelector from '../components/language-selector/LanguageSelector' import PinningManager from '../components/pinning-manager/PinningManager' import AnalyticsToggle from '../components/analytics-toggle/AnalyticsToggle' import ApiAddressForm from '../components/api-address-form/ApiAddressForm' +import PublicGatewayForm from '../components/public-gateway-form/PublicGatewayForm' import JsonEditor from './editor/JsonEditor' import Experiments from '../components/experiments/ExperimentsPanel' import Title from './Title' @@ -58,6 +59,16 @@ export const SettingsPage = ({ + +
+ {t('app:terms.publicGateway')} + +

Choose which public gateway you want to use to open your files.

+
+ +
+
+ {t('pinningServices.title')}

diff --git a/tsconfig.json b/tsconfig.json index d495c7c7f..163bd9735 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -66,6 +66,8 @@ "src/bundles/ipfs-desktop.js", "src/bundles/ipfs-provider.js", "src/bundles/retry-init.js", + "src/bundles/retry-init.js", + "src/bundles/local-storage.js", "src/bundles/task.js", "src/lib/count-dirs.js", "src/lib/sort.js",