Skip to content

Commit

Permalink
feat: remote pins on files page (#1721)
Browse files Browse the repository at this point in the history
* feat: upload progress ui (#1655)

This PR adds visual feedback for then big files are imported:
progress bar + % status.

There is also new http-client and a bunch of required fixes.

* fix: broken file list stories
* feat: upload progress ui
* chore: integrate external changes
* fix: integrate new http client
* fix: jsdom problem
* fix(cid) switch circleci to new images
* fix(e2e): test/e2e/remote-api.test.js
  This makes E2E tests for remote API less flaky:
  - leverage expect-puppeteer where possible
  - tweak navigation so it is not impacted by connection-error state
  - force refresh of status page to avoid wait for manual refresh
  * fix(ci): E2E_IPFSD_TYPE=js npm run test:e2e
  js-ipfs changed CLI path at some point recently

Co-authored-by: Marcin Rataj <[email protected]>

* chore: rollback connect-deps

* chore: go-ipfs 0.8.0-rc1

* feat: integrate remote pinning in the files page

* chore: update deps

* chore: linting fixes

* feat: add pinning services to the pinning modal

* chore: update conflicts

Co-authored-by: Irakli Gozalishvili <[email protected]>
Co-authored-by: Marcin Rataj <[email protected]>
  • Loading branch information
3 people committed Mar 19, 2021
1 parent a2eb066 commit 85f4d55
Show file tree
Hide file tree
Showing 9 changed files with 103 additions and 49 deletions.
4 changes: 3 additions & 1 deletion src/bundles/peer-bandwidth.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createSelector } from 'redux-bundler'
import last from 'it-last'

// Depends on ipfsBundle, peersBundle, routesBundle
export default function (opts) {
const bundle = function (opts) {
opts = opts || {}
// Max number of peers to update at once
opts.peerUpdateConcurrency = opts.peerUpdateConcurrency || 5
Expand Down Expand Up @@ -180,3 +180,5 @@ export default function (opts) {
)
}
}

export default bundle
95 changes: 62 additions & 33 deletions src/bundles/pinning.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ const pinningBundle = {
if (action.type === 'SET_REMOTE_PINS') {
return { ...state, remotePins: action.payload }
}
if (action.type === 'ADD_REMOTE_PIN') {
return { ...state, remotePins: [...state.remotePins, action.payload] }
}
if (action.type === 'REMOVE_REMOTE_PIN') {
return { ...state, remotePins: state.remotePins.filter(p => p.id !== action.payload.id) }
}
if (action.type === 'SET_REMOTE_PINNING_SERVICES') {
return { ...state, pinningServices: action.payload }
}
Expand All @@ -41,43 +47,43 @@ const pinningBundle = {
return state
},

doFetchRemotePins: () => async ({ dispatch, store }) => {
// const pinningServices = store.selectPinningServices()

// if (!pinningServices?.length) return

// // TODO: unmock this (e.g. const pins = ipfs.pin.remote.ls ...)
// const response = [
// {
// id: 'Pinata:UniqueIdOfPinRequest',
// status: 'queued',
// cid: 'QmQsUbcVx6Vu8vtL858FdxD3sVBE6m8uP3bjFoTzrGubmX',
// name: '26_remote.png',
// delegates: ['/dnsaddr/pin-service.example.com']
// }
// ]

// // TODO: get type of item?

// const remotePins = response.map(item => ({
// ...item,
// isRemotePin: true,
// type: item.type || 'unknown',
// size: Math.random() * 1000// TODO: files.stat in the future
// }))
doFetchRemotePins: () => async ({ dispatch, store, getIpfs }) => {
const pinningServices = store.selectPinningServices()

// // TODO: handle different status (queued = async fetch in batches to update ui?)
if (!pinningServices?.length) return

const remotePins = []
const ipfs = getIpfs()

dispatch({ type: 'SET_REMOTE_PINS', payload: remotePins })
if (!ipfs || store?.ipfs?.ipfs?.ready || !ipfs.pin.remote) return

const pinsGenerator = pinningServices.map(async service => ({
pins: await ipfs.pin.remote.ls({
service: service.name
}),
serviceName: service.name
}))

dispatch({ type: 'SET_REMOTE_PINS', payload: [] })

for await (const { pins, serviceName } of pinsGenerator) {
for await (const pin of pins) {
dispatch({
type: 'ADD_REMOTE_PIN',
payload: {
...pin,
id: `${serviceName}:${pin.cid}`
}
})
}
}
},

selectRemotePins: (state) => state.pinning.remotePins || [],

doSelectRemotePinsForFile: (file) => async ({ store }) => {
doSelectRemotePinsForFile: (file) => ({ store }) => {
const pinningServicesNames = store.selectPinningServices().map(remote => remote.name)
const remotePinForFile = store.selectRemotePins().filter(pin => pin.cid === file.cid.string)

const remotePinForFile = store.selectRemotePins().filter(pin => pin.cid.string === file.cid.string)
const servicesBeingUsed = remotePinForFile.map(pin => pin.id.split(':')[0]).filter(pinId => pinningServicesNames.includes(pinId))

return servicesBeingUsed
Expand Down Expand Up @@ -117,19 +123,42 @@ const pinningBundle = {
}
}), {}),

doSetPinning: (cid, services = []) => async ({ getIpfs, store }) => {
doSetPinning: (pin, services = []) => async ({ getIpfs, store, dispatch }) => {
const ipfs = getIpfs()
const { cid, name } = pin

const pinLocally = services.includes('local')
try {
pinLocally ? await ipfs.pin.add(cid) : await ipfs.pin.rm(cid)
} catch (e) {
console.error(e)
} finally {
await store.doPinsFetch()
}

// TODO: handle rest of services
store.selectPinningServices().forEach(async service => {
const shouldPin = services.includes(service.name)
try {
if (shouldPin) {
dispatch({
type: 'ADD_REMOTE_PIN',
payload: {
...pin,
id: `${service.name}:${pin.cid}`
}
})
await ipfs.pin.remote.add(cid, { service: service.name, name })
} else {
dispatch({
type: 'REMOVE_REMOTE_PIN',
payload: { id: `${service.name}:${pin.cid}` }
})
await ipfs.pin.remote.rm({ cid: [cid], service: service.name })
}
} catch (e) {
console.error(e)
}
})

await store.doPinsFetch()
},
doAddPinningService: ({ apiEndpoint, nickname, secretApiKey }) => async ({ getIpfs }) => {
const ipfs = getIpfs()
Expand Down
8 changes: 4 additions & 4 deletions src/components/pinning-manager/PinningManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,11 @@ const OptionsCell = ({ doRemovePinningService, name, t }) => {
<ContextMenu className="pv2 ph1" style={{ zIndex: 1001 }} visible={isContextVisible}
target={buttonRef} onDismiss={() => setContextVisibility(false)} arrowAlign="right">
{ visitServiceUrl && (
<ContextMenuItem className='pv2 ph1' onClick={ () => setContextVisibility(false) }>
<a className='link flex items-center' href={visitServiceUrl} target='_blank' rel='noopener noreferrer'>
<a className='link flex items-center' href={visitServiceUrl} target='_blank' rel='noopener noreferrer'>
<ContextMenuItem className='pv2 ph1' onClick={ () => setContextVisibility(false) }>
<StrokeExternalLink width="28" className='fill-aqua'/> <span className="ph1 charcoal">{t('visitService')}</span>
</a>
</ContextMenuItem>)
</ContextMenuItem>
</a>)
}
<ContextMenuItem className='pv2 ph1' onClick={ handleRemove }>
<StrokeCancel width="28" className='fill-aqua'/> <span className="ph1">{t('remove')}</span>
Expand Down
4 changes: 3 additions & 1 deletion src/components/pinning-manager/fixtures/pinningServices.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export default [
const services = [
{
name: 'Pinata',
icon: 'https://svgshare.com/i/M7y.svg',
Expand All @@ -19,3 +19,5 @@ export default [
addedAt: new Date(1592491648691)
}
]

export default services
4 changes: 3 additions & 1 deletion src/constants/pinning.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export default [
const pinningConstants = [
{
name: 'Pinata',
icon: 'https://ipfs.io/ipfs/QmVYXV4urQNDzZpddW4zZ9PGvcAbF38BnKWSgch3aNeViW?filename=pinata.svg',
apiEndpoint: 'https://api.pinata.cloud/psa'
}
]

export default pinningConstants
3 changes: 2 additions & 1 deletion src/files/FilesPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class FilesPage extends React.Component {
this.props.doFilesFetch()
this.props.doPinsFetch()
this.props.doFilesSizeGet()
this.props.doFetchRemotePins()
this.props.doFetchPinningServices().then(() => this.props.doFetchRemotePins())
}

componentDidUpdate (prev) {
Expand Down Expand Up @@ -316,6 +316,7 @@ export default connect(
'selectFilesPathInfo',
'doUpdateHash',
'doPinsFetch',
'doFetchPinningServices',
'doFetchRemotePins',
'doFilesFetch',
'doFilesMove',
Expand Down
4 changes: 2 additions & 2 deletions src/files/files-list/FilesList.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const addFiles = async (filesPromise, onAddFiles) => {
}

const mergeRemotePinsIntoFiles = (files, remotePins) => {
const remotePinsCids = remotePins.map(c => c.cid)
const remotePinsCids = remotePins.map(c => c.cid.string)

return files.map(f => remotePinsCids.includes(f.cid?.string) ? ({
...f,
Expand All @@ -32,7 +32,7 @@ const mergeRemotePinsIntoFiles = (files, remotePins) => {
}

export const FilesList = ({
className, files, pins, remotePins, filesSorting, updateSorting, downloadProgress, filesIsFetching, filesPathInfo, showLoadingAnimation,
className, files, pins, remotePins, filesSorting, updateSorting, downloadProgress, filesIsFetching, filesPathInfo, showLoadingAnimation, availablePinningServices,
onShare, onSetPinning, onInspect, onDownload, onDelete, onRename, onNavigate, onRemotePinClick, onAddFiles, onMove, handleContextMenuClick, t
}) => {
const [selected, setSelected] = useState([])
Expand Down
26 changes: 21 additions & 5 deletions src/files/modals/pinning-modal/PinningModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,26 @@ const humanSize = (size) => {
})
}

export const PinningModal = ({ t, tReady, onCancel, onPinningSet, file, availablePinningServices, doGetFileSizeThroughCid, doSelectRemotePinsForFile, className, ...props }) => {
const PinIcon = ({ icon, index }) => {
if (icon) {
return <img className="mr1" src={icon} alt='' width={32} height={32} style={{ objectFit: 'contain' }} />
}

const colors = ['aqua', 'link', 'yellow', 'teal', 'red', 'green', 'navy', 'gray', 'charcoal']
const color = colors[index % colors.length]
const glyphClass = `mr1 fill-${color} flex-shrink-0`

return <GlyphPin width={32} height={32} className={glyphClass}/>
}

export const PinningModal = ({ t, tReady, onCancel, onPinningSet, file, pinningServices, doGetFileSizeThroughCid, doSelectRemotePinsForFile, doFetchPinningServices, className, ...props }) => {
const remoteServices = useMemo(() => doSelectRemotePinsForFile(file), [doSelectRemotePinsForFile, file])

const [selectedServices, setSelectedServices] = useState([...remoteServices, ...[file.pinned && 'local']])
const [size, setSize] = useState(null)

useEffect(() => {
doFetchPinningServices()
const fetchSize = async () => setSize(await doGetFileSizeThroughCid(file.cid))
fetchSize()
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand All @@ -35,6 +49,7 @@ export const PinningModal = ({ t, tReady, onCancel, onPinningSet, file, availabl

return setSelectedServices(selectedServices.filter(s => s !== key))
}

return (
<Modal {...props} className={className} onCancel={onCancel} >
<ModalBody title={t('pinningModal.title')}>
Expand All @@ -44,10 +59,10 @@ export const PinningModal = ({ t, tReady, onCancel, onPinningSet, file, availabl
<GlyphPin fill="currentColor" width={32} height={32} className="mr1 aqua flex-shrink-0"/>
<p className="f5 w-100">{ t('pinningModal.localNode') }</p>
</button>
{ availablePinningServices.map(({ icon, name }) => (
{ pinningServices.map(({ icon, name }, index) => (
<button className="flex items-center pa1 hoverable-button" key={name} onClick={() => selectService(name)}>
<Checkbox className='pv3 pl3 pr1 flex-none' checked={selectedServices.includes(name)} style={{ pointerEvents: 'none' }}/>
<img className="mr1" src={icon} alt='' width={32} height={32} style={{ objectFit: 'contain' }} />
<PinIcon index={index} icon={icon}/>
<p className="f5">{ name }</p>
</button>
))}
Expand All @@ -62,7 +77,7 @@ export const PinningModal = ({ t, tReady, onCancel, onPinningSet, file, availabl

<ModalActions>
<Button className='ma2 tc' bg='bg-gray' onClick={onCancel}>{t('app:actions.cancel')}</Button>
<Button className='ma2 tc' bg='bg-teal' onClick={() => onPinningSet(file.cid, selectedServices)}>{t('app:actions.apply')}</Button>
<Button className='ma2 tc' bg='bg-teal' onClick={() => onPinningSet(file, selectedServices)}>{t('app:actions.apply')}</Button>
</ModalActions>
</Modal>
)
Expand All @@ -81,8 +96,9 @@ PinningModal.defaultProps = {
}

export default connect(
'selectAvailablePinningServices',
'selectPinningServices',
'doSelectRemotePinsForFile',
'doGetFileSizeThroughCid',
'doFetchPinningServices',
withTranslation('files')(PinningModal)
)
4 changes: 3 additions & 1 deletion src/files/modals/pinning-modal/fixtures/pinningServices.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export default [
const services = [
{
name: 'Pinata',
icon: 'https://svgshare.com/i/M7y.svg'
Expand All @@ -10,3 +10,5 @@ export default [
icon: 'https://www.eternum.io/static/images/icons/favicon-32x32.a2341c8ec160.png'
}
]

export default services

0 comments on commit 85f4d55

Please sign in to comment.