├── .babelrc ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── dist ├── context │ └── index.js └── index.js ├── netlify.toml ├── package.json ├── src ├── context │ └── index.js └── index.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | "@babel/preset-env" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | react-blockstack-context-*.tgz 4 | .DS_Store 5 | dist 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /example 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [Unreleased] 6 | 7 | - Document use with showBlockstackConnect 8 | 9 | ## [0.6.10] - 2020-04-28 10 | 11 | - Minor correction 12 | 13 | ## [0.6.9] - 2020-04-28 14 | 15 | - The useFile hook doesn't fail with SDK 21 when the file is missing 16 | - Documenting the `useConnectOptions` hook for Connect integration 17 | 18 | ## [0.6.8] - 2020-03-27 19 | 20 | - Unofficial `useConnectOptions` hook for Connect integration 21 | 22 | ## [0.6.7] - 2020-03-27 23 | 24 | - New `didConnect` function to support Blockstack Connect integration 25 | 26 | ## [0.6.6] - 2020-03-26 27 | 28 | - Update documentation 29 | 30 | ## [0.6.5] - 2020-03-26 31 | 32 | - New `authenticated` property returned by useBlockstack() 33 | 34 | ## [0.6.4] - 2019-11-22 35 | 36 | - Example extracted to https://github.com/REBL-Stack/starter-app 37 | 38 | ## [0.6.3] - 2019-10-19 39 | 40 | - Guard in `useFile` against concurrent operations on the same file. 41 | 42 | ## [0.6.0] - 2019-10-12 43 | 44 | ### Added 45 | 46 | - `useFile` hook 47 | 48 | ### Changed 49 | 50 | - Default export is `initBlockstack` instead of the `Blockstack` context provider object (breaking). 51 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development Guide 2 | 3 | For the react-blockstack package. 4 | 5 | ## Publish to npm 6 | 7 | The react-blockstack package is published at: 8 | 9 | https://www.npmjs.com/package/react-blockstack 10 | 11 | First test in the example or another local project (see other section). 12 | 13 | To publish a new version, first update the version build the distribution: 14 | 15 | npm install 16 | npm run build 17 | npm version [patch | minor | major] 18 | npm publish 19 | 20 | # Test react-blockstack in a local project 21 | 22 | During development of this package, it can be used/tested in a local project 23 | without first publishing to npm (but see issues below). 24 | 25 | 1. In this directory, execute: 26 | 27 | npm install 28 | npm run build 29 | npm link 30 | 31 | 2. In the dependent project top directory, execute: 32 | 33 | npm link react-blockstack 34 | npm install 35 | 36 | If `npm install` reports the package is not found, try deleting the package-lock.json or see this for other options: 37 | 38 | https://stackoverflow.com/questions/24550515/npm-after-npm-link-module-is-not-found 39 | 40 | If `npm start` reports missing modules when loading react-blockstack, try running `npm install` in this module. 41 | 42 | If there are problems related to "[react hook] can only be called inside the body of a function component" suggesting missing or mismatching modules (like react-dom) 43 | use `npm ls react-dom` in using project to investigate. Removing package-lock.json and 44 | reinstalling is a potential fix. Also make sure to use same case for imported names (`React`). And `npm link` doesn't work well with hooks, says posters at: 45 | https://github.com/facebook/react/issues/13991 46 | https://github.com/webpack/webpack/issues/8607#issuecomment-453068938 47 | https://github.com/transitive-bullshit/create-react-library/issues/99 48 | 49 | Verified work-around for link issue is to change the dependency in project to: 50 | "react": "file:../node_modules/react" 51 | But npm install has to be run first... so it's a drag. 52 | 53 | Adding this to webpack.config.js appears to have done the trick: 54 | "resolve": { "alias": { "react": "./node_modules/react" }} 55 | 56 | Other possibility: 57 | "Solution was as simple as adding these lines to webpack.config.js:"" 58 | module.exports = { 59 | externals: [ 60 | "react", 61 | "react-dom" 62 | ], 63 | }; 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Terje Norderhaug 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Blockstack 2 | 3 | React hooks to use the [Blockstack SDK](https://blockstack.github.io/blockstack.js/) 4 | with [react function components](https://reactjs.org/docs/components-and-props.html). 5 | 6 | Includes backward compatibility with react class components. 7 | 8 | ## Installation 9 | 10 | npm install react-blockstack 11 | 12 | ## Blockstack Authentication 13 | 14 | Execute as early as possible to initialize the Blockstack SDK and user authentication: 15 | 16 | ````javascript 17 | import ReactBlockstack from 'react-blockstack' 18 | 19 | const blockstack = ReactBlockstack() 20 | ```` 21 | 22 | Consider placing this code in the main index.js file of your project. For customization of the authentication, use the same options argument as for [UserSession](https://blockstack.github.io/blockstack.js/classes/usersession.html) in the Blockstack SDK: 23 | 24 | ````javascript 25 | import { AppConfig } from 'blockstack' 26 | 27 | const appConfig = new AppConfig(['store_write', 'publish_data']) 28 | const blockstack = ReactBlockstack({appConfig}) 29 | ```` 30 | 31 | The `blockstack.userSession` property is available in case you need to access to the blockstack SDK on toplevel. It is typically preferable to get `userSession` from the `useBlockstack` hook, ignoring the return value from `ReactBlockstack`. 32 | 33 | ## React Hook for Function Components 34 | 35 | The package provides a `useBlockStack` React hook for use in function components. It provides access to the Blockstack SDK and eventually an authenticated user: 36 | 37 | const {userSession, userData, signIn, signOut, person} = useBlockstack() 38 | 39 | The hook returns these properties: 40 | 41 | * `userSession` (UserSession interface for the Blockstack SDK) 42 | * `userData` (UserData interface from the Blockstack SDK; `null` unless authenticated) 43 | * `authenticated` (true when authentication is complete) 44 | * `signIn` (function to sign in the user; `null` when already logged in or pending authentication) 45 | * `signOut` (function to sign out the user; `null` when not logged in or pending authentication) 46 | * `person` (if authenticated, a Person instance containing the user profile) 47 | 48 | Only `userSession` and `signIn` are available before authentication. 49 | After authentication, `signIn` is null, but there are bindings for 50 | `userData`, `authenticated`, `signOut` and `person`. This can be used for conditional rendering 51 | depending on the authentication status. Note that the user can neither sign in nor sign out when the authentication is pending, so: 52 | 53 | ```javascript 54 | const pendingAuthentication = !signIn && !signOut 55 | ``` 56 | 57 | ### Example: Authentication Button 58 | 59 | Here is a react function component that implements an authentication button. 60 | It handles both signin and logout, adapting the label depending on status, 61 | disabling the button while authentication is pending: 62 | 63 | ````javascript 64 | import { useBlockstack } from 'react-blockstack' 65 | 66 | function Auth () { 67 | const { signIn, signOut } = useBlockstack() 68 | return ( 69 | 73 | ) 74 | } 75 | ```` 76 | 77 | To include the button in jsx: 78 | 79 | 80 | 81 | ## Persistent Data Storage 82 | 83 | The `useFile(path: string)` hook is used to access the app's data store, covering 84 | the functionality of `getFile`, `putFile` and `deleteFile` in the Blockstack SDK. 85 | 86 | The argument is a pathname in the app's data store. The file does not have to exists before the call. 87 | 88 | The `useFile` hook returns the content of the file like `getFile`, with a function to change the file content as second value. The returned content is `undefined` until the file has been accessed and `null` if the file is determined not to exist. The setter accepts the same content types as `putFile`, and will delete the file if called with `null`. The content returned by `useFile` is conservatively updated, not reflecting the change until after storing the content is completed. 89 | 90 | ### Example 91 | 92 | ```javascript 93 | const [content, setContent] = useFile("content") 94 | ``` 95 | 96 | ## Blockstack Connect 97 | 98 | React Blockstack can be used with 99 | [Blockstack Connect](https://github.com/blockstack/ux/tree/master/packages/connect). 100 | 101 | To ensure that the state is properly updated after Connect authentication, 102 | make Connect's `authOptions.finished` callback function call `didConnect()`. 103 | 104 | The `useConnectOptions()` hook can be used to fill in default options like the 105 | `userSession`. The argument is the same as the authOptions in Connect. 106 | The hook provides sensible defaults if called without an argument. 107 | 108 | ### Example 109 | 110 | ```javascript 111 | import { didConnect, useConnectOptions } from 'react-blockstack' 112 | 113 | const connectOptions = { 114 | redirectTo: '/', 115 | finished: ({ userSession }) => { 116 | didConnect({ userSession }) 117 | } 118 | } 119 | 120 | function Register (props) { 121 | const authOptions = useConnectOptions(connectOptions) 122 | const signIn = useCallback(authOptions && (() => { 123 | showBlockstackConnect(authOptions) 124 | }), [authOptions]) 125 | return( 126 | 127 | ) 128 | } 129 | 130 | ``` 131 | 132 | ## React Class Components 133 | 134 | For conventional React class components, the package provides an optional 135 | [React context object](https://reactjs.org/docs/context.html) 136 | that pass properties from `useBlockstack` down to components. 137 | 138 | Enclose top level elements in a shared Blockstack context: 139 | 140 | ````javascript 141 | import { Blockstack } from 'react-blockstack/dist/context' 142 | 143 | ReactDOM.render(, document.getElementById('app-root')) 144 | ```` 145 | 146 | The Blockstack SDK properties are implicitly passed through the component tree and can be used as any other React context. 147 | 148 | ### Example 149 | 150 | The App component below will automatically be updated whenever there is a change in the Blockstack status. Note the use of the `this.context` containing the properties and 151 | that the class is required to have `contextType = BlockstackContext`. 152 | 153 | ````javascript 154 | import React, { Component } from 'react' 155 | import BlockstackContext from 'react-blockstack/dist/context' 156 | 157 | export default class App extends Component { 158 | static contextType = BlockstackContext 159 | render() { 160 | const { person } = this.context 161 | const avatarUrl = person && person.avatarUrl && person.avatarUrl() 162 | const personName = person && person.name && person.name() 163 | return( 164 |
165 | 166 | { personName } 167 | 168 |
169 | ) 170 | } 171 | } 172 | ```` 173 | 174 | If there are multiple Blockstack components they will all share the same context. 175 | 176 | ## Live Demo 177 | 178 | The REBL Stack [starter app](https://github.com/REBL-Stack/starter-app) 179 | is a reimplementation of the 180 | [Blockstack react template](https://github.com/blockstack/blockstack-app-generator/tree/master/react/templates). 181 | 182 | It demonstrates different ways of using react-blockStack. 183 | You are encouraged to use the example as a starting point for your own projects. 184 | 185 | Live at: 186 | [![Netlify Status](https://api.netlify.com/api/v1/badges/4c1f3c5b-c184-4659-935a-c66065978127/deploy-status)](https://react-blockstack.netlify.com) 187 | -------------------------------------------------------------------------------- /dist/context/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | Object.defineProperty(exports, "Blockstack", { 7 | enumerable: true, 8 | get: function get() { 9 | return _index.Blockstack; 10 | } 11 | }); 12 | exports["default"] = void 0; 13 | 14 | var _index = require("../index.js"); 15 | 16 | var _default = _index.BlockstackContext; 17 | exports["default"] = _default; -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.useBlockstack = useBlockstack; 7 | exports.setContext = setContext; 8 | exports.initBlockstack = initBlockstack; 9 | exports.Blockstack = Blockstack; 10 | exports.didConnect = didConnect; 11 | exports.useConnectOptions = useConnectOptions; 12 | exports.useFile = useFile; 13 | exports.useFilesList = useFilesList; 14 | exports.useFileUrl = useFileUrl; 15 | exports.useFetch = useFetch; 16 | exports.useStored = useStored; 17 | exports.usePersistent = usePersistent; 18 | exports.Persistent = Persistent; 19 | exports.createAppManifestHook = createAppManifestHook; 20 | exports.useAppManifest = useAppManifest; 21 | exports.AuthenticatedDocumentClass = AuthenticatedDocumentClass; 22 | exports.useProfile = useProfile; 23 | exports.BlockstackContext = exports["default"] = void 0; 24 | 25 | var _react = _interopRequireWildcard(require("react")); 26 | 27 | var _blockstack = require("blockstack"); 28 | 29 | var _reactAtom = require("@dbeining/react-atom"); 30 | 31 | var _lodash = require("lodash"); 32 | 33 | function _getRequireWildcardCache() { if (typeof WeakMap !== "function") return null; var cache = new WeakMap(); _getRequireWildcardCache = function _getRequireWildcardCache() { return cache; }; return cache; } 34 | 35 | function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } if (obj === null || _typeof(obj) !== "object" && typeof obj !== "function") { return { "default": obj }; } var cache = _getRequireWildcardCache(); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj["default"] = obj; if (cache) { cache.set(obj, newObj); } return newObj; } 36 | 37 | function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } 38 | 39 | function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); } 40 | 41 | function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } 42 | 43 | function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && Symbol.iterator in Object(iter)) return Array.from(iter); } 44 | 45 | function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); } 46 | 47 | function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); } 48 | 49 | function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } 50 | 51 | function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(n); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } 52 | 53 | function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } 54 | 55 | function _iterableToArrayLimit(arr, i) { if (typeof Symbol === "undefined" || !(Symbol.iterator in Object(arr))) return; var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } 56 | 57 | function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } 58 | 59 | function _readOnlyError(name) { throw new Error("\"" + name + "\" is read-only"); } 60 | 61 | var defaultValue = { 62 | userData: null, 63 | signIn: null, 64 | signOut: null, 65 | authenticated: false 66 | }; 67 | 68 | var contextAtom = _reactAtom.Atom.of(defaultValue); 69 | /** 70 | * React hook for the Blockstack SDK 71 | * 72 | * @return {{userSession: UserSession, userData: ?UserData, signIn: ?function, signOut: ?function, person: ?Person}} Blockstack SDK context 73 | * 74 | * @example 75 | * 76 | * useBlockstack() 77 | */ 78 | 79 | 80 | function useBlockstack() { 81 | return (0, _reactAtom.useAtom)(contextAtom); 82 | } 83 | 84 | function setContext(update) { 85 | // use sparingly as it triggers all using components to update 86 | (0, _reactAtom.swap)(contextAtom, function (state) { 87 | return (0, _lodash.merge)({}, state, (0, _lodash.isFunction)(update) ? update(state) : update); 88 | }); 89 | } 90 | 91 | function signIn(e) { 92 | var _deref = (0, _reactAtom.deref)(contextAtom), 93 | userSession = _deref.userSession; 94 | 95 | var update = { 96 | signIn: null 97 | }; 98 | setContext(update); 99 | userSession.redirectToSignIn(); // window.location.pathname 100 | } 101 | 102 | function signOut(e) { 103 | var _deref2 = (0, _reactAtom.deref)(contextAtom), 104 | userSession = _deref2.userSession; 105 | 106 | var update = { 107 | userData: null, 108 | signIn: signIn, 109 | signOut: null, 110 | authenticated: false, 111 | person: null 112 | }; 113 | setContext(update); 114 | userSession.signUserOut(); 115 | } 116 | 117 | function handleAuthenticated(userData) { 118 | window.history.replaceState({}, document.title, window.location.pathname); 119 | var update = { 120 | userData: userData, 121 | person: new _blockstack.Person(userData.profile), 122 | signIn: null, 123 | authenticated: true, 124 | signOut: signOut 125 | }; 126 | setContext(update); 127 | } 128 | 129 | function initBlockstack(options) { 130 | // Idempotent 131 | var _deref3 = (0, _reactAtom.deref)(contextAtom), 132 | userSession = _deref3.userSession; 133 | 134 | if (!userSession) { 135 | var _userSession = new _blockstack.UserSession(options); 136 | 137 | var update = { 138 | userSession: _userSession 139 | }; 140 | setContext(update); 141 | 142 | if (_userSession.isSignInPending()) { 143 | _userSession.handlePendingSignIn().then(handleAuthenticated); 144 | } else if (_userSession.isUserSignedIn()) { 145 | handleAuthenticated(_userSession.loadUserData()); 146 | } else { 147 | setContext({ 148 | signIn: signIn 149 | }); 150 | } 151 | 152 | return { 153 | userSession: _userSession 154 | }; 155 | } else { 156 | return { 157 | userSession: userSession 158 | }; 159 | } 160 | } 161 | 162 | var _default = initBlockstack; 163 | exports["default"] = _default; 164 | var BlockstackContext = (0, _react.createContext)(defaultValue); 165 | exports.BlockstackContext = BlockstackContext; 166 | 167 | function Blockstack(props) { 168 | var context = useBlockstack(); 169 | return /*#__PURE__*/_react["default"].createElement(BlockstackContext.Provider, { 170 | value: context 171 | }, props.children); 172 | } 173 | 174 | function didConnect(_ref) { 175 | var session = _ref.userSession; 176 | 177 | var _deref4 = (0, _reactAtom.deref)(contextAtom), 178 | userSession = _deref4.userSession, 179 | authenticated = _deref4.authenticated; 180 | 181 | if (userSession != session) { 182 | userSession = (_readOnlyError("userSession"), session); 183 | setContext({ 184 | userSession: userSession 185 | }); 186 | } 187 | 188 | if (!authenticated) { 189 | var userData = userSession.loadUserData(); 190 | handleAuthenticated(userData); 191 | } 192 | } 193 | 194 | function useConnectOptions(options) { 195 | var _useBlockstack = useBlockstack(), 196 | userSession = _useBlockstack.userSession; 197 | 198 | var authOptions = { 199 | redirectTo: '/', 200 | manifest: '/manifest.json', 201 | finished: function finished(_ref2) { 202 | var userSession = _ref2.userSession; 203 | didConnect({ 204 | userSession: userSession 205 | }); 206 | }, 207 | userSession: userSession, 208 | appDetails: { 209 | name: "Blockstack App", 210 | icon: '/logo.svg' 211 | } 212 | }; 213 | return (0, _lodash.merge)({}, authOptions, options); 214 | } 215 | 216 | function useFile(path, options) { 217 | var _useStateWithGaiaStor = useStateWithGaiaStorage(path, (0, _lodash.merge)({ 218 | reader: _lodash.identity, 219 | writer: _lodash.identity, 220 | initial: null 221 | }, options)), 222 | _useStateWithGaiaStor2 = _slicedToArray(_useStateWithGaiaStor, 2), 223 | value = _useStateWithGaiaStor2[0], 224 | setValue = _useStateWithGaiaStor2[1]; 225 | 226 | return [value, !(0, _lodash.isUndefined)(value) ? setValue : null]; 227 | } 228 | /* 229 | function gaiaReducer (state, event) { 230 | // state machine for gaia file operations 231 | console.debug("Gaia:", state, event) 232 | switch (state.status) { 233 | case "start": 234 | switch (event.action) { 235 | case "reading": return({...state, status: "reading", pending: true}) 236 | } 237 | case "reading": 238 | switch (event.action) { 239 | case "read-error": return({...state, status: "read-error", error: event.error}) 240 | case "no-file": return ({...state, status: "no-file"}) 241 | case "read-success": return({...state, status: "ready", content: event.content, pending: false}) 242 | } 243 | case "no-file": 244 | switch (event.action) { 245 | case "writing": return({...state, status: "writing", content: event.content}) 246 | } 247 | case "writing": 248 | switch (event.action) { 249 | case "write-error": return({...state, status: "write-error", error: event.error}) 250 | case "write-complete": return({...state, status: "ready", content: event.content}) 251 | } 252 | case "ready": 253 | switch (event.action) { 254 | case "writing": return({...state, status: "writing", content: event.content}) 255 | case "deleting": return({...state, status: "deleting", content: event.content}) 256 | } 257 | case "deleting": 258 | switch (event.action) { 259 | case "delete-error": return({...state, status: "delete-error", error: event.error}) 260 | case "delete-complete": return ({...state, status: "no-file", content: null}) 261 | } 262 | default: return (state) 263 | } 264 | } 265 | */ 266 | 267 | 268 | function useStateWithGaiaStorage(path, _ref3) { 269 | var _ref3$reader = _ref3.reader, 270 | reader = _ref3$reader === void 0 ? _lodash.identity : _ref3$reader, 271 | _ref3$writer = _ref3.writer, 272 | writer = _ref3$writer === void 0 ? _lodash.identity : _ref3$writer, 273 | _ref3$initial = _ref3.initial, 274 | initial = _ref3$initial === void 0 ? null : _ref3$initial; 275 | 276 | /* Low level gaia file hook 277 | Note: Does not guard against multiple hooks for the same file 278 | Possbly an issue that change is set then value, could introduce inconsisitent state 279 | Return value: 280 | 1. File read -> content of file 281 | 2. File not existing or empty -> {} // could also be null 282 | 3. File not yet accessed or inaccessible -> undefined 283 | Attempting to set value when undefined throws an error. 284 | */ 285 | var _useState = (0, _react.useState)(undefined), 286 | _useState2 = _slicedToArray(_useState, 2), 287 | value = _useState2[0], 288 | setValue = _useState2[1]; 289 | 290 | var _useState3 = (0, _react.useState)(undefined), 291 | _useState4 = _slicedToArray(_useState3, 2), 292 | change = _useState4[0], 293 | setChange = _useState4[1]; 294 | 295 | var _useState5 = (0, _react.useState)(false), 296 | _useState6 = _slicedToArray(_useState5, 2), 297 | pending = _useState6[0], 298 | setPending = _useState6[1]; // const [{status, pending}, dispatch] = useReducer(gaiaReducer, {:status: "start"}) 299 | 300 | 301 | var updateValue = function updateValue(update) { 302 | // ##FIX: properly handle update being a fn, call with (change || value) 303 | //console.log("[File] Update:", path, update) 304 | if (!(0, _lodash.isUndefined)(value)) { 305 | if ((0, _lodash.isFunction)(update)) { 306 | setChange(function (change) { 307 | return update(!(0, _lodash.isUndefined)(change) ? change : value); 308 | }); 309 | } else { 310 | setChange(update); 311 | } 312 | } else { 313 | throw "Premature attempt to update file:" + path; 314 | } 315 | }; 316 | 317 | var _useBlockstack2 = useBlockstack(), 318 | userSession = _useBlockstack2.userSession, 319 | userData = _useBlockstack2.userData, 320 | authenticated = _useBlockstack2.authenticated; // React roadmap is to support data loading with Suspense hook 321 | 322 | 323 | (0, _react.useEffect)(function () { 324 | if ((0, _lodash.isNil)(value)) { 325 | if (authenticated && path) { 326 | setPending(true); 327 | userSession.getFile(path).then(function (stored) { 328 | //console.info("[File] Get:", path, value, stored) 329 | var content = !(0, _lodash.isNil)(stored) ? reader(stored) : initial; 330 | setValue(content); 331 | })["catch"](function (error) { 332 | if (error.code === "does_not_exist") { 333 | // SDK 21 errs when file does not exist 334 | setValue(initial); 335 | } else { 336 | console.error("[File] Get error:", error); 337 | } 338 | })["finally"](function () { 339 | return setPending(false); 340 | }); 341 | } else if (path) { 342 | console.info("Waiting for user to sign on before reading file:", path); 343 | } else { 344 | console.warn("[File] No file path"); 345 | } 346 | } else {//console.log("[File] Get skip:", value) 347 | } 348 | }, [userSession, authenticated, path]); 349 | (0, _react.useEffect)(function () { 350 | if (!(0, _lodash.isUndefined)(change) && !pending) { 351 | if (!authenticated) { 352 | console.warn("[File] User not logged in"); 353 | } else if (!(0, _lodash.isEqual)(change, value)) { 354 | if ((0, _lodash.isNull)(change)) { 355 | setPending(true); 356 | userSession.deleteFile(path).then(function () { 357 | return setValue(null); 358 | })["catch"](function (err) { 359 | return console.warn("Failed deleting:", path, err); 360 | })["finally"](function () { 361 | return setPending(false); 362 | }); 363 | } else { 364 | var content = writer(change); 365 | var original = value; // setValue(change) // Cannot delay until saved? as it may cause inconsistent state 366 | 367 | setPending(true); 368 | userSession.putFile(path, content).then(function () { 369 | // console.info("[File] Put", path, content); 370 | setValue(change); 371 | setPending(false); 372 | })["catch"](function (err) { 373 | // Don't revert on error for now as it impairs UX 374 | // setValue(original) 375 | setPending(false); // FIX: delay before retry? 376 | 377 | console.warn("[File] Put error: ", path, err); 378 | }); 379 | } 380 | } else {// console.log("[File] Put noop:", path) 381 | } 382 | } 383 | }, [change, userSession, pending]); // FIX: deliver eventual error as third value? 384 | 385 | return [value, updateValue]; 386 | } 387 | /* 388 | ======================================================================= 389 | EXPERIMENTAL FUNCTIONALITY 390 | APT TO CHANGE WITHOUT FURTHER NOTICE 391 | ======================================================================= 392 | */ 393 | 394 | /* Low-level hooks for Gaia file system */ 395 | 396 | 397 | function useFilesList() { 398 | /* First value is a list of files, defaults to empty list. 399 | Better of undefined until names retrieved? 400 | Second value is null then number of files when list is complete. 401 | FIX: Is number of files useful as output? What about errors? */ 402 | var _useBlockstack3 = useBlockstack(), 403 | userSession = _useBlockstack3.userSession, 404 | userData = _useBlockstack3.userData, 405 | authenticated = _useBlockstack3.authenticated; 406 | 407 | var _useState7 = (0, _react.useState)([]), 408 | _useState8 = _slicedToArray(_useState7, 2), 409 | value = _useState8[0], 410 | setValue = _useState8[1]; 411 | 412 | var _useState9 = (0, _react.useState)(null), 413 | _useState10 = _slicedToArray(_useState9, 2), 414 | fileCount = _useState10[0], 415 | setCount = _useState10[1]; 416 | 417 | var appendFile = (0, _react.useCallback)(function (path) { 418 | setValue(function (value) { 419 | return [].concat(_toConsumableArray(value), [path]); 420 | }); 421 | return true; 422 | }); 423 | (0, _react.useEffect)(function () { 424 | if (userSession && authenticated) { 425 | userSession.listFiles(appendFile).then(setCount)["catch"](function (err) { 426 | return console.warn("Failed retrieving files list:", err); 427 | }); 428 | } 429 | }, [userSession, authenticated]); 430 | return [value, fileCount]; 431 | } 432 | 433 | function useFileUrl(path) { 434 | // FIX: Should combine with others? 435 | var _useBlockstack4 = useBlockstack(), 436 | userSession = _useBlockstack4.userSession, 437 | userData = _useBlockstack4.userData; 438 | 439 | var _useState11 = (0, _react.useState)(null), 440 | _useState12 = _slicedToArray(_useState11, 2), 441 | value = _useState12[0], 442 | setValue = _useState12[1]; 443 | 444 | (0, _react.useEffect)(function () { 445 | if (userSession) { 446 | if (path) { 447 | userSession.getFileUrl(path).then(setValue)["catch"](function (err) { 448 | return console.warn("Failed getting file url:", err); 449 | }); 450 | } else { 451 | setValue(null); 452 | } 453 | } 454 | }, [userSession, path]); 455 | return value; 456 | } 457 | 458 | function useFetch(path, init) { 459 | // For internal uses, likely better covered by other libraries 460 | var url = useFileUrl(path); 461 | 462 | var _useState13 = (0, _react.useState)(null), 463 | _useState14 = _slicedToArray(_useState13, 2), 464 | value = _useState14[0], 465 | setValue = _useState14[1]; 466 | 467 | (0, _react.useEffect)(function () { 468 | if (url) { 469 | fetch(url, init).then(setValue)["catch"](function (err) { 470 | return console.warn("Failed fetching url:", err); 471 | }); 472 | } else { 473 | setValue(null); 474 | } 475 | }, [url]); 476 | return value; 477 | } 478 | 479 | function useStateWithLocalStorage(storageKey) { 480 | var stored = localStorage.getItem(storageKey); 481 | var content = typeof stored != 'undefined' ? JSON.parse(stored) : null; 482 | console.log("PERSISTENT local:", stored, _typeof(stored)); 483 | 484 | var _useState15 = (0, _react.useState)(content), 485 | _useState16 = _slicedToArray(_useState15, 2), 486 | value = _useState16[0], 487 | setValue = _useState16[1]; 488 | 489 | _react["default"].useEffect(function () { 490 | localStorage.setItem(storageKey, JSON.stringify(value || null)); 491 | }, [value]); 492 | 493 | return [value, setValue]; 494 | } 495 | 496 | function useStored(props) { 497 | // Generalized persistent property storage 498 | var property = props.property, 499 | overwrite = props.overwrite, 500 | value = props.value, 501 | setValue = props.setValue; 502 | var version = props.version || 0; 503 | var path = props.path || property; 504 | 505 | var _ref4 = props.local ? useStateWithLocalStorage(path) : useStateWithGaiaStorage(path, { 506 | reader: JSON.parse, 507 | writer: JSON.stringify, 508 | initial: {} 509 | }), 510 | _ref5 = _slicedToArray(_ref4, 2), 511 | stored = _ref5[0], 512 | setStored = _ref5[1]; 513 | 514 | (0, _react.useEffect)(function () { 515 | // Load data from file 516 | if (!(0, _lodash.isUndefined)(stored) && !(0, _lodash.isNil)(stored) && !(0, _lodash.isEqual)(value, stored)) { 517 | console.info("STORED load:", path, stored, "Current:", value); 518 | 519 | if (stored.version && version != stored.version) { 520 | // ## Fix: better handling of version including migration 521 | console.error("Mismatching version in file", path, " - expected", version, "got", stored.version); 522 | } 523 | 524 | if ((0, _lodash.isFunction)(setValue)) { 525 | setValue(stored.content); 526 | } else { 527 | console.warn("Missing setValue property for storing:", property); 528 | } 529 | } else { 530 | console.log("STORED pass:", path, stored, "Current:", value); 531 | } 532 | }, [stored]); 533 | (0, _react.useEffect)(function () { 534 | // Store content to file 535 | if (!(0, _lodash.isUndefined)(stored) && !(0, _lodash.isUndefined)(value) && !(0, _lodash.isEqual)(value, stored && stored.content)) { 536 | var content = stored && stored.content; 537 | var replacement = overwrite ? value : (0, _lodash.merge)({}, content, value); 538 | console.info("STORED save:", path, replacement, "Was:", value); 539 | setStored({ 540 | version: version, 541 | property: property, 542 | content: replacement 543 | }); 544 | } else { 545 | console.log("STORED noop:", path, value, stored); 546 | } 547 | }, [value, stored]); 548 | return stored; 549 | } 550 | 551 | function usePersistent(props) { 552 | // Make context state persistent 553 | var property = props.property, 554 | overwrite = props.overwrite; 555 | var context = useBlockstack(); // useContext(BlockstackContext) // ## FIX: call useBlockstack() instead?? 556 | 557 | var value = property ? context[property] : null; 558 | var setValue = property ? function (value) { 559 | return setContext((0, _lodash.set)({}, property, value)); 560 | } : null; 561 | var stored = useStored((0, _lodash.merge)({}, props, { 562 | value: value, 563 | setValue: setValue 564 | })); 565 | return stored; 566 | } 567 | 568 | function Persistent(props) { 569 | // perhaps should only bind value to context for its children? 570 | // ##FIX: validate input properties, particularly props.property 571 | var property = props.property, 572 | debug = props.debug, 573 | overwrite = props.overwrite; 574 | var result = usePersistent(props); 575 | var context = useBlockstack(); // useContext(BlockstackContext) // ## FIX: call useBlockstack() instead?? 576 | 577 | var content = property ? context[property] : null; 578 | return debug ? /*#__PURE__*/_react["default"].createElement("div", null, /*#__PURE__*/_react["default"].createElement("h1", null, "Persistent ", property), /*#__PURE__*/_react["default"].createElement("p", null, "Stored: ", JSON.stringify(stored)), /*#__PURE__*/_react["default"].createElement("p", null, "Context: ", JSON.stringify(content))) : null; 579 | } 580 | /* External Dapps */ 581 | 582 | 583 | function getAppManifestAtom(appUri) { 584 | // Out: Atom promise containing a either an app manifest or a null value. 585 | // Avoid passing outside this module to avoid conflicts if there are multiple react-atom packages in the project 586 | var atom = _reactAtom.Atom.of(null); 587 | 588 | var setValue = function setValue(value) { 589 | return (0, _reactAtom.swap)(atom, function (state) { 590 | return value; 591 | }); 592 | }; 593 | 594 | try { 595 | var manifestUri = appUri + "/manifest.json"; 596 | var controller = new AbortController(); 597 | 598 | var cleanup = function cleanup() { 599 | return controller.abort(); 600 | }; 601 | 602 | console.info("FETCHING:", manifestUri); 603 | fetch(manifestUri, { 604 | signal: controller.signal 605 | }).then(function (response) { 606 | response.json().then(setValue); 607 | })["catch"](function (err) { 608 | console.warn("Failed to get manifest for:", appUri, err); 609 | }); // .finally (() => setValue({})) 610 | } catch (err) { 611 | console.warn("Failed fetching when mounting:", err); 612 | setValue({ 613 | error: err 614 | }); 615 | } 616 | 617 | return atom; 618 | } 619 | 620 | function createAppManifestHook(appUri) { 621 | var atom = getAppManifestAtom(appUri); 622 | return function () { 623 | return (0, _reactAtom.useAtom)(atom); 624 | }; 625 | } 626 | 627 | function useAppManifest(appUri) { 628 | // null when pending 629 | var _useState17 = (0, _react.useState)(null), 630 | _useState18 = _slicedToArray(_useState17, 2), 631 | value = _useState18[0], 632 | setValue = _useState18[1]; // ## FIX bug: May start another request while pending for a response 633 | 634 | 635 | (0, _react.useEffect)(function () { 636 | // #FIX: consider useCallback instead 637 | try { 638 | var manifestUri = appUri + "/manifest.json"; 639 | var controller = new AbortController(); 640 | 641 | var cleanup = function cleanup() { 642 | return controller.abort(); 643 | }; 644 | 645 | console.info("FETCHING:", manifestUri); 646 | fetch(manifestUri, { 647 | signal: controller.signal 648 | }).then(function (response) { 649 | response.json().then(setValue); 650 | })["catch"](function (err) { 651 | console.warn("Failed to get manifest for:", appUri, err); 652 | }); // .finally (() => setValue({})) 653 | 654 | return cleanup; 655 | } catch (err) { 656 | console.warn("Failed fetching when mounting:", err); 657 | setValue({ 658 | error: err 659 | }); 660 | } 661 | }, [appUri]); 662 | return value; 663 | } 664 | /* Update document element class */ 665 | 666 | 667 | function AuthenticatedDocumentClass(props) { 668 | // declare a classname decorating the document element when authenticated 669 | var className = props.name; 670 | 671 | var _useBlockstack5 = useBlockstack(), 672 | userData = _useBlockstack5.userData; 673 | 674 | (0, _react.useEffect)(function () { 675 | console.log("Updating documentElement classes to reflect signed in status:", !!userData); 676 | 677 | if (userData) { 678 | document.documentElement.classList.add(className); 679 | document.documentElement.classList.remove('reloading'); 680 | } else { 681 | document.documentElement.classList.remove(className); 682 | } 683 | }, [userData]); 684 | return null; 685 | } 686 | /* User Profiles ================================ */ 687 | 688 | 689 | function useProfile(username, zoneFileLookupURL) { 690 | // FIX: don't lookup if username is current profile... 691 | var _useState19 = (0, _react.useState)(null), 692 | _useState20 = _slicedToArray(_useState19, 2), 693 | value = _useState20[0], 694 | setValue = _useState20[1]; 695 | 696 | var _useBlockstack6 = useBlockstack(), 697 | userSession = _useBlockstack6.userSession; 698 | 699 | (0, _react.useEffect)(function () { 700 | if (userSession && username) { 701 | (0, _blockstack.lookupProfile)(username, zoneFileLookupURL).then(setValue)["catch"](function (err) { 702 | return console.warn("Failed to use profile:", err); 703 | }); 704 | } 705 | }, [userSession, username, zoneFileLookupURL]); 706 | return value; 707 | } -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "cd example; npm install; npm run build; mv cors/* build" 3 | publish = "example/build" 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-blockstack", 3 | "version": "0.6.10", 4 | "description": "React hooks for the Blockstack SDK", 5 | "directories": { 6 | "context": "./dist/context" 7 | }, 8 | "main": "./dist/index.js", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/njordhov/react-blockstack.git" 12 | }, 13 | "keywords": [ 14 | "react-hooks", 15 | "react", 16 | "blockstack", 17 | "hooks" 18 | ], 19 | "author": "Terje Norderhaug", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/njordhov/react-blockstack/issues" 23 | }, 24 | "homepage": "https://github.com/njordhov/react-blockstack#readme", 25 | "peerDependencies": { 26 | "blockstack": "^21.0.0", 27 | "react": "^16.8.0" 28 | }, 29 | "dependencies": { 30 | "@dbeining/react-atom": "^4.1.4", 31 | "lodash": "^4.17.5" 32 | }, 33 | "devDependencies": { 34 | "@babel/cli": "^7.8.4", 35 | "@babel/core": "^7.9.0", 36 | "@babel/preset-env": "^7.9.0", 37 | "@babel/preset-react": "^7.9.4", 38 | "@babel/runtime": "^7.9.2", 39 | "babel-loader": "^8.1.0" 40 | }, 41 | "scripts": { 42 | "build": "babel src --out-dir dist" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/context/index.js: -------------------------------------------------------------------------------- 1 | import { Blockstack, BlockstackContext } from '../index.js' 2 | 3 | export {Blockstack} 4 | export default BlockstackContext 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, createContext, useState, useEffect, useContext, useCallback, useReducer } from 'react' 2 | import { UserSession, AppConfig, Person, lookupProfile } from 'blockstack' 3 | import { Atom, swap, useAtom, deref} from "@dbeining/react-atom" 4 | import { isNil, isNull, isEqual, isFunction, isUndefined, merge, set, identity } from 'lodash' 5 | 6 | const defaultValue = {userData: null, signIn: null, signOut: null, authenticated: false} 7 | 8 | const contextAtom = Atom.of(defaultValue) 9 | 10 | /** 11 | * React hook for the Blockstack SDK 12 | * 13 | * @return {{userSession: UserSession, userData: ?UserData, signIn: ?function, signOut: ?function, person: ?Person}} Blockstack SDK context 14 | * 15 | * @example 16 | * 17 | * useBlockstack() 18 | */ 19 | 20 | export function useBlockstack () { 21 | return( useAtom(contextAtom) ) 22 | } 23 | 24 | export function setContext(update) { 25 | // use sparingly as it triggers all using components to update 26 | swap(contextAtom, state => merge({}, state, isFunction(update) ? update(state) : update)) 27 | } 28 | 29 | function signIn(e) { 30 | const { userSession } = deref(contextAtom) 31 | const update = {signIn: null} 32 | setContext( update ) 33 | userSession.redirectToSignIn() // window.location.pathname 34 | } 35 | 36 | function signOut(e) { 37 | const { userSession } = deref(contextAtom) 38 | const update = { userData: null, 39 | signIn: signIn, 40 | signOut: null, 41 | authenticated: false, 42 | person: null } 43 | setContext( update ) 44 | userSession.signUserOut() 45 | } 46 | 47 | function handleAuthenticated (userData) { 48 | window.history.replaceState({}, document.title, window.location.pathname) 49 | const update = { userData: userData, 50 | person: new Person(userData.profile), 51 | signIn: null, 52 | authenticated: true, 53 | signOut: signOut } 54 | setContext( update ) 55 | } 56 | 57 | export function initBlockstack (options) { 58 | // Idempotent 59 | const { userSession } = deref(contextAtom) 60 | if (!userSession) { 61 | const userSession = new UserSession(options) 62 | const update = { userSession: userSession } 63 | setContext( update ) 64 | if (userSession.isSignInPending()) { 65 | userSession.handlePendingSignIn().then( handleAuthenticated ) 66 | } else if (userSession.isUserSignedIn()) { 67 | handleAuthenticated (userSession.loadUserData()) 68 | } else { 69 | setContext( { signIn: signIn }) 70 | } 71 | return({ userSession: userSession }) 72 | } else { 73 | return({ userSession: userSession }) 74 | } 75 | } 76 | 77 | export default initBlockstack 78 | 79 | export const BlockstackContext = createContext(defaultValue) 80 | 81 | export function Blockstack(props) { 82 | const context = useBlockstack() 83 | return 84 | {props.children} 85 | 86 | } 87 | 88 | export function didConnect ({userSession: session}) { 89 | const { userSession, authenticated } = deref(contextAtom) 90 | if (userSession != session) { 91 | userSession = session; 92 | setContext({ userSession }) 93 | } 94 | if (!authenticated) { 95 | const userData = userSession.loadUserData(); 96 | handleAuthenticated(userData) 97 | } 98 | } 99 | 100 | export function useConnectOptions (options) { 101 | const {userSession} = useBlockstack() 102 | const authOptions = { 103 | redirectTo: '/', 104 | manifest: '/manifest.json', 105 | finished: ({userSession}) => { 106 | didConnect({userSession}) 107 | }, 108 | userSession: userSession, 109 | appDetails: { 110 | name: "Blockstack App", 111 | icon: '/logo.svg' 112 | } 113 | } 114 | return merge({}, authOptions, options) 115 | } 116 | 117 | export function useFile (path, options) { 118 | const [value, setValue] = useStateWithGaiaStorage (path, merge({reader:identity, writer:identity, initial: null}, options)) 119 | return ([value, !isUndefined(value) ? setValue : null ]) 120 | } 121 | 122 | /* 123 | function gaiaReducer (state, event) { 124 | // state machine for gaia file operations 125 | console.debug("Gaia:", state, event) 126 | switch (state.status) { 127 | case "start": 128 | switch (event.action) { 129 | case "reading": return({...state, status: "reading", pending: true}) 130 | } 131 | case "reading": 132 | switch (event.action) { 133 | case "read-error": return({...state, status: "read-error", error: event.error}) 134 | case "no-file": return ({...state, status: "no-file"}) 135 | case "read-success": return({...state, status: "ready", content: event.content, pending: false}) 136 | } 137 | case "no-file": 138 | switch (event.action) { 139 | case "writing": return({...state, status: "writing", content: event.content}) 140 | } 141 | case "writing": 142 | switch (event.action) { 143 | case "write-error": return({...state, status: "write-error", error: event.error}) 144 | case "write-complete": return({...state, status: "ready", content: event.content}) 145 | } 146 | case "ready": 147 | switch (event.action) { 148 | case "writing": return({...state, status: "writing", content: event.content}) 149 | case "deleting": return({...state, status: "deleting", content: event.content}) 150 | } 151 | case "deleting": 152 | switch (event.action) { 153 | case "delete-error": return({...state, status: "delete-error", error: event.error}) 154 | case "delete-complete": return ({...state, status: "no-file", content: null}) 155 | } 156 | default: return (state) 157 | } 158 | } 159 | */ 160 | 161 | function useStateWithGaiaStorage (path, {reader=identity, writer=identity, initial=null}) { 162 | /* Low level gaia file hook 163 | Note: Does not guard against multiple hooks for the same file 164 | Possbly an issue that change is set then value, could introduce inconsisitent state 165 | Return value: 166 | 1. File read -> content of file 167 | 2. File not existing or empty -> {} // could also be null 168 | 3. File not yet accessed or inaccessible -> undefined 169 | Attempting to set value when undefined throws an error. 170 | */ 171 | const [value, setValue] = useState(undefined) 172 | const [change, setChange] = useState(undefined) 173 | const [pending, setPending] = useState(false) 174 | // const [{status, pending}, dispatch] = useReducer(gaiaReducer, {:status: "start"}) 175 | 176 | const updateValue = (update) => { 177 | // ##FIX: properly handle update being a fn, call with (change || value) 178 | //console.log("[File] Update:", path, update) 179 | if (!isUndefined(value)) { 180 | if (isFunction(update)) { 181 | setChange(change => update(!isUndefined(change) ? change : value)) 182 | } else { 183 | setChange(update) 184 | } 185 | } else { 186 | throw "Premature attempt to update file:" + path 187 | } 188 | } 189 | const { userSession, userData, authenticated } = useBlockstack() 190 | // React roadmap is to support data loading with Suspense hook 191 | useEffect (() => { 192 | if ( isNil(value) ) { 193 | if (authenticated && path) { 194 | setPending(true) 195 | userSession.getFile(path) 196 | .then(stored => { 197 | //console.info("[File] Get:", path, value, stored) 198 | const content = !isNil(stored) ? reader(stored) : initial 199 | setValue(content) 200 | }) 201 | .catch(error => { 202 | if (error.code === "does_not_exist") { 203 | // SDK 21 errs when file does not exist 204 | setValue(initial) 205 | } else { 206 | console.error("[File] Get error:", error) 207 | } 208 | }) 209 | .finally(() => setPending(false)) 210 | } else if (path) { 211 | console.info("Waiting for user to sign on before reading file:", path) 212 | } else { 213 | console.warn("[File] No file path") 214 | } 215 | } else { 216 | //console.log("[File] Get skip:", value) 217 | }}, [userSession, authenticated, path]) 218 | 219 | useEffect(() => { 220 | if ( !isUndefined(change) && !pending ) { 221 | if (!authenticated) { 222 | console.warn("[File] User not logged in") 223 | } else if (!isEqual(change, value)){ 224 | if (isNull(change)) { 225 | setPending(true) 226 | userSession.deleteFile(path) 227 | .then(() => setValue(null)) 228 | .catch((err) => console.warn("Failed deleting:", path, err)) 229 | .finally(() => setPending(false)) 230 | } else { 231 | const content = writer(change) 232 | const original = value 233 | // setValue(change) // Cannot delay until saved? as it may cause inconsistent state 234 | setPending(true) 235 | userSession.putFile(path, content) 236 | .then(() => { 237 | // console.info("[File] Put", path, content); 238 | setValue(change) 239 | setPending(false)}) 240 | .catch((err) => { 241 | // Don't revert on error for now as it impairs UX 242 | // setValue(original) 243 | setPending(false) // FIX: delay before retry? 244 | console.warn("[File] Put error: ", path, err) 245 | })} 246 | } else { 247 | // console.log("[File] Put noop:", path) 248 | } 249 | }},[change, userSession, pending]) 250 | // FIX: deliver eventual error as third value? 251 | return [value, updateValue] 252 | } 253 | 254 | /* 255 | ======================================================================= 256 | EXPERIMENTAL FUNCTIONALITY 257 | APT TO CHANGE WITHOUT FURTHER NOTICE 258 | ======================================================================= 259 | */ 260 | 261 | /* Low-level hooks for Gaia file system */ 262 | 263 | export function useFilesList () { 264 | /* First value is a list of files, defaults to empty list. 265 | Better of undefined until names retrieved? 266 | Second value is null then number of files when list is complete. 267 | FIX: Is number of files useful as output? What about errors? */ 268 | const { userSession, userData, authenticated } = useBlockstack() 269 | const [value, setValue] = useState([]) 270 | const [fileCount, setCount] = useState(null) 271 | const appendFile = useCallback(path => { 272 | setValue((value) => [...value, path]); 273 | return true}) 274 | useEffect( () => { 275 | if (userSession && authenticated) { 276 | userSession.listFiles(appendFile) 277 | .then(setCount) 278 | .catch((err) => console.warn("Failed retrieving files list:", err)) 279 | }}, [userSession, authenticated]) 280 | return ([value, fileCount]) 281 | } 282 | 283 | export function useFileUrl (path) { 284 | // FIX: Should combine with others? 285 | const { userSession, userData } = useBlockstack() 286 | const [value, setValue] = useState(null) 287 | useEffect( () => { 288 | if (userSession) { 289 | if (path) { 290 | userSession.getFileUrl(path) 291 | .then(setValue) 292 | .catch((err) => console.warn("Failed getting file url:", err)) 293 | } else { 294 | setValue(null) 295 | } 296 | } 297 | }, [userSession, path]) 298 | return(value) 299 | } 300 | 301 | export function useFetch (path, init) { 302 | // For internal uses, likely better covered by other libraries 303 | const url = useFileUrl(path) 304 | const [value, setValue] = useState(null) 305 | useEffect ( () => { 306 | if (url) { 307 | fetch(url, init) 308 | .then(setValue) 309 | .catch((err) => console.warn("Failed fetching url:", err)) 310 | } else { 311 | setValue(null) 312 | } 313 | }, [url]) 314 | return (value) 315 | } 316 | 317 | function useStateWithLocalStorage (storageKey) { 318 | const stored = localStorage.getItem(storageKey) 319 | const content = (typeof stored != 'undefined') ? JSON.parse(stored) : null 320 | console.log("PERSISTENT local:", stored, typeof stored) 321 | const [value, setValue] = useState(content) 322 | React.useEffect(() => { 323 | localStorage.setItem(storageKey, JSON.stringify(value || null)); 324 | }, [value]) 325 | return [value, setValue]; 326 | } 327 | 328 | export function useStored (props) { 329 | // Generalized persistent property storage 330 | const {property, overwrite, value, setValue} = props 331 | const version = props.version || 0 332 | const path = props.path || property 333 | const [stored, setStored] = props.local 334 | ? useStateWithLocalStorage(path) 335 | : useStateWithGaiaStorage(path, {reader: JSON.parse, writer: JSON.stringify, initial: {}}) 336 | useEffect(() => { 337 | // Load data from file 338 | if (!isUndefined(stored) && !isNil(stored) && !isEqual (value, stored)) { 339 | console.info("STORED load:", path, stored, "Current:", value) 340 | if (stored.version && version != stored.version) { 341 | // ## Fix: better handling of version including migration 342 | console.error("Mismatching version in file", path, " - expected", version, "got", stored.version) 343 | } 344 | if (isFunction(setValue)) { 345 | setValue(stored.content) 346 | } else { 347 | console.warn("Missing setValue property for storing:", property) 348 | } 349 | } else { 350 | console.log("STORED pass:", path, stored, "Current:", value) 351 | } 352 | }, [stored]) 353 | 354 | useEffect(() => { 355 | // Store content to file 356 | if (!isUndefined(stored) && !isUndefined(value) && !isEqual (value, stored && stored.content)) { 357 | const content = stored && stored.content 358 | const replacement = overwrite ? value : merge({}, content, value) 359 | console.info("STORED save:", path, replacement, "Was:", value) 360 | setStored({version: version, property: property, content: replacement}) 361 | } else { 362 | console.log("STORED noop:", path, value, stored) 363 | } 364 | }, [value, stored]) 365 | return ( stored ) 366 | } 367 | 368 | export function usePersistent (props){ 369 | // Make context state persistent 370 | const {property, overwrite} = props 371 | const context = useBlockstack() // useContext(BlockstackContext) // ## FIX: call useBlockstack() instead?? 372 | const value = property ? context[property] : null 373 | const setValue = property ? (value) => setContext( set({}, property, value )) : null 374 | const stored = useStored (merge ({}, props, {value: value, setValue: setValue})) 375 | return( stored ) 376 | } 377 | 378 | export function Persistent (props) { 379 | // perhaps should only bind value to context for its children? 380 | // ##FIX: validate input properties, particularly props.property 381 | const {property, debug, overwrite} = props 382 | const result = usePersistent(props) 383 | const context = useBlockstack() // useContext(BlockstackContext) // ## FIX: call useBlockstack() instead?? 384 | const content = property ? context[property] : null 385 | return ( 386 | debug ? 387 |
388 |

