diff --git a/.env.defaults b/.env.defaults index b1da81ebe..e9f804a94 100644 --- a/.env.defaults +++ b/.env.defaults @@ -16,7 +16,7 @@ COMMENT_SERVER_NAME=Odysee SEARCH_SERVER_API=https://lighthouse.odysee.com/search SOCKETY_SERVER_API=wss://sockety.odysee.com/ws THUMBNAIL_CDN_URL=https://image-processor.vanwanet.com/optimize/ -WELCOME_VERSION=1.1 +WELCOME_VERSION=1.2 # STRIPE # STRIPE_PUBLIC_KEY='pk_test_NoL1JWL7i1ipfhVId5KfDZgo' diff --git a/static/app-strings.json b/static/app-strings.json index c9f577638..a553fec7f 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -2206,13 +2206,6 @@ "Enabling a minimum amount to comment will force all comments to have tips associated with them. This can help prevent spam.": "Enabling a minimum amount to comment will force all comments to have tips associated with them. This can help prevent spam.", "Comments containing these words will be blocked.": "Comments containing these words will be blocked.", "Enter the full channel name or URL to search.\n\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8": "Enter the full channel name or URL to search.\n\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8", - "Disk Space": "Disk Space", - "Data Hosting": "Data Hosting", - "Limit": "Limit", - "Limit Space Used": "Limit Space Used", - "Apply": "Apply", - "Limit in GB": "Limit in GB", - "If you set a limit, playing videos may exceed your limit until cleanup runs every 30 minutes.": "If you set a limit, playing videos may exceed your limit until cleanup runs every 30 minutes.", "Enable Data Hosting": "Enable Data Hosting", "Data over the limit will be deleted within 30 minutes. This will make the Yrbl cry a little bit.": "Data over the limit will be deleted within 30 minutes. This will make the Yrbl cry a little bit.", "Choose %asset%": "Choose %asset%", @@ -2228,13 +2221,6 @@ "Enable Prerelease Updates": "Enable Prerelease Updates", "Enable Upgrade to Test Builds": "Enable Upgrade to Test Builds", "Prereleases may break things and we may not be able to fix them for you.": "Prereleases may break things and we may not be able to fix them for you.", - "Limit (GB)": "Limit (GB)", - "Limit Hosting for Content you Use": "Limit Hosting for Content you Use", - "Allow (GB)": "Allow (GB)", - "Content Data Hosting helps to seed things that you watch and download.": "Content Data Hosting helps to seed things that you watch and download.", - "Network Data Hosting allows the p2p network to store blobs unrelated to your browsing.": "Network Data Hosting allows the p2p network to store blobs unrelated to your browsing.", - "Content: Limit (GB)": "Content: Limit (GB)", - "Network: Allow (GB)": "Network: Allow (GB)", "A channel is required to repost on LBRY": "A channel is required to repost on LBRY", "Admin": "Admin", "Stickers": "Stickers", @@ -2256,19 +2242,6 @@ "Move Up": "Move Up", "Move Down": "Move Down", "Trending for #Game": "Trending for #Game", - "Help the P2P data network by hosting data.": "Help the P2P data network by hosting data.", - "History hosting lets you choose how much storage to use helping content you've consumed.": "History hosting lets you choose how much storage to use helping content you've consumed.", - "Automatic hosting lets you delegate some amount of storage for the network to automatically download ad host.": "Automatic hosting lets you delegate some amount of storage for the network to automatically download ad host.", - "Playing videos may exceed your history hosting limit until cleanup runs every 30 minutes.": "Playing videos may exceed your history hosting limit until cleanup runs every 30 minutes.", - "History: Limit (GB)": "History: Limit (GB)", - "Automatic: Allow (GB)": "Automatic: Allow (GB)", - "Automatic hosting lets you delegate some amount of storage for the network to automatically download and host.": "Automatic hosting lets you delegate some amount of storage for the network to automatically download and host.", - "History Hosting": "History Hosting", - "Automatic Hosting": "Automatic Hosting", - "History Hosting lets you choose how much storage to use helping content you've consumed.": "History Hosting lets you choose how much storage to use helping content you've consumed.", - "Automatic Hosting lets you delegate some amount of storage for the network to automatically download and host.": "Automatic Hosting lets you delegate some amount of storage for the network to automatically download and host.", - "Help improve the P2P data network (and make LBRY happy) by hosting data.": "Help improve the P2P data network (and make LBRY happy) by hosting data.", - "Limit Hosting of Content History": "Limit Hosting of Content History", "Remove custom comment server": "Remove custom comment server", "Use Https": "Use Https", "Server URL": "Server URL", @@ -2315,8 +2288,27 @@ "Clear Views": "Clear Views", "Show Video View Progress": "Show Video View Progress", "Display view progress on thumbnail. This setting will not hide any blockchain activity or downloads.": "Display view progress on thumbnail. This setting will not hide any blockchain activity or downloads.", - "%anonymous%": "%anonymous%", - "Anon --[used in <%anonymous% Reposted>]--": "Anon", - "This will be visible in a few minutes after you submit this form.": "This will be visible in a few minutes after you submit this form.", + "Content Hosting": "Content Hosting", + "Hosting": "Hosting", + "Viewed Hosting": "Viewed Hosting", + "Auto Hosting": "Auto Hosting", + "Help creators and improve the P2P data network by hosting content.": "Help creators and improve the P2P data network by hosting content.", + "I'm happy with my settings": "I'm happy with my settings", + "We've noticed you already have some settings.": "We've noticed you already have some settings.", + "You choose how much data to host.": "You choose how much data to host.", + "Go back": "Go back", + "Custom Hosting": "Custom Hosting", + "Automatic Hosting (GB)": "Automatic Hosting (GB)", + "* Note that as peer-to-peer software, your IP address and potentially other system information can be sent to other users, though this information is not stored permanently.": "* Note that as peer-to-peer software, your IP address and potentially other system information can be sent to other users, though this information is not stored permanently.", + "Help improve the P2P data network (and make LBRY users happy) by hosting data.": "Help improve the P2P data network (and make LBRY users happy) by hosting data.", + "View History Hosting lets you choose how much storage to use hosting content you've consumed.": "View History Hosting lets you choose how much storage to use hosting content you've consumed.", + "Automatic Hosting downloads a small portion of content active on the network.": "Automatic Hosting downloads a small portion of content active on the network.", + "Publishes --[legend, storage category]--": "Publishes", + "Auto Hosting --[legend, storage category]--": "Auto Hosting", + "View Hosting --[legend, storage category]--": "View Hosting", + "%spaceUsed% of %limit% GB": "%spaceUsed% of %limit% GB", + "%spaceUsed% of %limit% Free GB": "%spaceUsed% of %limit% Free GB", + "Disabled": "Disabled", + "Free --[legend, unused disk space]--": "Free", "--end--": "--end--" } diff --git a/ui/component/appStorageVisualization/index.js b/ui/component/appStorageVisualization/index.js new file mode 100644 index 000000000..c4b20ecd9 --- /dev/null +++ b/ui/component/appStorageVisualization/index.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import StorageViz from './view'; +import { + selectViewBlobSpace, + selectViewHostingLimit, + selectAutoBlobSpace, + selectPrivateBlobSpace, + selectAutoHostingLimit, +} from 'redux/selectors/settings'; +import { selectDiskSpace } from 'redux/selectors/app'; + +const select = (state) => ({ + diskSpace: selectDiskSpace(state), + viewHostingLimit: selectViewHostingLimit(state), + autoHostingLimit: selectAutoHostingLimit(state), + viewBlobSpace: selectViewBlobSpace(state), + autoBlobSpace: selectAutoBlobSpace(state), + privateBlobSpace: selectPrivateBlobSpace(state), +}); + +export default connect(select)(StorageViz); diff --git a/ui/component/appStorageVisualization/view.jsx b/ui/component/appStorageVisualization/view.jsx new file mode 100644 index 000000000..c8d40cbe9 --- /dev/null +++ b/ui/component/appStorageVisualization/view.jsx @@ -0,0 +1,126 @@ +// @flow +import * as React from 'react'; +import I18nMessage from 'component/i18nMessage'; + +type Props = { + // --- select --- + diskSpace: DiskSpace, // KB + viewHostingLimit: number, // MB + autoHostingLimit: number, + viewBlobSpace: number, + autoBlobSpace: number, + privateBlobSpace: number, +}; + +function StorageViz(props: Props) { + const { diskSpace, viewHostingLimit, autoHostingLimit, viewBlobSpace, autoBlobSpace, privateBlobSpace } = props; + + if (!diskSpace || !diskSpace.total) { + return ( +
+
+
{__('Cannot get disk space information.')}
+
+
+ ); + } + + const totalMB = diskSpace && Math.floor(Number(diskSpace.total) / 1024); + const freeMB = diskSpace && Math.floor(Number(diskSpace.free) / 1024); + const otherMB = totalMB - (freeMB + viewBlobSpace + autoBlobSpace + privateBlobSpace); + const autoFree = autoHostingLimit - autoBlobSpace; + const viewFree = viewHostingLimit > 0 ? viewHostingLimit - viewBlobSpace : freeMB - autoFree; + const unallocFree = freeMB - viewFree - autoFree; + const viewLimit = + viewHostingLimit === 0 + ? freeMB - (autoHostingLimit - autoBlobSpace) + viewBlobSpace + : viewHostingLimit + viewBlobSpace; + + const getPercent = (val, lim = totalMB) => (val / lim) * 100; + const getGB = (val) => (Number(val) / 1024).toFixed(2); + + const otherPercent = getPercent(otherMB); + const privatePercent = getPercent(privateBlobSpace); + const autoLimitPercent = getPercent(autoHostingLimit); + const viewLimitPercent = getPercent(viewLimit); + const viewUsedPercentOfLimit = getPercent(viewBlobSpace, viewLimit); + const autoUsedPercentOfLimit = getPercent(autoBlobSpace, autoHostingLimit); + + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ {viewHostingLimit !== 0 &&
} +
+
+
+
+
+ +
{`${getGB(privateBlobSpace)} GB`}
+
+
+
+
+
+ +
+ {autoHostingLimit === 0 ? ( + __('Disabled') + ) : ( + + %spaceUsed% of %limit% GB + + )} +
+
+
+
+
+
+ +
+ {viewHostingLimit === 1 ? ( + __('Disabled') + ) : ( + + %spaceUsed% of %limit% Free GB + + )} +
+
+
+ {viewHostingLimit !== 0 && ( +
+
+
+ +
{`${getGB(unallocFree)} GB`}
+
+
+ )} +
+
+ ); +} + +export default StorageViz; diff --git a/ui/component/hostingSplash/index.js b/ui/component/hostingSplash/index.js new file mode 100644 index 000000000..469b4cba6 --- /dev/null +++ b/ui/component/hostingSplash/index.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; + +import HostingSplash from './view'; +import { + selectViewBlobSpace, + selectViewHostingLimit, + selectAutoBlobSpace, + selectAutoHostingLimit, + selectSaveBlobs, +} from 'redux/selectors/settings'; +import { doSetDaemonSetting } from 'redux/actions/settings'; +import { selectDiskSpace } from 'redux/selectors/app'; + +const select = (state) => ({ + diskSpace: selectDiskSpace(state), + viewHostingLimit: selectViewHostingLimit(state), + autoHostingLimit: selectAutoHostingLimit(state), + viewBlobSpace: selectViewBlobSpace(state), + autoBlobSpace: selectAutoBlobSpace(state), + saveBlobs: selectSaveBlobs(state), +}); + +const perform = (dispatch) => ({ + setDaemonSetting: (key, value) => dispatch(doSetDaemonSetting(key, value)), +}); + +export default connect(select, perform)(HostingSplash); diff --git a/ui/component/hostingSplash/view.jsx b/ui/component/hostingSplash/view.jsx new file mode 100644 index 000000000..efe9472a1 --- /dev/null +++ b/ui/component/hostingSplash/view.jsx @@ -0,0 +1,156 @@ +// @flow +import React from 'react'; +import Button from 'component/button'; +import { FormField } from 'component/common/form-components/form-field'; +import { Form } from 'component/common/form-components/form'; +import { withRouter } from 'react-router-dom'; + +// $FlowFixMe cannot resolve ... +import image from 'static/img/yrblhappy.svg'; +import * as DAEMON_SETTINGS from 'constants/daemon_settings'; + +type SetDaemonSettingArg = boolean | string | number; + +type Props = { + handleNextPage: () => void, + handleDone: () => void, + setDaemonSetting: (string, ?SetDaemonSettingArg) => void, + // --- select --- + diskSpace: DiskSpace, // KB + viewHostingLimit: number, // MB + autoHostingLimit: number, + viewBlobSpace: number, + autoBlobSpace: number, + privateBlobSpace: number, + saveBlobs: boolean, +}; + +const TWENTY_PERCENT = 0.2; +const TEN_PRECNT = 0.1; + +function HostingSplash(props: Props) { + const { + handleNextPage, + diskSpace, + viewHostingLimit, + autoHostingLimit, + viewBlobSpace, + autoBlobSpace, + saveBlobs, + setDaemonSetting, + handleDone, + } = props; + + const totalMB = diskSpace && Math.floor(Number(diskSpace.total) / 1024); + const freeMB = diskSpace && Math.floor(Number(diskSpace.free) / 1024); + const blobSpaceUsed = viewBlobSpace + autoBlobSpace; + + const [hostingChoice, setHostingChoice] = React.useState('MANAGED'); + function handleSubmit() { + if (hostingChoice === 'CUSTOM') { + handleNextPage(); + } else { + handleAuto(); + } + } + + function getManagedLimitMB() { + const value = + freeMB > totalMB * TWENTY_PERCENT // lots of free space? + ? blobSpaceUsed > totalMB * TEN_PRECNT // using more than 10%? + ? (freeMB + blobSpaceUsed) / 2 // e.g. 40g used plus 30g free, knock back to 35g limit, freeing to 35g + : totalMB * TEN_PRECNT // let it go up to 10% + : (freeMB + blobSpaceUsed) / 2; // e.g. 40g used plus 10g free, knock back to 25g limit, freeing to 25g + return value > 10240 ? Math.floor(value / 1024) * 1024 : 0; + } + + function getAutoLimit() { + // return floor of 10% of total + const totalGB = Math.floor(getManagedLimitMB() / 1024); // eg, 25GB + return Math.floor(totalGB / 10) * 1024; // eg, 2 GB -> 2048MB + } + + function getViewedLimit() { + return getManagedLimitMB() - getAutoLimit(); + } + + function getManagedCopy() { + if (viewHostingLimit || autoHostingLimit || !saveBlobs) { + return __("I'm happy with my settings"); + } else if (getManagedLimitMB() > 0) { + return __(`Host up to %percent% of my drive (%limit% GB)`, { + percent: `${Math.round((Math.floor(getManagedLimitMB() / 1024) / Math.floor(totalMB / 1024)) * 100)}%`, + limit: Math.floor(getManagedLimitMB() / 1024), + }); + } else { + return __(`Not now, my disk is almost full.`); + } + } + + function getManagedHelper() { + if (viewHostingLimit || autoHostingLimit || !saveBlobs) { + return __(`We've noticed you already have some settings.`); + } else if (getManagedLimitMB() > 0) { + return __(`Donate space without filling up your drive.`); + } else { + return __(`You can clear some space and check hosting settings later.`); + } + } + + async function handleAuto() { + if (viewHostingLimit || autoHostingLimit || !saveBlobs) { + handleDone(); + } else if (getManagedLimitMB() > 0) { + // limit to used // maybe move this to a single action function that doesn't live inside the component. + await setDaemonSetting(DAEMON_SETTINGS.BLOB_STORAGE_LIMIT_MB, getViewedLimit()); + await setDaemonSetting(DAEMON_SETTINGS.NETWORK_STORAGE_LIMIT_MB, getAutoLimit()); + handleDone(); + } else { + // running low on space + handleDone(); + } + } + + return ( +
+
+
+
+

