forked from react-bootstrap-web3-spa

This commit is contained in:
Chris Troutner
2025-01-22 05:37:44 -07:00
commit 46243cfdb2
38 changed files with 19967 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
build/
docs/
.gitsigners
+8
View File
@@ -0,0 +1,8 @@
[
{
"srcDir": "",
"destDir": "",
"files": "**/*.js",
"command": "npm run lint"
}
]
+7
View File
@@ -0,0 +1,7 @@
Copyright 2022 Chris Troutner
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+62
View File
@@ -0,0 +1,62 @@
# react-bootstrap-web3-spa
This is a single page app (SPA) based on [react-bootstrap](https://www.npmjs.com/package/react-bootstrap). This app is a template to show how to build a web app that leverages the [Cash Stack](https://cashstack.info) web3 architecture, a censorship-resistant back-end for accessing the Bitcoin Cash blockchain.
This app can be compiled and uploaded to Filecoin via [web3.storage](https://web3.storage). This means a censorship-resistant front-end app (SPA) can be built, which communicates with a censorship-resistant back-end.
- [Live Demo on GitHub Pages](https://permissionless-software-foundation.github.io/react-bootstrap-web3-spa/)
- [Live Demo on Filecoin](https://bafybeic3nuawgogcfjkxxstyqyg6dmzajvkxp55ccldipwmgiyuikhrq5y.ipfs.dweb.link/)
## Major Features
- [react-bootstrap](https://react-bootstrap.github.io/) is used for general style and layout control.
- An easily customized *waiting modal* component can be invoked while waiting for network calls to complete.
- [minimal-slp-wallet](https://www.npmjs.com/package/minimal-slp-wallet) is used to access tokens and BCH on the Bitcoin Cash blockchain.
- A 'server selection' dropdown allows the user to select from an array of redundant [web3 back end servers](https://cashstack.info).
- This site is statically compiled, uploaded to Filecoin, and served over IPFS for censorship resistance and version control.
- A collapsible navigation menu is used to load different views.
- This app can be compiled into an native Android app using [react-bootstrap-web3-android](https://github.com/Permissionless-Software-Foundation/react-bootstrap-web3-android).
## Installation
```bash
git clone https://github.com/Permissionless-Software-Foundation/react-bootstrap-web3-spa
cd react-bootstrap-web3-spa
npm install
npm start
npm run build
```
## Publish to Filecoin
```bash
export FILECOIN_TOKEN=myFilecoinAPITokenFromWeb3.Storage
npm run build
npm run pub
```
Learn more about alternative deployments in the [deployment directory](./deploy)
## Support
Have questions? Need help? Join our community support
[Telegram channel](https://t.me/bch_js_toolkit)
## Donate
This open source software is developed and maintained by the [Permissionless Software Foundation](https://psfoundation.cash). If this library provides value to you, please consider making a donation to support the PSF developers:
<div align="center">
<img src="./img/donation-qr.png" />
<p>bitcoincash:qqsrke9lh257tqen99dkyy2emh4uty0vky9y0z0lsr</p>
</div>
## Repo Backup
This GitHub repository is backed up on [Radicle](https://radicle.network/get-started.html). If GitHub ever censors this respository, the code can be found in this alternative repository. [Here are extra notes on working with Radicle](https://christroutner.github.io/trouts-blog/docs/censorship/radicle).
- Project ID: rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy
- [Repo on Rad Website](https://app.radicle.network/seeds/maple.radicle.garden/rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy/remotes/hyyycncbn9qzqmobnhjq9rry6t4mbjiadzjoyhaknzxjcz3cxkpfpc)
This can be cloned from [PSF](https://psfoundation.info) Radicle seed node with one of these commands:
- `rad clone rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy --seed radicle.fullstackcash.nl`
- `rad clone rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy --seed maple.radicle.garden`
## License
[MIT](./LICENSE.md)
+17
View File
@@ -0,0 +1,17 @@
# Deploy
This directory contains scripts for deploying the app to different platforms and blockchains.
## App Deployment
### Blockchains
- Filecoin - The compiled app is uploaded to the Filecoin blockchain using [publish-filecoin.js](./publish-filecoin.js). Running this script requires a free API key from [web3.storage](https://web3.storage).
- IPFS - The files are also pinned by the Pinata service using [publish-pinata.js](./publish-filecoin.js). Running this script requires a free JWT token from [Pinata](https://pinata.cloud).
- Bitcoin Cash - The IPFS CID is written to the Bitcoin Cash blockchain with [publish-bch.js](/publish-bch.js) This creates an immutable, censorship-resistant, globally available, and secure pointer to the latest version of the app.
The above deployment scripts are orchestrated with [publish-main.js](`./publish-main.js`). This script is run by executing `npm run pub`.
### GitHub Pages
The app can also be deployed to GitHub pages. This requires switching to the `gh-pages` branch and running the command `npm run pub:ghp`.
## Code Deployment
The code in this repository is backed up to the [Radicle](https://radicle.network/get-started.html) network, as GitHub has been increasing its censorship of code. Find instructions for *consuming* the code in the [top-level README](../README.md). To learn how install Radicle on your own machine and collaborate on the code that way, check out [this research article](https://christroutner.github.io/trouts-blog/docs/censorship/radicle).
+52
View File
@@ -0,0 +1,52 @@
/*
This script will write the CID for the current version of the app to an
address on the BCH blockchain. This creates an immutable, censorship-resistant,
globally available, and secure pointer to the latest version of the app.
The exported function expects an IPFS CID as input and returns a TXID for a
BCH transaction.
This function expects this environtment variable to contain a WIF private key
with BCH to write to the blockchain:
- REACT_BOOTSTRAP_WEB3_SPA_WIF
*/
// Global npm libraries
// const BCHJS = require('@psf/bch-js')
const BchWallet = require('minimal-slp-wallet/index')
const BchMessageLib = require('bch-message-lib/index')
async function publishToBch (cid) {
try {
// Get the Filecoin token from the environment variable.
const wif = process.env.REACT_BOOTSTRAP_WEB3_SPA_WIF
if (!wif) {
throw new Error(
'WIF private key not detected. Get a private key from https://wallet.fullstack.cash and save it to the REACT_BOOTSTRAP_WEB3_SPA_WIF environment variable.'
)
}
// Initialize libraries for working with BCH blockchain.
// const bchjs = new BCHJS()
const wallet = new BchWallet(wif, {
interface: 'consumer-api'
})
await wallet.walletInfoPromise
await wallet.initialize()
const bchMsg = new BchMessageLib({ wallet })
// Publish the CID to the BCH blockchain.
const hex = await bchMsg.memo.memoPush(cid, 'IPFS UPDATE')
// Broadcast the transaction to the network.
const txid = await wallet.ar.sendTx(hex)
// console.log(`BCH blockchain updated with new CID. TXID: ${txid}`)
// console.log(`https://blockchair.com/bitcoin-cash/transaction/${txid}`)
return txid
} catch (err) {
console.error(err)
}
}
module.exports = publishToBch
+82
View File
@@ -0,0 +1,82 @@
/*
This library is used to publish the compiled app to Filecoin.
The publishToFilecoin() function will upload the 'build' folder to Filecoin
via the web3.storage API.
The function will return an object that contains the CID of the uploaded
directory, and a URL for loading the app in a browser.
In order to run this script, you must obtain an API key from web3.storage.
That key should be saved to an environment variable named FILECOIN_TOKEN.
*/
const { Web3Storage, getFilesFromPath } = require('web3.storage')
const fs = require('fs')
async function publish () {
try {
const currentDir = `${__dirname}`
// console.log(`Current directory: ${dir}`)
const buildDir = `${currentDir}/../build`
// Get the Filecoin token from the environment variable.
const filecoinToken = process.env.FILECOIN_TOKEN
if (!filecoinToken) {
throw new Error(
'Filecoin token not detected. Get a token from https://web3.storage and save it to the FILECOIN_TOKEN environment variable.'
)
}
// Get a list of all the files to be uploaded.
const fileAry = await getFileList(buildDir)
// console.log(`fileAry: ${JSON.stringify(fileAry, null, 2)}`)
// Upload the files to Filecoin.
const cid = await uploadToFilecoin(fileAry, filecoinToken)
// console.log('Content added to Filecoin with CID:', cid)
// console.log(`https://${cid}.ipfs.dweb.link/`)
return cid
} catch (err) {
console.error(err)
}
}
function getFileList (buildDir) {
const fileAry = []
return new Promise((resolve, reject) => {
fs.readdir(buildDir, (err, files) => {
if (err) return reject(err)
files.forEach(file => {
// console.log(file)
fileAry.push(`${buildDir}/${file}`)
})
return resolve(fileAry)
})
})
}
async function uploadToFilecoin (fileAry, token) {
const storage = new Web3Storage({ token })
const files = []
for (let i = 0; i < fileAry.length; i++) {
const thisPath = fileAry[i]
// console.log('thisPath: ', thisPath)
const pathFiles = await getFilesFromPath(thisPath)
// console.log('pathFiles: ', pathFiles)
files.push(...pathFiles)
}
console.log(`Uploading ${files.length} files. Please wait...`)
const cid = await storage.put(files)
return cid
}
module.exports = publish
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Bash shell script to publish the app to GitHub pages.
# Ensure you are in the gh-pages branch.
#pwd
#git checkout gh-pages
#git merge master
npm run build
cp -r build docs
git add -A
git commit -m "Updating GitHub page"
git push
+29
View File
@@ -0,0 +1,29 @@
/*
This is the main publish file that aggregates the other publish libraries
and orchestrates them, so that one command can publish to different platforms.
*/
// Local libraries
const publishToFilecoin = require('./publish-filecoin')
const publishToPinata = require('./publish-pinata')
const publishToBch = require('./publish-bch')
async function publish () {
try {
// Publish to Filecoin
const cid = await publishToFilecoin()
console.log('Content added to Filecoin with CID:', cid)
console.log(`https://${cid}.ipfs.dweb.link/`)
// Publish to Pinata
await publishToPinata(cid)
// Public to BCH
const txid = await publishToBch(cid)
console.log(`\nBCH blockchain updated with new CID. TXID: ${txid}`)
console.log(`https://blockchair.com/bitcoin-cash/transaction/${txid}`)
} catch (err) {
console.error('Error while trying to publish app: ', err)
}
}
publish()
+52
View File
@@ -0,0 +1,52 @@
/*
This library will pin the app to Pinata. It expects a CID
as input, which is the output of publish-filecoin.js.
Filecoin should be though of as cold-storage for data. It's very slow to
retrieve. Pinata can be though of as RAM. It keeps content at the ready and
fast to deliver. They are complimentary services.
In order to run this script, you must obtain an API key from pinata.cloud.
That key should be saved to an environment variable named PINATA_JWT.
*/
const axios = require('axios')
async function publishToPinata (cid) {
// Get the Pinata token from the environment variable.
const pinataToken = process.env.PINATA_JWT
if (!pinataToken) {
throw new Error(
'Pinata JWT token not detected. Get a token from https://pinata.cloud and save it to the PINATA_JWT environment variable.'
)
}
const now = new Date()
const data = JSON.stringify({
hashToPin: cid,
pinataMetadata: {
name: 'react-bootstrap-web3-spa',
keyvalues: {
timestamp: now.toISOString()
}
}
})
const config = {
method: 'post',
url: 'https://api.pinata.cloud/pinning/pinByHash',
headers: {
Authorization: `Bearer ${pinataToken}`,
'Content-Type': 'application/json'
},
data
}
const res = await axios(config)
console.log('\nCID pinned using Pinata:')
console.log(res.data)
}
module.exports = publishToPinata
+28
View File
@@ -0,0 +1,28 @@
# Developer Docs
This file contains notes taken during software development. These notes may eventually be edited into informaiton that goes into the top-level README, or other documentation.
## Main Features of this App
- [react-bootstrap](https://react-bootstrap.github.io/) is used for general style and layout control.
- An easily customizable waiting modal component can be invoked while waiting for network calls to complete.
- [minimal-slp-wallet](https://www.npmjs.com/package/minimal-slp-wallet) is used to access tokens and BCH on the Bitcoin Cash blockchain.
- A 'server selection' dropdown allows the user to select from an array of redundent back end servers.
- This site is statically compiled, uploaded to Filecoin, and served over IPFS for censorship resistance and version control.
## File Layout
The top-level file layout of this app looks like this:
- App.js - the main application orchestates these child components:
- GetRestUrl - retrieves the REST URL for the selected back-end web3 server from query paramenters in the URL.
- LoadScripts - Loads the modal with a waiting spinner animation until the external script files are loaded.
- NavMenu - the collapsible navigation menu
- InitializedView & UnitializedView - the default Views that are displayed depending on the state of the app.
- ServerSelect - allows the user to select a different web3 back end server.
- Footer - Footer links
After initialization, the InitailizedView is displayed. This loads the AppBody, which is a wrapper for each View. Views are selected using the navigation menu. When one View is selected, the others are hidden.
## Loading of Wallet
The wallet library [minimal-slp-wallet](https://www.npmjs.com/package/minimal-slp-wallet) is loaded at startup, and initialized with a web3 back end server. By default, the back-end server is free-bch.fullstack.cash. However, a list of back end servers provided by the [PSF](https://psfoundation.cash) are loaded into a drop-down from a GitHub
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+18473
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "react-bootstrap-web3-spa",
"version": "2.0.1",
"dependencies": {
"axios": "0.27.2",
"bch-message-lib": "2.2.1",
"bootstrap": "5.2.0",
"query-string": "7.1.1",
"react": "19.0.0",
"react-bootstrap": "2.10.7",
"react-dom": "19.0.0",
"react-scripts": "5.0.1",
"use-query-params": "1.2.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "standard --env mocha --fix",
"pub": "node deploy/publish-main.js",
"pub:ghp": "./deploy/publish-gh-pages.sh"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"minimal-slp-wallet": "5.8.5",
"standard": "17.0.0",
"web3.storage": "4.3.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BCH Web3 Template</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
.header {
text-align: center;
}
+203
View File
@@ -0,0 +1,203 @@
/*
This is an SPA that creates a template for future BCH web3 apps.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { useQueryParam, StringParam } from 'use-query-params'
// Local libraries
import './App.css'
import LoadScripts from './components/load-scripts'
import WaitingModal from './components/waiting-modal'
import AsyncLoad from './services/async-load'
import SelectServerButton from './components/servers/select-server-button'
import Footer from './components/footer'
import NavMenu from './components/nav-menu'
import AppBody from './components/app-body'
function App (props) {
// BEGIN STATE
// Get the CashStack URL from a query parameter, if it exists.
let [restURL] = useQueryParam('restURL', StringParam)
// Otherwise default to free-bch.fullstack.cash
if (!restURL) restURL = 'https://free-bch.fullstack.cash'
// console.log('restURL: ', restURL)
const [serverUrl, setServerUrl] = useState(restURL)
const [menuState, setMenuState] = useState(0)
const [wallet, setWallet] = useState(false)
const [servers, setServers] = useState([])
// Startup state
// When the page is loaded, it goes through a series of async network calls
// to initialize the app. These variables track the state of this startup
// process.
const [asyncInitStarted, setAsyncInitStarted] = useState(false)
const [asyncInitFinished, setAsyncInitFinished] = useState(false)
const [asyncInitSucceeded, setAsyncInitSucceeded] = useState(null)
// Startup Modal
const [showStartModal, setShowStartModal] = useState(true)
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [denyClose, setDenyClose] = useState(false) // Deny modal to be closed
// Load all the app state into a single object that can be passed to child
// components.
const appData = {
wallet,
setWallet,
serverUrl,
setServerUrl,
servers,
setServers,
showStartModal,
setShowStartModal,
menuState,
setMenuState,
asyncInitFinished,
setAsyncInitFinished,
asyncInitSucceeded,
setAsyncInitSucceeded,
modalBody,
setModalBody,
hideSpinner,
setHideSpinner,
denyClose,
setDenyClose
}
// END STATE
// Encasulate dependencies
const asyncLoad = new AsyncLoad()
useEffect(() => {
async function asyncEffect () {
// console.log('asyncInitStarted: ', asyncInitStarted)
if (!asyncInitStarted) {
try {
setAsyncInitStarted(true)
addToModal('Loading minimal-slp-wallet', appData)
setDenyClose(true)
await asyncLoad.loadWalletLib()
// console.log('Wallet: ', Wallet)
addToModal('Getting alternative servers', appData)
const gistServers = await asyncLoad.getServers()
setServers(gistServers)
// console.log('servers: ', servers)
// throw new Error('test error')
addToModal('Initializing wallet', appData)
console.log(`Initializing wallet with back end server ${serverUrl}`)
const walletTemp = await asyncLoad.initWallet(serverUrl)
setWallet(walletTemp)
// Update state
setShowStartModal(false)
setDenyClose(false)
// Update the startup state.
setAsyncInitFinished(true)
setAsyncInitSucceeded(true)
console.log('App.js useEffect() startup finished successfully')
} catch (err) {
const errModalBody = [
`Error: ${err.message}`,
'Try selecting a different back end server using the drop-down menu at the bottom of the app.'
]
setModalBody(errModalBody)
// Update Modal State
setHideSpinner(true)
setShowStartModal(true)
setDenyClose(false)
// Update the startup state.
setAsyncInitFinished(true)
setAsyncInitSucceeded(false)
}
}
}
asyncEffect()
})
return (
<>
<LoadScripts />
<NavMenu menuHandler={onMenuClick} appData={appData} />
{
showStartModal
? (<UninitializedView appData={appData} />)
: (<InitializedView menuState={menuState} appData={appData} />)
}
<SelectServerButton menuHandler={onMenuClick} appData={appData} />
<Footer appData={appData} />
</>
)
}
// Add a new line to the waiting modal.
function addToModal (inStr, appData) {
// console.log('addToModal() inStr: ', inStr)
appData.setModalBody(prevBody => {
// console.log('prevBody: ', prevBody)
prevBody.push(inStr)
return prevBody
})
}
// This handler is passed into the child menu component. When an item in the
// nav menu is clicked, this handler will update the state. The state is
// used by the AppBody component to determine which View component to display.
function onMenuClick (menuState, appData) {
// console.log('onMenuClick() menuState: ', menuState)
appData.setMenuState(menuState)
}
// This is rendered *before* the BCH wallet is initialized.
function UninitializedView (props = {}) {
// console.log('UninitializedView props: ', props)
const heading = 'Connecting to BCH blockchain...'
return (
<>
<WaitingModal
heading={heading}
body={props.appData.modalBody}
hideSpinner={props.appData.hideSpinner}
denyClose={props.appData.denyClose}
/>
{
props.asyncInitFinished
? <AppBody menuState={100} wallet={props.appData.wallet} appData={props.appData} />
: null
}
</>
)
}
// This is rendered *after* the BCH wallet is initialized.
function InitializedView (props) {
return (
<>
<br />
<AppBody menuState={props.menuState} appData={props.appData} />
</>
)
}
export default App
+9
View File
@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
it('renders without crashing', () => {
const div = document.createElement('div')
ReactDOM.render(<App />, div)
ReactDOM.unmountComponentAtNode(div)
})
+50
View File
@@ -0,0 +1,50 @@
/*
This Body component is a container for all the different Views of the app.
Views are equivalent to 'pages' in a multi-page app. Views are hidden or
displayed to simulate the use of pages in an SPA.
The Body app contains all the Views and chooses which to show, based on
the state of the Menu component.
*/
// Global npm libraries
import React from 'react'
// Local libraries
import GetBalance from '../balance'
import Placeholder2 from '../placeholder2'
import Placeholder3 from '../placeholder3'
import ServerSelectView from '../servers/select-server-view'
function AppBody (props) {
// Dependency injection through props
const appData = props.appData
const menuState = props.menuState
// console.log('AppBody() menuState: ', menuState)
function chooseView (menuState) {
// console.log(`chooseView() menuState: ${menuState}`)
switch (menuState) {
case 0:
return (<GetBalance wallet={appData.wallet} />)
case 1:
return (<Placeholder2 />)
case 2:
return (<Placeholder3 />)
// Special Views
case 100:
return (<ServerSelectView appData={appData} />)
default:
return (<GetBalance wallet={appData.wallet} />)
}
}
return (
<>
{chooseView(menuState)}
</>
)
}
export default AppBody
+64
View File
@@ -0,0 +1,64 @@
/*
Component for looking up the balance of a BCH address.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Form, Button, Spinner } from 'react-bootstrap'
function GetBalance (props) {
const { wallet } = props
// State
const [balance, setBalance] = useState('')
const [textInput, setTextInput] = useState('')
// Button click handler
const handleGetBalance = async (event) => {
try {
// Exit on invalid input
if (!textInput) return
if (!textInput.includes('bitcoincash:')) return
setBalance(<span>Retrieving balance... <Spinner animation='border' /></span>)
const balance = await wallet.getBalance({ bchAddress: textInput })
console.log('balance: ', balance)
const bchBalance = wallet.bchjs.BitcoinCash.toBitcoinCash(balance)
setBalance(`Balance: ${balance} sats, ${bchBalance} BCH`)
} catch (err) {
setBalance(<p><b>Error</b>: {`${err.message}`}</p>)
}
}
return (
<>
<Container>
<Row>
<Col className='text-break' style={{ textAlign: 'center' }}>
<Form>
<Form.Group className='mb-3' controlId='formBasicEmail'>
<Form.Label>Enter a BCH address to check the balance.</Form.Label>
<Form.Control type='text' placeholder='bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' onChange={e => setTextInput(e.target.value)} />
</Form.Group>
<Button variant='primary' onClick={handleGetBalance}>
Check Balance
</Button>
</Form>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
{balance}
</Col>
</Row>
</Container>
</>
)
}
export default GetBalance
+70
View File
@@ -0,0 +1,70 @@
/*
This service file contains functions for retrieving an IPFS hash from the
BCH blockchain, in a fasion similar to PS001:
https://github.com/Permissionless-Software-Foundation/specifications/blob/master/ps001-media-sharing.md
*/
// Global npm libraries
import BchMessage from 'bch-message-lib'
// Local libraries
import AppUtil from '../../util'
class Memo {
constructor (config) {
this.config = config
// Encapsulate dependencies
this.util = new AppUtil()
}
// Instantiate the bch-message-lib library.
async initialize (wallet) {
try {
// Throw an error if this class is instantiated without passing a BCH address.
if (!this.config || !this.config.bchAddr) {
throw new Error('Must pass a BCH address to Memo constructor.')
} else {
this.bchAddr = this.config.bchAddr
}
this.wallet = wallet
this.bchMessage = new BchMessage({ wallet })
} catch (err) {
console.error('Error in get-cid.js/initialize(): ', err.message)
// console.log('Waiting 5 seconds before trying again.')
// await this.util.sleep(5000)
// this.initialize()
}
}
// Walk the transactions associated with an address until a proper IPFS hash is
// found. If one is not found, will return false.
async findHash () {
try {
console.log(`Finding latest IPFS hash for address: ${this.bchAddr}...`)
const txs = await this.bchMessage.memo.memoRead(
this.bchAddr,
'IPFS UPDATE'
)
// console.log(`txs: ${JSON.stringify(txs, null, 2)}`)
// If the array is empty, then return false.
if (txs.length === 0) return false
const hash = txs[0].subject
console.log(`...found this IPFS hash: ${hash}`)
// The transactions should automatically be sorted by the bchMessage
// library. So Just return the subject.
return hash
} catch (err) {
console.warn('Could not find IPFS hash in transaction history.')
return false
}
}
}
export default Memo
+87
View File
@@ -0,0 +1,87 @@
/*
A footer section for the SPA
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Row, Col } from 'react-bootstrap'
// Local libraries
import config from '../../config'
import Memo from './get-cid'
function Footer (props) {
const [ipfsCid, setIpfsCid] = useState(config.ipfsCid)
const wallet = props.appData.wallet
// Retrieve the most up-to-date CID for the app on Filecoin from the BCH blockchain.
useEffect(() => {
async function fetchData () {
try {
const hash = await getUpdatedUrl(wallet)
if (hash) {
setIpfsCid(hash)
}
} catch (err) {
console.error('Error trying to retrieve Filecoin CID for the app from the BCH blockchain.')
}
}
fetchData()
}, [wallet])
return (
<Container style={{ backgroundColor: '#ddd' }}>
<Row style={{ padding: '25px' }}>
<Col>
<h6>Site Mirrors</h6>
<ul>
<li>
<a href={config.ghPagesUrl} target='_blank' rel='noreferrer'>GitHub Pages</a>
</li>
<li>
<a href={`https://${ipfsCid}.ipfs.dweb.link/`} target='_blank' rel='noreferrer'>Filecoin</a>
</li>
</ul>
</Col>
<Col>
<h6>Source Code</h6>
<ul>
<li>
<a href={config.ghRepo} target='_blank' rel='noreferrer'>GitHub</a>
</li>
<li>
<a href={config.radicleUrl} target='_blank' rel='noreferrer'>Radicle</a>
</li>
</ul>
</Col>
</Row>
</Container>
)
}
async function getUpdatedUrl (wallet) {
try {
// Exit if the wallet is not initialized.
if (!wallet) return
// Initialize the memo library for retrieving data from the BCH blockchain.
const memo = new Memo({ bchAddr: config.appBchAddr })
await memo.initialize(wallet)
const hash = await memo.findHash()
if (!hash) {
console.error(
`Could not find IPFS hash in transactions for address ${config.appBchAddr}`
)
return false
}
// console.log(`latest IPFS hash: ${hash}`)
return hash
} catch (err) {
console.log('Error in getUpdatedUrl(): ', err)
}
}
export default Footer
+16
View File
@@ -0,0 +1,16 @@
/*
Load <script> libraries
*/
import useScript from '../hooks/use-script'
function LoadScripts () {
// useScript('https://unpkg.com/minimal-slp-wallet?module')
// Load the libraries from the local directory.
useScript(`${process.env.PUBLIC_URL}/minimal-slp-wallet.min.js`)
return true
}
export default LoadScripts
+40
View File
@@ -0,0 +1,40 @@
/*
This component controlls the navigation menu.
Inspired from this example:
https://codesandbox.io/s/react-bootstrap-hamburger-menu-example-rnud4?from-embed
*/
// Global npm libraries
import React from 'react'
import { Nav, Navbar, Image } from 'react-bootstrap'
import Logo from './psf-logo.png'
function NavMenu (props) {
const handleClickEvent = (menuItem) => {
// Pass the selected menu item up to the parent component.
props.menuHandler(menuItem, props.appData)
}
return (
<>
<Navbar collapseOnSelect expand='xxxl' bg='dark' variant='dark' style={{ paddingRight: '20px' }}>
<Navbar.Brand href='#home' style={{ paddingLeft: '20px' }}>
<Image src={Logo} thumbnail width='50' />{' '}
PSF Web3 Demo
</Navbar.Brand>
<Navbar.Toggle aria-controls='responsive-navbar-nav' />
<Navbar.Collapse id='responsive-navbar-nav'>
<Nav className='mr-auto'>
<Nav.Link href='#' onClick={(e) => handleClickEvent(0)}>Check Balance</Nav.Link>
<Nav.Link href='#' onClick={(e) => handleClickEvent(1)}>Placeholder2</Nav.Link>
<Nav.Link href='#' onClick={(e) => handleClickEvent(2)}>Placeholder 3</Nav.Link>
</Nav>
</Navbar.Collapse>
</Navbar>
</>
)
}
export default NavMenu
+52
View File
@@ -0,0 +1,52 @@
/*
This component controlls the navigation menu.
Inspired from this example:
https://codesandbox.io/s/react-bootstrap-hamburger-menu-example-rnud4?from-embed
*/
// Global npm libraries
import React from 'react'
import { Nav, Navbar, Image } from 'react-bootstrap'
import Logo from './psf-logo.png'
class NavMenu extends React.Component {
constructor (props) {
super(props)
// This is the event handler passed in from the Parent component.
this.parentMenuHandler = props.menuHandler
}
render () {
// console.log(`selectedMenuItem: ${this.state.selectedMenuItem}`)
// this.handleMenuClick(this.state.selectedMenuItem)
return (
<>
<Navbar collapseOnSelect expand='xxxl' bg='dark' variant='dark' style={{ paddingRight: '20px' }}>
<Navbar.Brand href='#home' style={{ paddingLeft: '20px' }}>
<Image src={Logo} thumbnail width='50' />{' '}
PSF Web3 Demo
</Navbar.Brand>
<Navbar.Toggle aria-controls='responsive-navbar-nav' />
<Navbar.Collapse id='responsive-navbar-nav'>
<Nav className='mr-auto'>
<Nav.Link href='#' onClick={() => this.handleClickEvent(0)}>Check Balance</Nav.Link>
<Nav.Link href='#' onClick={() => this.handleClickEvent(1)}>Placeholder2</Nav.Link>
<Nav.Link href='#' onClick={() => this.handleClickEvent(2)}>Placeholder 3</Nav.Link>
</Nav>
</Navbar.Collapse>
</Navbar>
</>
)
}
handleClickEvent (menuItem) {
// Pass the selected menu item up to the parent component.
this.parentMenuHandler(menuItem)
}
}
export default NavMenu
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+24
View File
@@ -0,0 +1,24 @@
/*
This is a placeholder View
*/
// Global npm libraries
import React from 'react'
class Placeholder2 extends React.Component {
constructor (props) {
super(props)
console.log('Placeholder 2 loaded.')
}
render () {
return (
<>
<p style={{ padding: '25px' }}>This is placeholder View #2</p>
</>
)
}
}
export default Placeholder2
+24
View File
@@ -0,0 +1,24 @@
/*
This is a placeholder View
*/
// Global npm libraries
import React from 'react'
class Placeholder3 extends React.Component {
constructor (props) {
super(props)
console.log('Placeholder 3 loaded.')
}
render () {
return (
<>
<p style={{ padding: '25px' }}>This is placeholder View #3</p>
</>
)
}
}
export default Placeholder3
@@ -0,0 +1,54 @@
/*
This component contains a drop-down form that lets the user select from
a range of Global Back End servers.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col, Button } from 'react-bootstrap'
// Local libraries
// import GistServers from '../../services/gist-servers'
// let _this
class ServerSelect extends React.Component {
constructor (props) {
super(props)
this.state = {
menuHandler: props.menuHandler,
appData: props.appData
}
this.handleServerSelect = this.handleServerSelect.bind(this)
}
render () {
return (
<Container>
<hr />
<Row>
<Col style={{ textAlign: 'center', padding: '25px' }}>
<br />
<h5>
Having trouble loading? Try selecting a different back-end server.
</h5>
<Button variant='warning' onClick={this.handleServerSelect}>Select a different back end server</Button>
<br />
</Col>
</Row>
</Container>
)
}
// This is a click handler for the server select button. It brings up the
// server selection View.
handleServerSelect () {
console.log('This function should navigate to the server selection view.')
this.state.menuHandler(100, this.state.appData)
}
}
export default ServerSelect
@@ -0,0 +1,90 @@
/*
This component is a View that allows the user to select a back end server
from a list of servers.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col, Button } from 'react-bootstrap'
class ServerSelectView extends React.Component {
constructor (props) {
super(props)
this.state = {
appData: props.appData
}
}
render () {
// console.log('this.state.appData: ', this.state.appData)
const servers = this.state.appData.servers
// Loop through each server in the list
const serverList = []
for (let i = 0; i < servers.length; i++) {
const thisServer = servers[i]
const serverComponent = this.singleServer(thisServer, i)
serverList.push(serverComponent)
}
return (
<>
<Container>
<Row>
<Col style={{ textAlign: 'center' }}>
<h2>
Select Alternative Server
</h2>
<p>
Select an alternative server below. The app will reload and use
the selected server.
</p>
</Col>
</Row>
<br />
{serverList}
</Container>
</>
)
}
// Generate the HTML to display and select a server.
singleServer (server, i) {
const isEven = i % 2
const backgroundColor = isEven ? '#eee' : '#fff'
return (
<div key={`server-${i}`}>
<Row
style={{
padding: '25px',
backgroundColor
}}
>
<Col xs={4}>
<Button id={server.value} onClick={this.handleReloadServer}>Select</Button>
</Col>
<Col xs={8}>
{server.label}
</Col>
</Row>
</div>
)
}
// Click handler for the button. Reloads the app using the selected back-end server.
handleReloadServer (event) {
const target = event.target.id
console.log('server target: ', target)
window.location.href = `/?restURL=${target}`
}
}
export default ServerSelectView
+74
View File
@@ -0,0 +1,74 @@
/*
This 'Waiting Modal' component displays a spinner animation and a status log.
It's used to inform the user that the app is waiting for something, and to
display progress.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Modal, Spinner } from 'react-bootstrap'
function ModalTemplate (props) {
// State
const [show, setShow] = useState(true)
// Dependency injection of props
const denyClose = props.denyClose // Determins if user is allowed to close modal.
const closeFunc = props.closeFunc // Optional function called after modal is closed.
const heading = props.heading // Title of the modal
const body = props.body // Body of the modal
const hideSpinner = props.hideSpinner // Hide the animated spinner
// This function is called when the modal is closed
const handleClose = () => {
console.log(`props.denyClose: ${denyClose}`)
if (denyClose) return
setShow(false)
if (closeFunc) {
closeFunc()
}
}
// const handleShow = () => setShow(true)
return (
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>{heading}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
<Row>
<Col style={{ textAlign: 'center' }}>
<BodyList body={body} />
{hideSpinner ? null : <Spinner animation='border' />}
</Col>
</Row>
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
)
}
// This function populates the body of the modal. It expects props.body to be
// an array of strings.
function BodyList (props) {
const items = props.body
// console.log('BodyList items: ', items)
const listItems = []
// Paragraphs
for (let i = 0; i < items.length; i++) {
listItems.push(<p key={items[i]}><code>{items[i]}</code></p>)
}
return (
listItems
)
}
// export default WaitingModal
export default ModalTemplate
+19
View File
@@ -0,0 +1,19 @@
/*
Global configuration settings for this app.
*/
const config = {
// Default IPFS CID for the app. This will be overwritten by dynamic lookup.
ipfsCid: 'bafybeibya4cwro6rgqfaazqxckcchy6qo5sz2aqc4dx7ptcvpns5peqcz4',
// BCH address used to point to the latest version of the IPFS CID.
appBchAddr: 'bitcoincash:qztv87ppjh82v527jq2drp4u8rzzn63r5cmhth2zzm',
// Backup Info that goes into the Footer.
ghPagesUrl: 'https://permissionless-software-foundation.github.io/react-bootstrap-web3-spa/',
ghRepo: 'https://github.com/Permissionless-Software-Foundation/react-bootstrap-web3-spa',
radicleUrl: 'https://app.radicle.network/seeds/maple.radicle.garden/rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy/remotes/hyyycncbn9qzqmobnhjq9rry6t4mbjiadzjoyhaknzxjcz3cxkpfpc'
}
export default config
+18
View File
@@ -0,0 +1,18 @@
import { useEffect } from 'react'
const useScript = url => {
useEffect(() => {
const script = document.createElement('script')
script.src = url
script.async = true
document.body.appendChild(script)
return () => {
document.body.removeChild(script)
}
}, [url])
}
export default useScript
+21
View File
@@ -0,0 +1,21 @@
/*
*/
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { QueryParamProvider } from 'use-query-params'
// Importing the Bootstrap CSS
import 'bootstrap/dist/css/bootstrap.min.css'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
<QueryParamProvider>
<App />
</QueryParamProvider>
)
// Updating to React v18
// https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#updates-to-client-rendering-apis
+114
View File
@@ -0,0 +1,114 @@
/*
This library gets data that requires an async wait.
*/
// Global npm libraries
import axios from 'axios'
// Local libraries
import GistServers from './gist-servers'
class AsyncLoad {
constructor () {
this.BchWallet = false
}
// Load the minimal-slp-wallet which comes in as a <script> file and is
// attached to the global 'window' object.
async loadWalletLib () {
do {
// console.log('window.SlpWallet: ', window.SlpWallet)
if (typeof window !== 'undefined' && window.SlpWallet) {
this.BchWallet = window.SlpWallet
return this.BchWallet
} else {
console.log('Waiting for wallet library to load...')
}
await sleep(1000)
} while (!this.BchWallet)
}
// Initialize the BCH wallet
async initWallet (restURL) {
const options = {
interface: 'consumer-api',
restURL,
noUpdate: true
}
const wallet = new this.BchWallet(null, options)
await wallet.walletInfoPromise
// console.log(`mnemonic: ${wallet.walletInfo.mnemonic}`)
this.wallet = wallet
return wallet
}
// Get token data for a given Token ID
async getTokenData (tokenId) {
const tokenData = await this.wallet.getTokenData(tokenId)
// Convert the IPFS CIDs into actual data.
tokenData.immutableData = await this.getIpfsData(tokenData.immutableData)
tokenData.mutableData = await this.getIpfsData(tokenData.mutableData)
return tokenData
}
// Get data about a Group token
async getGroupData (tokenId) {
const tokenData = await this.getTokenData(tokenId)
const groupData = {
immutableData: tokenData.immutableData,
mutableData: tokenData.mutableData,
nfts: tokenData.genesisData.nfts,
tokenId: tokenData.genesisData.tokenId
}
return groupData
}
// Given an IPFS URI, this will download and parse the JSON data.
async getIpfsData (ipfsUri) {
const cid = ipfsUri.slice(7)
const downloadUrl = `https://${cid}.ipfs.dweb.link/data.json`
const response = await axios.get(downloadUrl)
const data = response.data
return data
}
// Get a list of alternative back end servers.
async getServers () {
// Try to get the list from GitHub
try {
const gistLib = new GistServers()
const gistServers = await gistLib.getServerList()
return gistServers
} catch (err) {
console.error('Error trying to retrieve list of servers from GitHub: ', err)
console.log('Returning hard-coded list of servers.')
const defaultOptions = [
{ value: 'https://free-bch.fullstack.cash', label: 'https://free-bch.fullstack.cash' },
{ value: 'https://dev-consumer.psfoundation.info', label: 'https://dev-consumer.psfoundation.info' }
]
return defaultOptions
}
}
}
function sleep (ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
export default AsyncLoad
+36
View File
@@ -0,0 +1,36 @@
/*
This library downloads a dynamic list of back-end servers from a GitHub Gist.
*/
const axios = require('axios')
class GistServers {
constructor () {
this.axios = axios
}
// Retrieve a JSON file from a GitHub Gist
async getServerList () {
try {
// https://gist.github.com/christroutner/e818ecdaed6c35075bfc0751bf222258
// 'https://api.github.com/gists/63c5513782181f8b8ea3eb89f7cadeb6'
const gistUrl = 'https://consumers.psfoundation.info/consumers.json'
// Retrieve the gist from github.com.
const result = await this.axios.get(gistUrl)
console.log('result.data: ', result.data)
// Get the current content of the gist.
const content = result.data.servers
// console.log('content: ', content)
return content
} catch (err) {
console.error('Error in getCRList()')
throw err
}
}
}
export default GistServers
+13
View File
@@ -0,0 +1,13 @@
/*
A utility library for holding functions that are commonly used by many different
areas of the app.
*/
class AppUtil {
// Returns a promise that resolves 'ms' milliseconds.
sleep (ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
export default AppUtil