├── .gitignore ├── index.html ├── src ├── hooks │ ├── use-user.js │ ├── use-peers.js │ └── use-peer.js ├── components │ ├── FilesList.js │ ├── Alert.js │ ├── Peer.js │ ├── Confirm.js │ ├── Prompt.js │ └── FilesListItem.js ├── containers │ ├── UserPanel.js │ ├── PeersPanel.js │ ├── App.js │ └── ControlPanel.js └── context │ ├── user.js │ └── peers.js ├── test └── index.test.js ├── index.js ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/hooks/use-user.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { UserContext } from '../context/user' 3 | 4 | export default () => { 5 | const context = useContext(UserContext) 6 | if (context === undefined) { 7 | throw new Error('useUser must be used within a UserProvider') 8 | } 9 | 10 | return context 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/use-peers.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { PeersContext } from '../context/peers' 3 | 4 | export default () => { 5 | const context = useContext(PeersContext) 6 | if (context === undefined) { 7 | throw new Error('usePeers must be used within a PeersProvider') 8 | } 9 | 10 | return context 11 | } 12 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import test from 'brittle' // https://github.com/holepunchto/brittle 2 | import joyrider from 'joyrider' // https://github.com/holepunchto/joyrider 3 | 4 | const rider = joyrider(import.meta.url) 5 | 6 | test('title click', async ({ teardown, is, ok, plan }) => { 7 | plan(2) 8 | const ride = await rider({ teardown, app: '..' }) 9 | 10 | const inspect = await ride.open() 11 | 12 | const h1 = await inspect.querySelector('h1') 13 | 14 | ok(h1) 15 | 16 | await inspect.click(h1) 17 | 18 | is(await inspect.innerHTML(h1), '🍐') 19 | }) 20 | -------------------------------------------------------------------------------- /src/components/FilesList.js: -------------------------------------------------------------------------------- 1 | import { html } from 'htm/react' 2 | import FilesListItem from '../components/FilesListItem' 3 | import { List } from '@mui/material' 4 | 5 | export default ({ files, hyperdrive, allowDeletion = false }) => { 6 | return html` 7 | <${List}> 8 | ${files.sort((a, b) => a.key.localeCompare(b.key)).map(file => html` 9 | <${FilesListItem} 10 | key=${file.key} 11 | file=${file} 12 | hyperdrive=${hyperdrive} 13 | allowDeletion=${allowDeletion} 14 | /> 15 | `)} 16 | > 17 | ` 18 | } 19 | -------------------------------------------------------------------------------- /src/containers/UserPanel.js: -------------------------------------------------------------------------------- 1 | import { html } from 'htm/react' 2 | import useUser from '../hooks/use-user' 3 | import FilesList from '../components/FilesList' 4 | import { 5 | Box, 6 | Typography 7 | } from '@mui/material' 8 | 9 | export default () => { 10 | const { hyperdrive, files } = useUser() 11 | 12 | return html` 13 | <${Box} sx=${{ margin: 1 }}> 14 | <${Typography} variant="h4">Your shared files> 15 | <${FilesList} 16 | hyperdrive=${hyperdrive} 17 | files=${files} 18 | allowDeletion 19 | /> 20 | > 21 | ` 22 | } 23 | -------------------------------------------------------------------------------- /src/containers/PeersPanel.js: -------------------------------------------------------------------------------- 1 | import { html } from 'htm/react' 2 | import usePeers from '../hooks/use-peers' 3 | import Peer from '../components/Peer' 4 | import { Box, Typography } from '@mui/material' 5 | 6 | export default () => { 7 | const { peers } = usePeers() 8 | 9 | return html` 10 | <${Box} sx=${{ margin: 1 }}> 11 | <${Typography} variant="h4"> 12 | Your peers 13 | > 14 | ${Object.entries(peers).map(([key, peer]) => html` 15 | <${Peer} 16 | key=${key} 17 | hyperdrive=${peer.hyperdrive} 18 | /> 19 | `)} 20 | > 21 | ` 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Alert.js: -------------------------------------------------------------------------------- 1 | import { html } from 'htm/react' 2 | import { 3 | Button, 4 | Dialog, 5 | DialogActions, 6 | DialogContent, 7 | DialogContentText, 8 | DialogTitle 9 | } from '@mui/material' 10 | 11 | export default ({ title, message, onClose }) => { 12 | return html` 13 | <${Dialog} 14 | open=${true} 15 | aria-labelledby="alert-title" 16 | aria-describedby="alert-message" 17 | onClose=${onClose} 18 | > 19 | <${DialogTitle} id="alert-title"> 20 | ${title} 21 | > 22 | <${DialogContent}> 23 | <${DialogContentText} id="alert-message"> 24 | ${message} 25 | > 26 | > 27 | <${DialogActions}> 28 | <${Button} onClick=${onClose}> 29 | OK 30 | > 31 | > 32 | > 33 | ` 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Peer.js: -------------------------------------------------------------------------------- 1 | import { html } from 'htm/react' 2 | import usePeer from '../hooks/use-peer' 3 | import { 4 | Accordion, 5 | AccordionDetails, 6 | AccordionSummary, 7 | Typography 8 | } from '@mui/material' 9 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore' 10 | import FilesList from './FilesList' 11 | 12 | export default ({ hyperdrive }) => { 13 | const { profile, files } = usePeer({ hyperdrive }) 14 | 15 | return html` 16 | <${Accordion}> 17 | <${AccordionSummary} aria-controls="peer-content" expandIcon=${html`<${ExpandMoreIcon} />`}> 18 | <${Typography}> 19 | ${profile?.name} 20 | > 21 | > 22 | <${AccordionDetails}> 23 | <${FilesList} 24 | hyperdrive=${hyperdrive} 25 | files=${files} 26 | /> 27 | > 28 | > 29 | ` 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Confirm.js: -------------------------------------------------------------------------------- 1 | import { html } from 'htm/react' 2 | import { 3 | Button, 4 | Dialog, 5 | DialogActions, 6 | DialogContent, 7 | DialogContentText, 8 | DialogTitle 9 | } from '@mui/material' 10 | 11 | export default ({ title, message, onConfirm, onCancel }) => { 12 | return html` 13 | <${Dialog} 14 | open=${true} 15 | aria-labelledby="confirm-title" 16 | aria-describedby="confirm-message" 17 | onClose=${onCancel} 18 | > 19 | <${DialogTitle} id="confirm-title"> 20 | ${title} 21 | > 22 | <${DialogContent}> 23 | <${DialogContentText} id="confirm-message"> 24 | ${message} 25 | > 26 | > 27 | <${DialogActions}> 28 | <${Button} onClick=${onCancel}> 29 | Cancel 30 | > 31 | <${Button} onClick=${onConfirm}> 32 | OK 33 | > 34 | > 35 | > 36 | ` 37 | } 38 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* global Pear */ 2 | 3 | import { html } from 'htm/react' 4 | import { createRoot } from 'react-dom/client' 5 | import { UserProvider } from './src/context/user' 6 | import { PeersProvider } from './src/context/peers' 7 | import App from './src/containers/App' 8 | import { ThemeProvider, createTheme, CssBaseline } from '@mui/material' 9 | 10 | const { app } = await Pear.versions() 11 | const theme = createTheme({ 12 | palette: { 13 | mode: 'dark' 14 | } 15 | }) 16 | 17 | const root = createRoot(document.querySelector('#root')) 18 | root.render(html` 19 | <${ThemeProvider} theme=${theme}> 20 | <${CssBaseline} /> 21 | <${UserProvider} config=${Pear.config}> 22 | <${PeersProvider} 23 | name="filesharing-app-example" 24 | topic=${app.key || '57337a386673415371314f315a6d386f504576774259624e32446a7377393752'} 25 | > 26 | <${App} 27 | app={app} 28 | /> 29 | > 30 | > 31 | > 32 | `) 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "filesharing-react-app-example", 3 | "main": "index.html", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "pear dev", 7 | "test": "standard" 8 | }, 9 | "pear": { 10 | "type": "desktop", 11 | "gui": { 12 | "backgroundColor": "#151517", 13 | "height": 540, 14 | "width": 720 15 | } 16 | }, 17 | "author": "Holepunch Inc", 18 | "license": "Apache-2.0", 19 | "devDependencies": { 20 | "brittle": "^3.0.0", 21 | "standard": "^17.1.0" 22 | }, 23 | "dependencies": { 24 | "@emotion/styled": "^11.11.0", 25 | "@mui/icons-material": "^5.14.18", 26 | "@mui/material": "^5.14.18", 27 | "corestore": "^6.15.11", 28 | "downloads-folder": "^3.0.3", 29 | "htm": "^3.1.1", 30 | "hyperbee": "^2.19.0", 31 | "hypercore-id-encoding": "^1.3.0", 32 | "hyperdrive": "^11.8.1", 33 | "hyperswarm": "^4.7.14", 34 | "localdrive": "^1.11.1", 35 | "protomux-rpc": "^1.5.1", 36 | "react": "^18.2.0", 37 | "react-dom": "^18.2.0", 38 | "styled-components": "^6.1.1", 39 | "ws": "^7.5.10" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/Prompt.js: -------------------------------------------------------------------------------- 1 | import { html } from 'htm/react' 2 | import { useState } from 'react' 3 | import { 4 | Button, 5 | Dialog, 6 | DialogActions, 7 | DialogContent, 8 | DialogContentText, 9 | DialogTitle, 10 | TextField 11 | } from '@mui/material' 12 | 13 | export default ({ title, message, label, initialResponse = '', onResponse, onCancel }) => { 14 | const [text, setText] = useState(initialResponse) 15 | 16 | return html` 17 | <${Dialog} 18 | open=${true} 19 | aria-labelledby="prompt-title" 20 | aria-describedby="prompt-message" 21 | onClose=${onCancel} 22 | > 23 | <${DialogTitle} id="prompt-title"> 24 | ${title} 25 | > 26 | <${DialogContent}> 27 | ${message && html` 28 | <${DialogContentText} id="prompt-message"> 29 | ${message} 30 | > 31 | `} 32 | <${TextField} 33 | autoFocus 34 | margin="dense" 35 | label=${label} 36 | value=${text} 37 | onChange=${e => setText(e.target.value)} 38 | /> 39 | > 40 | <${DialogActions}> 41 | <${Button} onClick=${onCancel}> 42 | Cancel 43 | > 44 | <${Button} onClick=${() => onResponse(text)}> 45 | OK 46 | > 47 | > 48 | > 49 | ` 50 | } 51 | -------------------------------------------------------------------------------- /src/containers/App.js: -------------------------------------------------------------------------------- 1 | import { html } from 'htm/react' 2 | import { useState, useEffect } from 'react' 3 | import useUser from '../hooks/use-user' 4 | import usePeers from '../hooks/use-peers' 5 | import ControlPanel from './ControlPanel' 6 | import PeersPanel from './PeersPanel' 7 | import UserPanel from './UserPanel' 8 | import { 9 | Box, 10 | CircularProgress, 11 | Grid, 12 | Typography 13 | } from '@mui/material' 14 | 15 | export default ({ app }) => { 16 | const [isLoaded, setIsLoaded] = useState(false) 17 | const { loaded: arePeersLoaded } = usePeers() 18 | const { loaded: isUserLoaded } = useUser() 19 | 20 | useEffect(() => { 21 | const isAllLoaded = arePeersLoaded && isUserLoaded 22 | if (!isAllLoaded) return 23 | setIsLoaded(true) 24 | }, [arePeersLoaded, isUserLoaded]) 25 | 26 | if (!isLoaded) { 27 | return html` 28 | <${Box} 29 | sx=${{ 30 | display: 'flex', 31 | height: '100vh', 32 | flexDirection: 'column', 33 | justifyContent: 'center', 34 | alignItems: 'center' 35 | }} 36 | > 37 | <${CircularProgress} sx=${{ marginBottom: '20px' }}/> 38 | <${Typography} variant="h3"> 39 | Loading 40 | > 41 | > 42 | ` 43 | } 44 | return html` 45 | <${Grid} container spacing=${2}> 46 | <${Grid} item xs=${12}> 47 | <${ControlPanel} /> 48 | > 49 | <${Grid} item xs=${6}> 50 | <${PeersPanel} /> 51 | > 52 | <${Grid} item xs=${6}> 53 | <${UserPanel} /> 54 | > 55 | > 56 | ` 57 | } 58 | -------------------------------------------------------------------------------- /src/hooks/use-peer.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | export default ({ hyperdrive }) => { 4 | const [profile, setProfile] = useState({}) 5 | const [files, setFiles] = useState([]) 6 | 7 | useEffect(() => { 8 | getProfile() 9 | getFiles() 10 | }, []) 11 | 12 | async function getProfile () { 13 | console.log('[use-peer] getProfile()') 14 | const buf = await hyperdrive.get('/meta/profile.json') 15 | setProfile(JSON.parse(buf)) 16 | } 17 | 18 | async function getFiles () { 19 | console.log('[use-peer] getFiles()') 20 | const newFiles = [] 21 | const stream = hyperdrive.list('/files', { recursive: false }) 22 | for await (const file of stream) { 23 | newFiles.push(file) 24 | } 25 | setFiles(newFiles) 26 | } 27 | 28 | useEffect(() => { 29 | const profileWatcher = hyperdrive.watch('/meta', { recursive: false }) 30 | 31 | watchForever() 32 | async function watchForever () { 33 | for await (const _ of profileWatcher) { // eslint-disable-line no-unused-vars 34 | await getProfile() 35 | } 36 | } 37 | 38 | return async () => { 39 | await profileWatcher.destroy() 40 | } 41 | }, [hyperdrive]) 42 | 43 | useEffect(() => { 44 | const filesWatcher = hyperdrive.watch('/files') 45 | 46 | watchForever() 47 | async function watchForever () { 48 | for await (const _ of filesWatcher) { // eslint-disable-line no-unused-vars 49 | await getFiles() 50 | } 51 | } 52 | 53 | return async () => { 54 | await filesWatcher.destroy() 55 | } 56 | }, [hyperdrive]) 57 | 58 | return { 59 | profile, 60 | files 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # filesharing-react-app-example 2 | 3 | This example app shows how to build a simple file sharing app where all files are shared between everyone who use the app. 4 | 5 | To read more details about how to use Pear, read the [Getting Started guide](https://docs.pears.com/guides/getting-started). 6 | 7 | ## How to run 8 | 9 | [Pear](https://docs.pears.com/guides/getting-started) is a prerequisite, which can be installed with `npm i -g pear`. 10 | 11 | ``` 12 | $ git clone https://github.com/holepunchto/filesharing-react-app-example.git 13 | $ cd filesharing-react-app-example 14 | $ npm install 15 | $ pear run . 16 | ``` 17 | 18 | When a Pear app runs, it uses a local storage that's the same for all instances. To test the file sharing app, it would be good run multiple instances that looks different. To do that, use the `--store/-s` parameter for `pear`. 19 | 20 | In one terminal: 21 | 22 | ``` 23 | $ pear run -s /tmp/fs1 . 24 | ``` 25 | 26 | In another terminal: 27 | 28 | ``` 29 | $ pear run -s /tmp/fs2 . 30 | ``` 31 | 32 | ## How to release 33 | 34 | When a Pear app is released, it can be run by others. Read more about this in this [guide](https://docs.pears.com/guides/sharing-a-pear-app). The details won't be in this section, but just commands to run. 35 | 36 | ``` 37 | $ pear stage foo # Stage the local version to a key called "foo" 38 | $ pear release foo # Release the staged version of "foo" 39 | $ pear seed foo # Start seeding (sharing) the app 40 | ``` 41 | 42 | When running `pear seed` there will be output similar to 43 | 44 | ``` 45 | -o-:- 46 | pear://a1b2c3... 47 | ... 48 | ^_^ announced 49 | ``` 50 | 51 | This link can be shared with others. 52 | 53 | To run the app, do this (in another terminal): 54 | 55 | ``` 56 | $ pear run pear://a1b2c3... 57 | ``` 58 | -------------------------------------------------------------------------------- /src/containers/ControlPanel.js: -------------------------------------------------------------------------------- 1 | import { html } from 'htm/react' 2 | import { useState } from 'react' 3 | import useUser from '../hooks/use-user' 4 | import styled from 'styled-components' 5 | import { 6 | AppBar, 7 | Box, 8 | Button, 9 | Toolbar 10 | } from '@mui/material' 11 | import AccountCircleIcon from '@mui/icons-material/AccountCircle' 12 | import Prompt from '../components/Prompt' 13 | 14 | export default () => { 15 | const user = useUser() 16 | const [showChangeName, setShowChangeName] = useState(false) 17 | const VisuallyHiddenInput = styled('input')({ 18 | clip: 'rect(0 0 0 0)', 19 | clipPath: 'inset(50%)', 20 | height: 1, 21 | overflow: 'hidden', 22 | position: 'absolute', 23 | bottom: 0, 24 | left: 0, 25 | whiteSpace: 'nowrap', 26 | width: 1 27 | }) 28 | 29 | return html` 30 | <${AppBar} position="static"> 31 | <${Toolbar}> 32 | <${Box} 33 | sx=${{ 34 | flexGrow: 1, 35 | '-webkit-app-region': 'drag' 36 | }} 37 | > 38 | <${Button} 39 | component="label" 40 | sx=${{ '-webkit-app-region': 'no-drag' }} 41 | > 42 | Add files 43 | <${VisuallyHiddenInput} 44 | type="file" 45 | multiple 46 | onChange=${async e => { 47 | for (const file of e.target.files) { 48 | const data = await file.arrayBuffer() 49 | user.hyperdrive.put(`/files/${file.name}`, data) 50 | } 51 | }} 52 | /> 53 | > 54 | 64 | > 65 | <${Button} 66 | onClick=${() => setShowChangeName(true)} 67 | sx=${{ '-webkit-app-region': 'no-drag' }} 68 | > 69 | ${user.profile.name} 70 | <${AccountCircleIcon} sx=${{ marginLeft: '10px' }}/> 71 | > 72 | ${showChangeName && html` 73 | <${Prompt} 74 | title="Change name" 75 | label="Name" 76 | initialResponse=${user.profile.name} 77 | onCancel=${() => setShowChangeName(false)} 78 | onResponse=${async newName => { 79 | setShowChangeName(false) 80 | const newProfile = { name: newName } 81 | await user.updateProfile(newProfile) 82 | }} 83 | /> 84 | `} 85 | > 86 | > 87 | ` 88 | } 89 | -------------------------------------------------------------------------------- /src/context/user.js: -------------------------------------------------------------------------------- 1 | /* global Pear */ 2 | 3 | import { createContext, useEffect, useRef, useState } from 'react' 4 | import { html } from 'htm/react' 5 | import Corestore from 'corestore' 6 | import Hyperdrive from 'hyperdrive' 7 | import Localdrive from 'localdrive' 8 | import downloadsFolder from 'downloads-folder' 9 | 10 | const UserContext = createContext() 11 | 12 | function UserProvider ({ config, ...props }) { 13 | const [loaded, setLoaded] = useState(false) 14 | const [profile, setProfile] = useState({}) 15 | const [files, setFiles] = useState([]) 16 | const corestoreRef = useRef(new Corestore(config.storage)) 17 | const hyperdriveRef = useRef(new Hyperdrive(corestoreRef.current)) 18 | const localdriveRef = useRef(new Localdrive(downloadsFolder())) 19 | 20 | // Does it make sense to put here?? 21 | Pear.teardown(async () => { 22 | await corestoreRef.current.close() 23 | }) 24 | 25 | useEffect(() => { 26 | hyperdriveRef.current.ready() 27 | .then(initProfile) 28 | .then(getProfile) 29 | .then(getFiles) 30 | .then(() => setLoaded(true)) 31 | }, [hyperdriveRef]) 32 | 33 | async function initProfile () { 34 | const exists = await hyperdriveRef.current.exists('/meta/profile.json') 35 | if (exists) return 36 | await updateProfile({ name: 'No name' }) 37 | } 38 | 39 | async function updateProfile (profile) { 40 | await hyperdriveRef.current.put('/meta/profile.json', Buffer.from(JSON.stringify(profile))) 41 | } 42 | 43 | async function getProfile () { 44 | console.log('[UserProvider] getProfile()') 45 | const buf = await hyperdriveRef.current.get('/meta/profile.json') 46 | setProfile(JSON.parse(buf)) 47 | } 48 | 49 | async function getFiles () { 50 | console.log('[UserProvider] getFiles()') 51 | const newFiles = [] 52 | const stream = hyperdriveRef.current.list('/files', { recursive: false }) 53 | for await (const file of stream) { 54 | newFiles.push(file) 55 | } 56 | setFiles(newFiles) 57 | } 58 | 59 | useEffect(() => { 60 | const profileWatcher = hyperdriveRef.current.watch('/meta', { recursive: false }) 61 | 62 | watchForever() 63 | async function watchForever () { 64 | for await (const _ of profileWatcher) { // eslint-disable-line no-unused-vars 65 | await getProfile() 66 | } 67 | } 68 | 69 | return async () => { 70 | await profileWatcher.destroy() 71 | } 72 | }, [hyperdriveRef.current]) 73 | 74 | useEffect(() => { 75 | const filesWatcher = hyperdriveRef.current.watch('/files') 76 | 77 | watchForever() 78 | async function watchForever () { 79 | for await (const _ of filesWatcher) { // eslint-disable-line no-unused-vars 80 | await getFiles() 81 | } 82 | } 83 | 84 | return async () => { 85 | await filesWatcher.destroy() 86 | } 87 | }, [hyperdriveRef.current]) 88 | 89 | return html` 90 | <${UserContext.Provider} 91 | value=${{ 92 | loaded, 93 | profile, 94 | updateProfile, 95 | files, 96 | corestore: corestoreRef.current, 97 | hyperdrive: hyperdriveRef.current, 98 | localdrive: localdriveRef.current, 99 | downloadsFolder: downloadsFolder() 100 | }} 101 | ...${props} 102 | /> 103 | ` 104 | } 105 | 106 | export { UserContext, UserProvider } 107 | -------------------------------------------------------------------------------- /src/components/FilesListItem.js: -------------------------------------------------------------------------------- 1 | import { html } from 'htm/react' 2 | import { useState } from 'react' 3 | import { 4 | IconButton, 5 | ListItemButton, 6 | CircularProgress, 7 | Typography, 8 | ListItemIcon 9 | } from '@mui/material' 10 | import DeleteIcon from '@mui/icons-material/Delete' 11 | import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile' 12 | import useUser from '../hooks/use-user' 13 | import Confirm from '../components/Confirm' 14 | import Alert from '../components/Alert' 15 | 16 | export default ({ file, hyperdrive, allowDeletion = false }) => { 17 | const user = useUser() 18 | const [showDeletePrompt, setShowDeletePrompt] = useState(false) 19 | const [errorMessage, setErrorMessage] = useState('') 20 | const [showErrorMessage, setShowErrorMessage] = useState(false) 21 | const [downloadMessage, setDownloadMessage] = useState('') 22 | const [showDownloadMessage, setShowDownloadMessage] = useState(false) 23 | const [showSpinner, setShowSpinner] = useState(false) 24 | const filename = file.key.split('/').pop() 25 | 26 | async function download () { 27 | console.log(`[FileListItem] Downloading ${filename} to ${user.downloadsFolder}`) 28 | 29 | const ws = user.localdrive.createWriteStream(filename) 30 | const rs = hyperdrive.createReadStream(file.key, { 31 | timeout: 10000 // is this timeout untit it starts, or until it ends? 32 | }) 33 | 34 | setShowSpinner(true) 35 | 36 | rs.pipe(ws) 37 | rs.on('end', () => { 38 | setShowSpinner(false) 39 | setDownloadMessage(`${filename} was downloaded to ${user.downloadsFolder}`) 40 | setShowDownloadMessage(true) 41 | }) 42 | rs.on('error', err => { 43 | const hasTimedOut = err.message.includes('REQUEST_TIMEOUT') 44 | setErrorMessage(hasTimedOut 45 | ? `Could not start download of ${filename}. Maybe the network does not have the file.` 46 | : err.message 47 | ) 48 | setShowErrorMessage(true) 49 | setShowSpinner(false) 50 | }) 51 | } 52 | 53 | return html` 54 | <${ListItemButton} onClick=${download}> 55 | <${ListItemIcon} size="small"> 56 | <${InsertDriveFileIcon} size=${10} /> 57 | > 58 | <${Typography} variant="h7" sx=${{ flexGrow: 1 }}> 59 | ${filename} 60 | > 61 | ${showSpinner && html` 62 | <${CircularProgress} size=${20} /> 63 | `} 64 | ${allowDeletion && !showSpinner && html` 65 | <${IconButton} 66 | size="small" 67 | onClick=${() => setShowDeletePrompt(true)} 68 | edge="end" 69 | aria-label="delete" 70 | > 71 | <${DeleteIcon} fontSize="small" /> 72 | > 73 | `} 74 | > 75 | ${showDeletePrompt && html` 76 | <${Confirm} 77 | title="Confirm" 78 | message=${`Are you sure you want to stop sharing ${filename}?`} 79 | onCancel=${() => setShowDeletePrompt(false)} 80 | onConfirm=${async () => { 81 | setShowDeletePrompt(false) 82 | setShowSpinner(true) 83 | await hyperdrive.del(file.key) 84 | setShowSpinner(false) 85 | }} 86 | /> 87 | `} 88 | ${showErrorMessage && html` 89 | <${Alert} 90 | title="Error" 91 | message=${errorMessage} 92 | onClose=${() => setShowErrorMessage(false)} 93 | /> 94 | `} 95 | ${showDownloadMessage && html` 96 | <${Alert} 97 | title="Downloaded" 98 | message=${downloadMessage} 99 | onClose=${() => setShowDownloadMessage(false)} 100 | /> 101 | `} 102 | ` 103 | } 104 | -------------------------------------------------------------------------------- /src/context/peers.js: -------------------------------------------------------------------------------- 1 | /* global Pear */ 2 | 3 | import { createContext, useEffect, useState, useRef } from 'react' 4 | import { html } from 'htm/react' 5 | import Hyperbee from 'hyperbee' 6 | import Hyperdrive from 'hyperdrive' 7 | import Hyperswarm from 'hyperswarm' 8 | import useUser from '../hooks/use-user' 9 | import ProtomuxRPC from 'protomux-rpc' 10 | import hic from 'hypercore-id-encoding' 11 | 12 | const PeersContext = createContext() 13 | 14 | function PeersProvider ({ name, topic, ...props }) { 15 | const [loaded, setLoaded] = useState(false) 16 | const user = useUser() 17 | const [peers, setPeers] = useState([]) 18 | const hyperswarm = useRef() 19 | const hyperbee = new Hyperbee(user.corestore.get({ 20 | name: 'peers' 21 | }), { 22 | keyEncoding: 'utf-8', 23 | valueEncoding: 'json' 24 | }) 25 | 26 | useEffect(() => { 27 | loadPeers() 28 | .then(initSwarm) 29 | .then(() => setLoaded(true)) 30 | 31 | async function loadPeers () { 32 | for await (const { key, value: { driveKey } } of hyperbee.createReadStream()) { 33 | add({ key, driveKey }) 34 | } 35 | } 36 | 37 | async function initSwarm () { 38 | hyperswarm.current = new Hyperswarm({ 39 | keyPair: await user.corestore.createKeyPair('first-app') 40 | }) 41 | 42 | Pear.teardown(async () => { 43 | await hyperswarm.current.destroy() 44 | }) 45 | 46 | hyperswarm.current.on('connection', async (conn, info) => { 47 | const key = conn.remotePublicKey.toString('hex') 48 | const rpc = new ProtomuxRPC(conn) 49 | console.log('[connection joined]', info) 50 | // TODO: Set online status 51 | // knownPeersOnlineStatus[key] = true 52 | 53 | user.corestore.replicate(conn) 54 | 55 | // If someone asks who we are, then tell them our driveKey 56 | rpc.respond('whoareyou', async req => { 57 | console.log('[whoareyou respond]') 58 | return Buffer.from(JSON.stringify({ 59 | driveKey: user.hyperdrive.key.toString('hex') 60 | })) 61 | }) 62 | 63 | conn.on('close', () => { 64 | console.log(`[connection left] ${conn}`) 65 | console.log('should update online status') 66 | }) 67 | 68 | // If we have never seen the peer before, then ask them who they are so 69 | // we can get their hyperdrive key. 70 | // On subsequent boots we already know them, so it doesn't matter if they 71 | // are online or not, before we can see and download their shared files 72 | // as long as someone in the network has accessed them. 73 | const peer = await hyperbee.get(key) 74 | const isAlreadyKnownPeer = !!peer 75 | if (isAlreadyKnownPeer) return 76 | 77 | console.log('[whoareyou request] This peer is new, ask them for their hyperdrive key') 78 | const reply = await rpc.request('whoareyou') 79 | const { driveKey } = JSON.parse(reply.toString()) 80 | await add({ 81 | key, 82 | driveKey 83 | }) 84 | }) 85 | 86 | // If this is an example app, then this key preferably should not be in sourcecode 87 | // But the app.key may not exist before `pear stage/release` has been called, so 88 | // maybe there is another 32-byte key we can use? 89 | const discovery = hyperswarm.current.join(hic.decode(topic), { server: true, client: true }) 90 | await discovery.flushed() 91 | } 92 | }, []) 93 | 94 | async function add ({ key, driveKey }) { 95 | console.log(`[PeersProvider] add() key=${key} driveKey=${driveKey}`) 96 | const hyperdrive = new Hyperdrive(user.corestore, driveKey) 97 | await hyperdrive.ready() 98 | await hyperbee.put(key, { driveKey }) 99 | 100 | setPeers(peers => ({ 101 | ...peers, 102 | [key]: { 103 | hyperdrive 104 | } 105 | })) 106 | } 107 | 108 | return html` 109 | <${PeersContext.Provider} 110 | value=${{ 111 | loaded, 112 | peers 113 | }} 114 | ...${props} 115 | /> 116 | ` 117 | } 118 | 119 | export { PeersContext, PeersProvider } 120 | --------------------------------------------------------------------------------