{__('Hosting')}

+

+ {__('Help creators and improve the P2P data network by hosting content.')} +

+
+ setHostingChoice('MANAGED')} + /> + {__('Custom')}} + helper={__(`You choose how much data to host.`)} + onChange={(e) => setHostingChoice('CUSTOM')} + /> +
+
+
+
+
+
+
+
+ +
+
+
+ ); +} + +export default withRouter(HostingSplash); diff --git a/ui/component/hostingSplashCustom/index.js b/ui/component/hostingSplashCustom/index.js new file mode 100644 index 000000000..2385a145a --- /dev/null +++ b/ui/component/hostingSplashCustom/index.js @@ -0,0 +1,3 @@ +import HostingSplashCustom from './view'; + +export default HostingSplashCustom; diff --git a/ui/component/hostingSplashCustom/view.jsx b/ui/component/hostingSplashCustom/view.jsx new file mode 100644 index 000000000..86b1e85e2 --- /dev/null +++ b/ui/component/hostingSplashCustom/view.jsx @@ -0,0 +1,31 @@ +// @flow +import React from 'react'; +import Button from 'component/button'; +import { Form } from 'component/common/form-components/form'; +import SettingStorage from 'component/settingStorage'; +import { withRouter } from 'react-router-dom'; + +type Props = { + handleNextPage: () => void, + handleGoBack: () => void, +}; + +function HostingSplashCustom(props: Props) { + const { handleNextPage, handleGoBack } = props; + + return ( +
+
+ +
+
+
+
+
+
+ ); +} + +export default withRouter(HostingSplashCustom); diff --git a/ui/component/privacyAgreement/view.jsx b/ui/component/privacyAgreement/view.jsx index b0d0715c8..af9189d40 100644 --- a/ui/component/privacyAgreement/view.jsx +++ b/ui/component/privacyAgreement/view.jsx @@ -72,9 +72,9 @@ function PrivacyAgreement(props: Props) { {__('No')} 😢 } - helper={__(`* Note that as - peer-to-peer software, your IP address and potentially other system information can be sent to other - users, though this information is not stored permanently.`)} + helper={__( + `* Note that as peer-to-peer software, your IP address and potentially other system information can be sent to other users, though this information is not stored permanently.` + )} onChange={(e) => setShare(NONE)} /> {authenticated && ( @@ -92,7 +92,7 @@ function PrivacyAgreement(props: Props) { )}
-
{share === NONE && (

diff --git a/ui/component/settingDataHosting/index.js b/ui/component/settingDataHosting/index.js index c001ae8d1..d3c67819f 100644 --- a/ui/component/settingDataHosting/index.js +++ b/ui/component/settingDataHosting/index.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import { doSetDaemonSetting, doGetDaemonStatus, doCleanBlobs } from 'redux/actions/settings'; -import { selectDaemonStatus, selectDaemonSettings } from 'redux/selectors/settings'; +import { selectDaemonStatus, selectDaemonSettings, selectSettingDaemonSettings } from 'redux/selectors/settings'; import SettingWalletServer from './view'; import { selectDiskSpace } from 'redux/selectors/app'; @@ -8,6 +8,7 @@ const select = (state) => ({ daemonSettings: selectDaemonSettings(state), daemonStatus: selectDaemonStatus(state), diskSpace: selectDiskSpace(state), + isSetting: selectSettingDaemonSettings(state), }); const perform = (dispatch) => ({ diff --git a/ui/component/settingDataHosting/view.jsx b/ui/component/settingDataHosting/view.jsx index d2d05b458..43bf1e668 100644 --- a/ui/component/settingDataHosting/view.jsx +++ b/ui/component/settingDataHosting/view.jsx @@ -4,89 +4,36 @@ import React from 'react'; import { FormField } from 'component/common/form'; import Button from 'component/button'; import * as DAEMON_SETTINGS from 'constants/daemon_settings'; -import { formatBytes } from 'util/format-bytes'; import { isTrulyANumber } from 'util/number'; -import I18nMessage from 'component/i18nMessage'; -const BYTES_PER_MB = 1048576; -const ENABLE_AUTOMATIC_HOSTING = false; +import * as ICONS from 'constants/icons'; +import * as KEYCODES from 'constants/keycodes'; -type Price = { - currency: string, - amount: number, -}; +import { convertGbToMbStr, isValidHostingAmount } from 'util/hosting'; -type DaemonStatus = { - disk_space: { - content_blobs_storage_used_mb: string, - published_blobs_storage_used_mb: string, - running: true, - seed_blobs_storage_used_mb: string, - total_used_mb: string, - }, -}; - -type SetDaemonSettingArg = boolean | string | number | Price; +type SetDaemonSettingArg = boolean | string | number; type DaemonSettings = { - download_dir: string, - share_usage_data: boolean, - max_key_fee?: Price, - max_connections_per_download?: number, - save_files: boolean, save_blobs: boolean, - ffmpeg_path: string, }; type Props = { // --- select --- daemonSettings: DaemonSettings, - daemonStatus: DaemonStatus, // --- perform --- setDaemonSetting: (string, ?SetDaemonSettingArg) => void, cleanBlobs: () => string, - diskSpace?: DiskSpace, getDaemonStatus: () => void, + isSetting: boolean, }; function SettingDataHosting(props: Props) { - const { daemonSettings, daemonStatus, setDaemonSetting, cleanBlobs, diskSpace, getDaemonStatus } = props; + const { daemonSettings, setDaemonSetting, cleanBlobs, getDaemonStatus, isSetting } = props; - const { disk_space: blobSpace } = daemonStatus; - const contentSpaceUsed = Number(blobSpace.content_blobs_storage_used_mb); - const blobLimitSetting = daemonSettings[DAEMON_SETTINGS.BLOB_STORAGE_LIMIT_MB] || '0'; - const [contentBlobSpaceLimitGB, setContentBlobSpaceLimit] = React.useState( - blobLimitSetting ? String(blobLimitSetting / 1024) : '10' - ); - const [applying, setApplying] = React.useState(false); - - const networkSpaceUsed = Number(blobSpace.seed_blobs_storage_used_mb); - const networkLimitSetting = daemonSettings[DAEMON_SETTINGS.NETWORK_STORAGE_LIMIT_MB] || '0'; + const networkLimitSetting = daemonSettings[DAEMON_SETTINGS.NETWORK_STORAGE_LIMIT_MB] || 0; const [networkBlobSpaceLimitGB, setNetworkBlobSpaceLimit] = React.useState( - networkLimitSetting ? String(networkLimitSetting / 1024) : '0' + networkLimitSetting ? String(networkLimitSetting / 1024) : 0 ); - const [unlimited, setUnlimited] = React.useState(blobLimitSetting === '0'); - - React.useEffect(() => { - getDaemonStatus(); - }, []); - - function convertGbToMb(gb) { - return Number(gb) * 1024; - } - - function handleContentLimitChange(gb) { - if (gb === '') { - setContentBlobSpaceLimit(''); - } else if (gb === '0') { - setContentBlobSpaceLimit('0.01'); // setting 0 means unlimited. - } else { - if (isTrulyANumber(Number(gb))) { - setContentBlobSpaceLimit(gb); - } - } - } - function handleNetworkLimitChange(gb) { if (gb === '') { setNetworkBlobSpaceLimit(''); @@ -98,109 +45,68 @@ function SettingDataHosting(props: Props) { } } - async function handleApply() { - setApplying(true); - if (unlimited) { - await setDaemonSetting(DAEMON_SETTINGS.BLOB_STORAGE_LIMIT_MB, '0'); - } else { - await setDaemonSetting( - DAEMON_SETTINGS.BLOB_STORAGE_LIMIT_MB, - String(contentBlobSpaceLimitGB === '0.01' ? '1' : convertGbToMb(contentBlobSpaceLimitGB)) - ); + function handleKeyDown(e) { + if (e.keyCode === KEYCODES.ESCAPE) { + e.preventDefault(); + setNetworkBlobSpaceLimit(String(networkLimitSetting / 1024)); + } else if (e.keyCode === KEYCODES.ENTER) { + e.preventDefault(); + handleApply(); } + } + + async function handleApply() { await setDaemonSetting( DAEMON_SETTINGS.NETWORK_STORAGE_LIMIT_MB, - String(convertGbToMb(Number(networkBlobSpaceLimitGB))) + String(convertGbToMbStr(Number(networkBlobSpaceLimitGB))) ); await cleanBlobs(); getDaemonStatus(); - setApplying(false); - } - - function validHostingAmount(amountString) { - const numberAmount = Number(amountString); - return amountString.length && ((numberAmount && String(numberAmount)) || numberAmount === 0); } return ( <>

setDaemonSetting('save_blobs', !daemonSettings.save_blobs)} - checked={daemonSettings.save_blobs} - label={__('Enable Data Hosting')} - helper={ - diskSpace && ( - - %free% of %total% available - - ) + name="network_blob_limit_gb" + type="number" + label={__(`Automatic Hosting (GB)`)} + disabled={!daemonSettings.save_blobs || isSetting} + onKeyDown={handleKeyDown} + inputButton={ + <> +
- {daemonSettings.save_blobs && ( -
- setUnlimited(true)} - /> - setUnlimited(false)} - label={__('Choose View Hosting Limit')} - /> - {!unlimited && ( - e.preventDefault()} - label={__(`View Hosting Limit (GB)`)} - onChange={(e) => handleContentLimitChange(e.target.value)} - value={Number(contentBlobSpaceLimitGB) <= Number('0.01') ? '0' : contentBlobSpaceLimitGB} - /> - )} -
{`Currently using ${formatBytes(contentSpaceUsed * BYTES_PER_MB)}`}
-
- )} - {daemonSettings.save_blobs && ENABLE_AUTOMATIC_HOSTING && ( - - handleNetworkLimitChange(e.target.value)} - value={networkBlobSpaceLimitGB} - /> -
{`Auto-hosting ${formatBytes(networkSpaceUsed * BYTES_PER_MB)}`}
-
- )} -
-
diff --git a/ui/component/settingSaveBlobs/index.js b/ui/component/settingSaveBlobs/index.js new file mode 100644 index 000000000..924c79bc7 --- /dev/null +++ b/ui/component/settingSaveBlobs/index.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { doSetDaemonSetting, doGetDaemonStatus } from 'redux/actions/settings'; +import { selectDaemonSettings } from 'redux/selectors/settings'; +import SettingWalletServer from './view'; +import { selectDiskSpace } from 'redux/selectors/app'; + +const select = (state) => ({ + daemonSettings: selectDaemonSettings(state), + diskSpace: selectDiskSpace(state), +}); + +const perform = (dispatch) => ({ + getDaemonStatus: () => dispatch(doGetDaemonStatus()), + setDaemonSetting: (key, value) => dispatch(doSetDaemonSetting(key, value)), +}); + +export default connect(select, perform)(SettingWalletServer); diff --git a/ui/component/settingSaveBlobs/view.jsx b/ui/component/settingSaveBlobs/view.jsx new file mode 100644 index 000000000..772e35272 --- /dev/null +++ b/ui/component/settingSaveBlobs/view.jsx @@ -0,0 +1,34 @@ +// @flow + +import React from 'react'; +import { FormField } from 'component/common/form'; + +type SetDaemonSettingArg = boolean | string | number; + +type DaemonSettings = { + save_blobs: boolean, +}; + +type Props = { + // --- select --- + daemonSettings: DaemonSettings, + // --- perform --- + setDaemonSetting: (string, ?SetDaemonSettingArg) => void, +}; + +function SettingDataHosting(props: Props) { + const { daemonSettings, setDaemonSetting } = props; + + return ( + <> + setDaemonSetting('save_blobs', !daemonSettings.save_blobs)} + checked={daemonSettings.save_blobs} + /> + + ); +} + +export default SettingDataHosting; diff --git a/ui/component/settingStorage/index.js b/ui/component/settingStorage/index.js new file mode 100644 index 000000000..5bb20322e --- /dev/null +++ b/ui/component/settingStorage/index.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { doWalletStatus } from 'redux/actions/wallet'; +import { doClearCache } from 'redux/actions/app'; +import { doSetDaemonSetting, doClearDaemonSetting, doCleanBlobs } from 'redux/actions/settings'; +import { selectDaemonSettings, selectDaemonStatus, selectSettingDaemonSettings } from 'redux/selectors/settings'; + +import SettingStorage from './view'; +import { selectDiskSpace } from 'redux/selectors/app'; + +const select = (state) => ({ + daemonSettings: selectDaemonSettings(state), + diskSpace: selectDiskSpace(state), + daemonStatus: selectDaemonStatus(state), + isSetting: selectSettingDaemonSettings(state), +}); + +const perform = (dispatch) => ({ + setDaemonSetting: (key, value) => dispatch(doSetDaemonSetting(key, value)), + clearDaemonSetting: (key) => dispatch(doClearDaemonSetting(key)), + clearCache: () => dispatch(doClearCache()), + cleanBlobs: () => dispatch(doCleanBlobs()), + updateWalletStatus: () => dispatch(doWalletStatus()), +}); + +export default connect(select, perform)(SettingStorage); diff --git a/ui/component/settingStorage/view.jsx b/ui/component/settingStorage/view.jsx new file mode 100644 index 000000000..cdc06f442 --- /dev/null +++ b/ui/component/settingStorage/view.jsx @@ -0,0 +1,94 @@ +// @flow +import { SETTINGS_GRP } from 'constants/settings'; +import React from 'react'; +import Button from 'component/button'; +import Card from 'component/common/card'; +import SettingDataHosting from 'component/settingDataHosting'; +import SettingViewHosting from 'component/settingViewHosting'; +import SettingSaveBlobs from 'component/settingSaveBlobs'; +import SettingsRow from 'component/settingsRow'; +import AppStorageViz from 'component/appStorageVisualization'; +import Spinner from 'component/spinner'; +import classnames from 'classnames'; + +type DaemonSettings = { + save_blobs: boolean, +}; + +type Props = { + daemonSettings: DaemonSettings, + isSetting: boolean, + isWelcome?: boolean, + cleanBlobs: () => Promise, +}; + +export default function SettingStorage(props: Props) { + const { daemonSettings, isSetting, isWelcome, cleanBlobs } = props; + + const saveBlobs = daemonSettings && daemonSettings.save_blobs; + const [isCleaning, setCleaning] = React.useState(false); + + // currently, it seems, blob space statistics are only updated during clean + React.useEffect(() => { + setCleaning(true); + cleanBlobs().then(() => { + setCleaning(false); + }); + }, []); + + return ( + <> +
+

+ {isWelcome ? __('Custom Hosting') : __('Hosting')} + {(isSetting || isCleaning) && } +

+
+ + + {__('Help improve the P2P data network (and make LBRY users happy) by hosting data.')} + + } + footer={} + > + + + + {__("View History Hosting lets you choose how much storage to use hosting content you've consumed.")}{' '} +
+ + ); +} + +export default SettingViewHosting; diff --git a/ui/component/settingsRow/view.jsx b/ui/component/settingsRow/view.jsx index f6864e3db..dd2e78623 100644 --- a/ui/component/settingsRow/view.jsx +++ b/ui/component/settingsRow/view.jsx @@ -9,10 +9,11 @@ type Props = { useVerticalSeparator?: boolean, // Show a separator line between Label and Value. Useful when there are multiple Values. disabled?: boolean, children?: React$Node, + footer?: React$Node, }; export default function SettingsRow(props: Props) { - const { title, subtitle, multirow, useVerticalSeparator, disabled, children } = props; + const { title, subtitle, multirow, useVerticalSeparator, disabled, children, footer } = props; return (

{title}

{subtitle &&

{subtitle}

} + {footer && footer}
= [ section: SETTINGS_GRP.SYSTEM, icon: ICONS.SETTINGS, }, + { + title: 'Content Hosting', + section: SETTINGS_GRP.STORAGE, + icon: ICONS.PUBLISH, + }, ]; export default function SettingsSideNavigation() { diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index ab04618ee..f3baef23b 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -249,6 +249,7 @@ export const SYNC_CLIENT_SETTINGS = 'SYNC_CLIENT_SETTINGS'; export const DAEMON_STATUS_RECEIVED = 'DAEMON_STATUS_RECEIVED'; export const SHARED_PREFERENCE_SET = 'SHARED_PREFERENCE_SET'; export const SAVE_CUSTOM_WALLET_SERVERS = 'SAVE_CUSTOM_WALLET_SERVERS'; +export const SETTING_DAEMON_SETTINGS = 'SETTING_DAEMON_SETTINGS'; // User export const AUTHENTICATION_STARTED = 'AUTHENTICATION_STARTED'; diff --git a/ui/constants/settings.js b/ui/constants/settings.js index d73339b38..a56b28fc3 100644 --- a/ui/constants/settings.js +++ b/ui/constants/settings.js @@ -53,4 +53,5 @@ export const SETTINGS_GRP = { ACCOUNT: 'account', CONTENT: 'content', SYSTEM: 'system', + STORAGE: 'Storage', }; diff --git a/ui/page/settings/view.jsx b/ui/page/settings/view.jsx index c3817fbe1..9d8ae3816 100644 --- a/ui/page/settings/view.jsx +++ b/ui/page/settings/view.jsx @@ -5,6 +5,7 @@ import SettingAccount from 'component/settingAccount'; import SettingAppearance from 'component/settingAppearance'; import SettingContent from 'component/settingContent'; import SettingSystem from 'component/settingSystem'; +import SettingStorage from 'component/settingStorage'; type DaemonSettings = { download_dir: string, @@ -51,6 +52,7 @@ class SettingsPage extends React.PureComponent { +
)} diff --git a/ui/page/welcome/view.jsx b/ui/page/welcome/view.jsx index 015f9db46..9885b404b 100644 --- a/ui/page/welcome/view.jsx +++ b/ui/page/welcome/view.jsx @@ -1,14 +1,17 @@ // @flow import React from 'react'; import PrivacyAgreement from 'component/privacyAgreement'; +import HostingSplash from 'component/hostingSplash'; +import HostingSplashCustom from 'component/hostingSplashCustom'; import WelcomeSplash from 'component/welcomeSplash'; import Page from 'component/page'; import { useHistory } from 'react-router-dom'; const SPLASH_PAGE = 0; const PRIVACY_PAGE = 1; -// const HOSTING_PAGE = 2; -// const WELCOME_PAGES = [SPLASH_PAGE, PRIVACY_PAGE]; +const HOSTING_PAGE = 2; +const HOSTING_ADVANCED = 3; + type DaemonStatus = { disk_space: { content_blobs_storage_used_mb: string, @@ -44,6 +47,16 @@ export default function Welcome(props: Props) { const handleNextPage = () => { if (welcomePage === SPLASH_PAGE) { setWelcomePage(PRIVACY_PAGE); + } else if (welcomePage === PRIVACY_PAGE) { + setWelcomePage(HOSTING_PAGE); + } else if (welcomePage === HOSTING_PAGE) { + setWelcomePage(HOSTING_ADVANCED); + } + }; + + const handleGoBack = () => { + if (welcomePage >= 1) { + setWelcomePage(welcomePage - 1); } }; @@ -55,8 +68,11 @@ export default function Welcome(props: Props) { return ( {welcomePage === SPLASH_PAGE && } - {welcomePage === PRIVACY_PAGE && } - {/* {welcomePage === HOSTING_PAGE && } */} + {welcomePage === PRIVACY_PAGE && } + {welcomePage === HOSTING_PAGE && } + {welcomePage === HOSTING_ADVANCED && ( + + )} ); } diff --git a/ui/redux/actions/comments.js b/ui/redux/actions/comments.js index 009b0c244..375a96757 100644 --- a/ui/redux/actions/comments.js +++ b/ui/redux/actions/comments.js @@ -1197,7 +1197,7 @@ export function doFetchModBlockedList() { if (blockedChannel.blocked_channel_name) { const channelUri = buildURI({ channelName: blockedChannel.blocked_channel_name, - claimId: blockedChannel.blocked_channel_id, + channelClaimId: blockedChannel.blocked_channel_id, }); if (!blockedList.find((blockedChannel) => isURIEqual(blockedChannel.channelUri, channelUri))) { @@ -1215,7 +1215,7 @@ export function doFetchModBlockedList() { if (blockedByMap !== undefined) { const blockedByChannelUri = buildURI({ channelName: blockedChannel.blocked_by_channel_name, - claimId: blockedChannel.blocked_by_channel_id, + channelClaimId: blockedChannel.blocked_by_channel_id, }); if (blockedByMap[channelUri]) { diff --git a/ui/redux/actions/settings.js b/ui/redux/actions/settings.js index bab4db508..aaf8b75dc 100644 --- a/ui/redux/actions/settings.js +++ b/ui/redux/actions/settings.js @@ -104,35 +104,59 @@ export function doSetDaemonSetting(key, value, doNotDispatch = false) { return (dispatch, getState) => { const state = getState(); const ready = selectPrefsReady(state); - if (!ready) { return dispatch(doAlertWaitingForSync()); } - + dispatch({ + type: ACTIONS.SETTING_DAEMON_SETTINGS, + data: { + val: true, + }, + }); const newSettings = { key, value: !value && value !== false ? null : value, }; - Lbry.settings_set(newSettings).then((newSetting) => { - if (SDK_SYNC_KEYS.includes(key) && !doNotDispatch) { + Lbry.settings_set(newSettings) + .then((newSetting) => { + if (SDK_SYNC_KEYS.includes(key) && !doNotDispatch) { + dispatch({ + type: ACTIONS.SHARED_PREFERENCE_SET, + data: { key: key, value: newSetting[key] }, + }); + } + // hardcoding this in lieu of a better solution + if (key === DAEMON_SETTINGS.LBRYUM_SERVERS) { + dispatch(doWalletReconnect()); + // todo: add sdk reloadsettings() (or it happens automagically?) + } + }) + .then(() => { + dispatch(doFetchDaemonSettings()); + }) + .then(() => { dispatch({ - type: ACTIONS.SHARED_PREFERENCE_SET, - data: { key: key, value: newSetting[key] }, + type: ACTIONS.SETTING_DAEMON_SETTINGS, + data: { + val: false, + }, }); - } - // hardcoding this in lieu of a better solution - if (key === DAEMON_SETTINGS.LBRYUM_SERVERS) { - dispatch(doWalletReconnect()); - // todo: add sdk reloadsettings() (or it happens automagically?) - } - }); - dispatch(doFetchDaemonSettings()); + }) + .catch((e) => { + console.log('error setting or fetching daemon setting', e.message); + dispatch({ + type: ACTIONS.SETTING_DAEMON_SETTINGS, + data: { + val: false, + }, + }); + }); }; } export function doCleanBlobs() { return (dispatch) => { - Lbry.blob_clean().then(() => { + return Lbry.blob_clean().then(() => { dispatch(doFetchDaemonSettings()); return 'done'; }); diff --git a/ui/redux/reducers/app.js b/ui/redux/reducers/app.js index db8371ccf..6fbdde650 100644 --- a/ui/redux/reducers/app.js +++ b/ui/redux/reducers/app.js @@ -327,10 +327,9 @@ reducers[ACTIONS.DISK_SPACE] = (state, action) => { }; reducers[ACTIONS.SYNC_STATE_POPULATE] = (state, action) => { - const { welcomeVersion, allowAnalytics } = action.data; + const { allowAnalytics } = action.data; return { ...state, - ...(welcomeVersion !== undefined ? { welcomeVersion } : {}), ...(allowAnalytics !== undefined ? { allowAnalytics } : {}), }; }; diff --git a/ui/redux/reducers/settings.js b/ui/redux/reducers/settings.js index 7ceea70d3..36e6250db 100644 --- a/ui/redux/reducers/settings.js +++ b/ui/redux/reducers/settings.js @@ -14,6 +14,7 @@ settingLanguage.push('en'); const defaultState = { isNight: false, + isSettingDaemonSettings: false, findingFFmpeg: false, loadedLanguages: [...Object.keys(window.i18n_messages), 'en'] || ['en'], customWalletServers: [], @@ -93,6 +94,11 @@ reducers[ACTIONS.FINDING_FFMPEG_STARTED] = (state) => findingFFmpeg: true, }); +reducers[ACTIONS.SETTING_DAEMON_SETTINGS] = (state, action) => + Object.assign({}, state, { + isSettingDaemonSettings: action.data.val, + }); + reducers[ACTIONS.FINDING_FFMPEG_COMPLETED] = (state) => Object.assign({}, state, { findingFFmpeg: false, diff --git a/ui/redux/selectors/settings.js b/ui/redux/selectors/settings.js index ea1900891..40527462e 100644 --- a/ui/redux/selectors/settings.js +++ b/ui/redux/selectors/settings.js @@ -9,10 +9,23 @@ const homepages = require('homepages'); const selectState = (state) => state.settings || {}; export const selectDaemonSettings = createSelector(selectState, (state) => state.daemonSettings); +export const selectSettingDaemonSettings = createSelector(selectState, (state) => state.isSettingDaemonSettings); export const selectDaemonStatus = createSelector(selectState, (state) => state.daemonStatus); export const selectFfmpegStatus = createSelector(selectDaemonStatus, (status) => status.ffmpeg_status); +export const selectViewBlobSpace = createSelector( + selectDaemonStatus, + (status) => status.disk_space.content_blobs_storage_used_mb +); +export const selectAutoBlobSpace = createSelector( + selectDaemonStatus, + (status) => status.disk_space.seed_blobs_storage_used_mb +); +export const selectPrivateBlobSpace = createSelector( + selectDaemonStatus, + (status) => status.disk_space.published_blobs_storage_used_mb +); export const selectFindingFFmpeg = createSelector(selectState, (state) => state.findingFFmpeg || false); @@ -82,6 +95,10 @@ export const selectHomepageData = createSelector( } ); +export const selectSaveBlobs = createSelector(selectDaemonSettings, (state) => state.save_blobs || false); +export const selectAutoHostingLimit = createSelector(selectDaemonSettings, (state) => state.network_storage_limit || 0); +export const selectViewHostingLimit = createSelector(selectDaemonSettings, (state) => state.blob_storage_limit || 0); + export const selectosNotificationsEnabled = makeSelectClientSetting(SETTINGS.OS_NOTIFICATIONS_ENABLED); export const selectDisableAutoUpdates = makeSelectClientSetting(SETTINGS.DISABLE_AUTO_UPDATES); diff --git a/ui/scss/component/_form-field.scss b/ui/scss/component/_form-field.scss index c68788a80..0f5d9e8dd 100644 --- a/ui/scss/component/_form-field.scss +++ b/ui/scss/component/_form-field.scss @@ -172,17 +172,28 @@ input-submit { margin: 0; } + & > *:not(:first-child):not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + & > *:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); + border-right: none; + } + + & > *:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); border-right: none; } - // FIX THIS e.g. copyable text vs editable text - //& > *:nth-child(2) { - // border-top-left-radius: 0; - // border-bottom-left-radius: 0; - // border: 1px solid var(--color-input-border); - //} } .checkbox, diff --git a/ui/scss/component/_main.scss b/ui/scss/component/_main.scss index d96af4dca..5528c020a 100644 --- a/ui/scss/component/_main.scss +++ b/ui/scss/component/_main.scss @@ -548,6 +548,7 @@ body { border-top: unset; .settings__row { + align-items: flex-start; padding: var(--spacing-s); border-bottom: 1px solid var(--color-border); .checkbox { diff --git a/ui/scss/component/_settings.scss b/ui/scss/component/_settings.scss index 63f763ef3..b9d9e3412 100644 --- a/ui/scss/component/_settings.scss +++ b/ui/scss/component/_settings.scss @@ -29,3 +29,95 @@ text-align: right; padding-top: var(--spacing-m); } + +.storage__wrapper { + .storage__bar { + bottom: 0; + left: 0; + width: 100%; + height: var(--spacing-xl); + background-color: var(--color-storage-free); + display: flex; + flex-direction: row; + justify-content: flex-start; + border-radius: var(--border-radius); + > :last-of-type { + border-bottom-right-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); + } + .storage__other { + height: 100%; + background-color: var(--color-gray-7); + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); + } + .storage__private { + height: 100%; + background-color: var(--color-storage-published); + } + box-sizing: border-box; + + .storage__viewed { + height: 100%; + background-color: var(--color-storage-viewed-free); + border: var(--color-storage-viewed) 1px solid; + box-sizing: border-box; + .storage__viewed--used { + height: 100%; + background-color: var(--color-storage-viewed); + box-sizing: border-box; + } + } + + .storage__auto { + height: 100%; + background-color: var(--color-storage-auto-free); + border: var(--color-storage-auto) 1px solid; + box-sizing: border-box; + .storage__auto--used { + height: 100%; + background-color: var(--color-storage-auto); + } + } + } + .storage__legend-wrapper { + margin-top: var(--spacing-m); + display: flex; + flex-direction: row; + justify-content: flex-start; + .storage__legend-item { + display: flex; + flex-direction: row; + .storage__legend-item-swatch { + padding: var(--spacing-xs); + margin-right: var(--spacing-s); + width: var(--spacing-l); + border-radius: var(--border-radius); + } + .storage__legend-item-label { + margin-right: var(--spacing-m); + display: flex; + flex-direction: column; + .help { + margin-top: 0; + } + } + + .storage__legend-item-swatch--other { + background-color: var(--color-gray-7); + } + .storage__legend-item-swatch--private { + background-color: var(--color-storage-published); + } + .storage__legend-item-swatch--viewed { + background-color: var(--color-storage-viewed); + } + .storage__legend-item-swatch--auto { + background-color: var(--color-storage-auto); + } + .storage__legend-item-swatch--free { + background-color: var(--color-storage-free); + } + } + } +} diff --git a/ui/scss/component/section.scss b/ui/scss/component/section.scss index 951d2cf6f..332ca7e9e 100644 --- a/ui/scss/component/section.scss +++ b/ui/scss/component/section.scss @@ -293,6 +293,9 @@ &:only-child { border-top: none; } + &.section__actions--between { + align-items: flex-start; + } } .card__main-actions.settings__row { @@ -301,6 +304,7 @@ margin-top: 0; margin-bottom: 0; } + padding-bottom: var(--spacing-m); } .settings__row--title { diff --git a/ui/scss/init/_gui.scss b/ui/scss/init/_gui.scss index 41140c433..1676015d1 100644 --- a/ui/scss/init/_gui.scss +++ b/ui/scss/init/_gui.scss @@ -901,23 +901,6 @@ img { } } -.scheduledLivestream-wrapper { - @media (max-width: $breakpoint-small) { - padding: var(--spacing-s); - padding-top: 0; - - .card__main-actions { - .claim-preview__wrapper { - a { - .button__content { - align-items: unset; - } - } - } - } - } -} - // Temporary master classes .date_time { font-size: var(--font-xsmall); diff --git a/ui/scss/themes/dark.scss b/ui/scss/themes/dark.scss index 6aa238753..20fe82b5f 100644 --- a/ui/scss/themes/dark.scss +++ b/ui/scss/themes/dark.scss @@ -200,4 +200,13 @@ radial-gradient(circle at 50% 117%, rgba(25, 25, 25, 0.2) 0, #202020 100%); --mui-background: #000; + + // storage vis + --color-storage-published: var(--color-brand-blue); + --color-storage-free: var(--color-gray-4); + --color-storage-used: var(--color-gray-7); + --color-storage-auto: #ff993c; + --color-storage-auto-free: #9f5f25; + --color-storage-viewed: #a93cff; + --color-storage-viewed-free: #602192; } diff --git a/ui/scss/themes/light.scss b/ui/scss/themes/light.scss index 2eb6d81a6..67b79ac6c 100644 --- a/ui/scss/themes/light.scss +++ b/ui/scss/themes/light.scss @@ -83,7 +83,7 @@ // Input --color-input-bg-selected: var(--color-primary-alt); --color-input-color: #111111; - --color-input-label: var(--color-gray-5); + --color-input-label: var(--color-text-base); --color-input-placeholder: #212529; --color-input-bg: var(--color-white); --color-input-border: var(--color-border); @@ -206,4 +206,13 @@ --mui-background: #fff; --mui-button: var(--color-header-button); + + // Storage vis + --color-storage-published: var(--color-brand-blue); + --color-storage-free: var(--color-gray-4); + --color-storage-used: var(--color-gray-7); + --color-storage-auto: #ff993c; + --color-storage-auto-free: #9f5f25; + --color-storage-viewed: #a93cff; + --color-storage-viewed-free: #602192; } diff --git a/ui/util/hosting.js b/ui/util/hosting.js new file mode 100644 index 000000000..f09f361bc --- /dev/null +++ b/ui/util/hosting.js @@ -0,0 +1,8 @@ +export function convertGbToMbStr(gb) { + return String(Number(gb) * 1024); +} + +export function isValidHostingAmount(amountString) { + const numberAmount = Number(amountString); + return amountString.length && ((numberAmount && String(numberAmount)) || numberAmount === 0); +}