Persistent {property}

389 |

Stored: { JSON.stringify( stored ) }

390 |

Context: { JSON.stringify( content ) }

391 |
392 | :null) 393 | } 394 | 395 | /* External Dapps */ 396 | 397 | function getAppManifestAtom (appUri) { 398 | // Out: Atom promise containing a either an app manifest or a null value. 399 | // Avoid passing outside this module to avoid conflicts if there are multiple react-atom packages in the project 400 | const atom = Atom.of(null) 401 | const setValue = (value) => swap(atom, state => value) 402 | try { 403 | const manifestUri = appUri + "/manifest.json" 404 | const controller = new AbortController() 405 | const cleanup = () => controller.abort() 406 | console.info("FETCHING:", manifestUri) 407 | fetch(manifestUri, {signal: controller.signal}) 408 | .then ( response => {response.json().then( setValue )}) 409 | .catch ( err => {console.warn("Failed to get manifest for:", appUri, err)}) 410 | // .finally (() => setValue({})) 411 | } catch (err) { 412 | console.warn("Failed fetching when mounting:", err) 413 | setValue({error: err}) 414 | } 415 | return (atom) 416 | } 417 | 418 | export function createAppManifestHook (appUri) { 419 | const atom = getAppManifestAtom(appUri) 420 | return( () => useAtom(atom) ) 421 | } 422 | 423 | export function useAppManifest (appUri) { 424 | // null when pending 425 | const [value, setValue] = useState(null) 426 | // ## FIX bug: May start another request while pending for a response 427 | useEffect(() => { // #FIX: consider useCallback instead 428 | try { 429 | const manifestUri = appUri + "/manifest.json" 430 | const controller = new AbortController() 431 | const cleanup = () => controller.abort() 432 | console.info("FETCHING:", manifestUri) 433 | fetch(manifestUri, {signal: controller.signal}) 434 | .then ( response => {response.json().then( setValue )}) 435 | .catch ( err => {console.warn("Failed to get manifest for:", appUri, err)}) 436 | // .finally (() => setValue({})) 437 | return (cleanup) 438 | } catch (err) { 439 | console.warn("Failed fetching when mounting:", err) 440 | setValue({error: err}) 441 | } 442 | }, [appUri]) 443 | return (value) 444 | } 445 | 446 | /* Update document element class */ 447 | 448 | export function AuthenticatedDocumentClass (props) { 449 | // declare a classname decorating the document element when authenticated 450 | const className = props.name 451 | const { userData } = useBlockstack() 452 | useEffect(() => { 453 | console.log("Updating documentElement classes to reflect signed in status:", !!userData) 454 | if (userData) { 455 | document.documentElement.classList.add(className) 456 | document.documentElement.classList.remove('reloading') 457 | } else { 458 | document.documentElement.classList.remove(className) 459 | }}, [userData]) 460 | return (null) 461 | } 462 | 463 | /* User Profiles ================================ */ 464 | 465 | export function useProfile (username, zoneFileLookupURL) { 466 | // FIX: don't lookup if username is current profile... 467 | const [value, setValue] = useState(null) 468 | const { userSession } = useBlockstack() 469 | useEffect(() => { 470 | if (userSession && username) { 471 | lookupProfile(username, zoneFileLookupURL) 472 | .then(setValue) 473 | .catch((err) => console.warn("Failed to use profile:", err)) 474 | }}, [userSession, username, zoneFileLookupURL]) 475 | return (value) 476 | } 477 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | module.exports = { 3 | entry: ['./src/index.js', './src/context.js'], 4 | target: 'web', 5 | output: { 6 | path: path.resolve(__dirname, 'build'), 7 | filename: 'index.js', 8 | libraryTarget: 'commonjs2' 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.js$/, 14 | include: path.resolve(__dirname, 'src'), 15 | exclude: /(node_modules|bower_components|build)/, 16 | use: { 17 | loader: 'babel-loader', 18 | options: { 19 | presets: ['env'] 20 | } 21 | } 22 | } 23 | ] 24 | }, 25 | externals: { 26 | 'react': 'commonjs react' 27 | } 28 | }; 29 | --------------------------------------------------------------------------------