├── src ├── react-app-env.d.ts ├── setupTests.ts ├── App.test.tsx ├── index.css ├── reportWebVitals.ts ├── index.tsx ├── App.css ├── logo.svg └── App.tsx ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── .gitignore ├── tsconfig.json ├── package.json ├── removinggroups.md ├── removingchannels.md ├── invitingmembers.md ├── addingmembers.md ├── scryingmessages.md ├── creatinggroups.md ├── removingmembers.md ├── creatingchannels.md ├── README.md ├── logginging.md └── sendingmessages.md /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/witfyl-ravped/urbit-react-cookbook/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/witfyl-ravped/urbit-react-cookbook/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/witfyl-ravped/urbit-react-cookbook/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Urbit React Cookbook", 3 | "name": "Urbit React Cookbook", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "urbit-tsx", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "@types/jest": "^26.0.15", 10 | "@types/node": "^12.0.0", 11 | "@types/react-dom": "^17.0.0", 12 | "@urbit/api": "^1.0.2", 13 | "@urbit/http-api": "^1.1.9", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "react-scripts": "4.0.3", 17 | "typescript": "^4.1.2", 18 | "web-vitals": "^1.0.1" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "devDependencies": { 45 | "@types/react": "^17.0.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /removinggroups.md: -------------------------------------------------------------------------------- 1 | # Removing Groups 2 | 3 | ## Local Function 4 | 5 | Similar to `removeChannelLocal()` we only need a very simple `thread` to remove a `group` from our `ship` and we see it on line 303: 6 | 7 | ``` 8 | function removeGroupLocal(group: string) { 9 | if (!urb) return; 10 | const groupResource = resourceFromPath(group); 11 | 12 | // Here we're passing a thread the deleteGroup function from the groups library and destructuring the ship and name from 13 | // the group resource we created above 14 | 15 | urb.thread(deleteGroup(groupResource.ship, groupResource.name)); 16 | window.confirm(`Removed group ${group}`); 17 | window.location.reload(); 18 | } 19 | ``` 20 | 21 | We're following the pattern of turning our `group` back into a `Resource` using `resourceFromPath()` and then passing a `thread` its `ship` and `name` via the `deleteGroup()` formatting function. 22 | 23 | ## UI 24 | 25 | No surprises in the UI starting on line 703: 26 | 27 | ``` 28 |
{ 30 | e.preventDefault(); 31 | const target = e.target as typeof e.target & { 32 | group: { value: string }; 33 | }; 34 | const group = target.group.value; 35 | removeGroupLocal(group); 36 | }}> 37 | 43 |
44 | 45 |
46 | ``` 47 | 48 | We leverage our `group` array from state in order to `map` its contents into a dropdown menu that our users and select from and then remove. 49 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Urbit React Cookbook 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /removingchannels.md: -------------------------------------------------------------------------------- 1 | # Removing Channels 2 | 3 | ## Local Function 4 | 5 | Another simple example that just requires data sent in via `urb.thread()` on line 314: 6 | 7 | ``` 8 | // React function to remove a channel(chat) from a group on our ship 9 | function removeChannelLocal(channel: Path) { 10 | if (!urb) return; 11 | const channelResource = resourceFromPath(channel); 12 | // Similar to removeGroupLocal we're converting the Path string to a Resource type 13 | // Notice below that we use the deSig function. Different API functions have different formatting processes 14 | // deSig will remove the ~ from the ship name because deleteGraph is instructed to add one. Without deSig we would 15 | // end up with "~~zod" 16 | urb.thread(deleteGraph(deSig(channelResource.ship), channelResource.name)); 17 | window.confirm(`Removed channel ${channel}`); 18 | window.location.reload(); 19 | } 20 | ``` 21 | 22 | We're passing the `thread` a formatting function called `deleteGraph` which lives in `@urbit/api/dist/graph/lib.d.ts`. By reading it's source we see that it only accepts `ship` names without leading `~`s so we import and implment the `deSig()` function from `@urbit/api` to remove the `~` from our `ship`. 23 | 24 | ## UI 25 | 26 | Nothing new here but including the UI here for reference. Line 682: 27 | 28 | ``` 29 |
{ 31 | e.preventDefault(); 32 | const target = e.target as typeof e.target & { 33 | chat: { value: Path }; 34 | }; 35 | const chat = target.chat.value; 36 | removeChannelLocal(chat); 37 | }} 38 | > 39 | 45 |
46 | 47 |
48 | ``` 49 | 50 | Just a single input from a drop down which is rendered by maping our `keys` array from state. 51 | -------------------------------------------------------------------------------- /invitingmembers.md: -------------------------------------------------------------------------------- 1 | # Inviting Members 2 | 3 | ## Local Function 4 | 5 | Another simple example that just requires us to send a `thread` into our ship. Line 375: 6 | 7 | ``` 8 | function inviteLocal(group: string, ship: string, description: string) { 9 | if (!urb) return; 10 | 11 | const groupResource = resourceFromPath(group); 12 | const shipArray: string[] = []; 13 | shipArray.push(ship); 14 | urb.thread( 15 | invite(groupResource.ship, groupResource.name, shipArray, description) 16 | ); 17 | 18 | window.confirm(`Invited ${ship} to ${group}`); 19 | window.location.reload(); 20 | } 21 | ``` 22 | 23 | Here we're using `urb.thread()` again and passing it another formatting function that lives in `@urbit/api/dist/groups/lib.d.ts`, this time it is `invite()`. Again we'll need to turn our `group` name from a `Path` to a `Resource` since we're getting it as a string from a dropdown. And just like `addMembers()` it takes `ship`s as an array so we need to make one as well. 24 | 25 | ## UI 26 | 27 | Nothing new here, just a dropdown menu to select the `group`, input field to enter a `ship` and another input field to write a nice message on line 630: 28 | 29 | ``` 30 |
{ 32 | e.preventDefault(); 33 | const target = e.target as typeof e.target & { 34 | group: { value: string }; 35 | ship: { value: string }; 36 | description: { value: string }; 37 | }; 38 | 39 | const group = target.group.value; 40 | const ship = target.ship.value; 41 | const description = target.description.value; 42 | inviteLocal(group, ship, description); 43 | }}> 44 | 50 |
51 | 52 |
53 | 58 |
59 | 60 |
61 | ``` 62 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /addingmembers.md: -------------------------------------------------------------------------------- 1 | # Adding Members 2 | 3 | ## Local Function 4 | 5 | This is a simple example since we don't need any information from our ship in order to add members to a group. We just need to format ship names and send them as a `poke`. We do this with the function on line 275: 6 | 7 | ``` 8 | // This is our local function to add members to a group 9 | function addMembersLocal(group: Path, ship: string) { 10 | if (!urb) return; 11 | 12 | // Since addMembers() accepts multiple ships, we'll have to create an array out of our ship even though we are only sending in one at a time in our example 13 | const shipArray: string[] = []; 14 | shipArray.push(ship); 15 | // We also need to coerce our group Path into a Resource to accommodate addMembers() data types 16 | const groupResource = resourceFromPath(group); 17 | urb.poke(addMembers(groupResource, shipArray)); 18 | 19 | window.confirm(`Added ${ship} to ${group}`); 20 | window.location.reload(); 21 | } 22 | ``` 23 | 24 | Notice that we're creating an array since the `addMembers()` function is capable of adding multiple `ship`s at a time and so its input requires an array. See for yourself at `@urbit/api/dist/groupStoreAction.lib.d.ts`. So we push our single `ship` into this array and send it into `addMembers()` along with the name of the `group` we're adding members to. Like in previous examples our users are selecting the `group` from a dropdown menu, and for that we need the `group` to be a string i.e. `Path`. This means that we will once again need `resourceFromPath()` to change it back into a `Resource` since that is the data type `addMembers()` expects. 25 | 26 | ## UI 27 | 28 | Now we just need the UI to collect data from our user which we see on line 595: 29 | 30 | ``` 31 |
{ 33 | e.preventDefault(); 34 | const target = e.target as typeof e.target & { 35 | group: { value: string }; 36 | member: { value: string }; 37 | }; 38 | const group = target.group.value; 39 | const member = target.member.value; 40 | addMembersLocal(group, member); 41 | }} 42 | > 43 | {/* Here we leverage our groups state variable to render a dropdown list of available groups that the user can add members to */} 44 | 50 |
51 | 56 |
57 | 58 |
59 | ``` 60 | 61 | These are all functions we've already seen. A dropdown menu of `groups` and then an input field for our users to enter a `ship` to add. 62 | -------------------------------------------------------------------------------- /scryingmessages.md: -------------------------------------------------------------------------------- 1 | # Scrying Messages 2 | 3 | ## Local Function 4 | 5 | Finally we introduce a new concept to see a fourth way we can interact with our ship from React, namely `scry`ing. These are useful to make calls for specific pieces of information for which you don't want to setup a `subscription`. A `Scry` takes an `app` and a `path` and returns the data as a `Promise` similar to a `subscription`. But unlike a `subscription` we don't receive any subsequent data from our `scry` so we don't need to manage it. 6 | 7 | Beginning on line 389: 8 | 9 | ``` 10 | function scryLocal(key: Path, count: string) { 11 | if (!urb) return; 12 | 13 | const keyResource = resourceFromPath(key); 14 | const scry: Scry = { 15 | app: "graph-store", 16 | path: `/newest/${keyResource.ship}/${keyResource.name}/${count}`, 17 | }; 18 | 19 | urb.scry(scry).then((messages) => { 20 | const nodes = messages["graph-update"]["add-nodes"]["nodes"]; 21 | Object.keys(nodes).forEach((index) => { 22 | console.log( 23 | `${nodes[index].post.author}:`, 24 | nodes[index].post.contents[0].text 25 | ); 26 | }); 27 | }); 28 | } 29 | ``` 30 | 31 | First we format our `Scry` by giving it the app `graph-store` and destructuring our `keyResource` to give it the `path` given to us by our user in the UI below. Our user also gives us the number of previous messages to return via the `count` argument which we add to the end of our `path`. 32 | 33 | Then we call `urb.scry()` passing it the `scry` variable we just made and append `.then()` to create a `Promise` to handle the returned data. Notice that we're parsing this very similarly to the `logHandler` callback we used on our `subscription` earlier in our app. In this example we are just `console.log`ing the authoring `ship` and text from each message, but of course you can do whatever you'd like from within the `Promise`. 34 | 35 | ## UI 36 | 37 | While the local function contains a new element, the UI is what we're used to seeing and can be found on line 724: 38 | 39 | ``` 40 |
{ 42 | e.preventDefault(); 43 | const target = e.target as typeof e.target & { 44 | chat: { value: Path }; 45 | count: { value: string }; 46 | }; 47 | const chat = target.chat.value; 48 | const count = target.count.value; 49 | scryLocal(chat, count); 50 | }} 51 | > 52 | 58 |
59 | 60 |
61 | 62 |
63 | ``` 64 | 65 | We just need to collet a `channel` to `scry` and a `count` to determine the number of messages we're requesting. This gets passed into `scryLocal()` which `console.log`s the results. 66 | -------------------------------------------------------------------------------- /creatinggroups.md: -------------------------------------------------------------------------------- 1 | # Creating Groups 2 | 3 | We're about to walkthrough the process of collecting user input via React UI, formatting it with JS functions in our app (we're calling these local functions to distinguish them from the JS functions in `@urbit/api`), and then leverage the `@urbit/api` functions to send it into our Urbit via the `urb` object we created in the last lesson. All the examples in our demo app will be a variation on process below. 4 | 5 | ## Local Function 6 | 7 | Let's start by looking at how we will parse our users' input in order to create a `group` on our ship, and then we'll look at the UI we use to collect said data. 8 | 9 | On line 197 we make a function to create a `group` from the user input which we will collect below. We're calling it `createGroupLocal` to distinguish it from the `@urbit/api` function `createGroup`: 10 | 11 | ``` 12 | function createGroupLocal(groupName: string, description: string) { 13 | if (!urb) return; 14 | urb.thread( 15 | // Notice that unlike subscriptions, we pass a formatting function into our thread function. In this case it is createGroup 16 | // I'm using default values for the 'open' object but you can create a UI to allow users to input custom values. 17 | createGroup( 18 | // The name variable stays under the hood and we use our helper format function to create it from the groupName 19 | formatGroupName(groupName), 20 | { 21 | open: { 22 | banRanks: [], 23 | banned: [], 24 | }, 25 | }, 26 | groupName, 27 | description 28 | ) 29 | ); 30 | window.confirm(`Created group ${groupName}`); 31 | window.location.reload(); 32 | } 33 | ``` 34 | 35 | First we make sure `urb` is set up by running `if(!urb) return`, TypeScript forces us to check this as well, otherwise it returns an error that `urb` might be null. Conveniently we can call `.thread()` directly on our `urb` object to send a `thread` into our ship. However, we need to add an additional formatting function `createGroup` which we import from `@urbit/api`. It is exported from `@urbit/api/dist/groups/lib.d.ts`, check it out there to see which parameters it accepts. 36 | 37 | The first argument we pass uses the kebab formatting function which we'll cover below. Then for simplicity I'm leaving the default policy values but of course you can customize those as well. We also pass in `groupName` and `description` collected from the UI we'll go over below. 38 | 39 | Finally we add a little pop-up to `confirm` that the group was created, and then a reload function to populate the rest of the UI. If we were using functional components this re-render would happen automatically, or perhaps I'm missing a way to update the `urb` object to cause a re-render. Please let me know if so. 40 | 41 | Line 188 has the kebab format function we mentioned: 42 | 43 | ``` 44 | function formatGroupName(name: string) { 45 | return name 46 | .replace(/([a-z])([A-Z])/g, "$1-$2") 47 | .replace(/\s+/g, "-") 48 | .toLowerCase(); 49 | } 50 | ``` 51 | 52 | Landscape coerces group names into kebab format so this is just us rolling our own function here. 53 | 54 | ## UI 55 | 56 | Now we're ready to render the UI that will allow users to name their groups and add a description on line 468: 57 | 58 | ``` 59 |
{ 61 | e.preventDefault(); 62 | const target = e.target as typeof e.target & { 63 | groupName: { value: string }; 64 | description: { value: string }; 65 | }; 66 | {/* We're just creating variables from the input fields defined below, createGroupLocal handles the formatting */} 67 | 68 | const groupName = target.groupName.value; 69 | const description = target.description.value; 70 | createGroupLocal(groupName, description); 71 | }}> 72 | 77 |
78 | 83 |
84 | 85 |
86 | ``` 87 | 88 | You'll notice this is the same pattern as we saw in the [Logging In](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/logginging.md) lesson. Namely we use the `onSubmit` prop to create an object from our two input fields (in this case `groupName` and `description`), and then we destructure those values into variables that we pass into our `createGroupLocal()` function described above. 89 | -------------------------------------------------------------------------------- /removingmembers.md: -------------------------------------------------------------------------------- 1 | # Removing Members 2 | 3 | ## Local Function 4 | 5 | Similar to Adding Members we only need a local function for this examplem, line 290: 6 | 7 | ``` 8 | // Our local function to remove members from a group. Requires the same formatting steps as addMembersLocal() 9 | function removeMembersLocal(group: Path, ship: string) { 10 | if (!urb) return; 11 | 12 | const shipArray: string[] = []; 13 | shipArray.push(`~${ship}`); 14 | const groupResource = resourceFromPath(group); 15 | urb.poke(removeMembers(groupResource, shipArray)); 16 | 17 | window.confirm(`Removeed ${ship} from ${group}`); 18 | window.location.reload(); 19 | } 20 | ``` 21 | 22 | It's almost exactly the same as `addMembersLocal()` except that we send `urb.poke` `removeMembers()` instead of `addMembers()`. The other difference is that `removeMembers()` requires that `ships` incude their `~` which we add when we push our `ship` into its array. 23 | 24 | ## UI 25 | 26 | This is the most complex UI example in our example app so I'm taking it as an opportunity to address, albeit briefly, functional components. In a full blown app each piece of UI would likely be its own functional component, likely imported from its own separate file. This is a scaled down example, but enough to understand what functional components are and why we'd want to use them. 27 | 28 | Starting on line 329 we see: 29 | 30 | ``` 31 | // We're using a functional component here to render the UI because removing members from groups requires a little extra logic 32 | // We want the user to select between groups to render a list of each group's members. We need the extra steps since the member list is derived from the group Paths 33 | // which a user can toggle between. Therefore lists will have to be rendered according to user input 34 | 35 | const RenderRemoveMembers = () => { 36 | // Making this a functional component gives us access to its own useState hook for free. We'll use this to populate lists of members from user input 37 | const [selectedGroup, setSelectedGroup] = useState("default"); 38 | 39 | ``` 40 | 41 | First thing to notice is that we're able to use `useState()` in our function. This is because React gives us access to Hooks in functional components allowing them to be self contained, self updating, sovereign pieces of UI. We will use this functionality to keep track of which group our user has selected and use that selection to render out the list of members belonging to that group. Our user can then select from the list of members and choose one to remove. 42 | 43 | Continuing on: 44 | 45 | ``` 46 | return ( 47 |
{ 49 | e.preventDefault(); 50 | const target = e.target as typeof e.target & { 51 | group: { value: string }; 52 | member: { value: string }; 53 | }; 54 | const group = groups[parseInt(selectedGroup)].name; 55 | const member = target.member.value; 56 | removeMembersLocal(group, member); 57 | }} 58 | > 59 | 72 |
73 | {/* This is the extra step needed to create a member list based on which group our user selects*/} 74 | 82 |
83 | 84 |
85 | ); 86 | }; 87 | ``` 88 | 89 | Two things are different from the UI we've seen so far. First notice that in the first `` tag that we are storing the `index` in our state variable, not the `group` name. We'll see why in a second. 90 | 91 | Let's look at the second ` 120 | 121 | {groups.map((group) => ( 122 | 123 | ))} 124 | 125 |
126 | 127 |
128 | 133 |
134 | 135 | 136 | 137 | ``` 138 | 139 | The only new pattern here, which we will use later as well, is on line 521. We map over the `groups` variable in our state to allow our users to select the group in which they want to create a new channel. Refer back to the `createManagedGraph()` call that we made in the previous function. Now you can see how `createChannelLocal()` gets each of its arguments from the UI above and formats them into a `thread` to send into our ship. 140 | 141 | Now you know how to setup `subscriptions` to your Urbit. It's the most complex communication pattern to setup in React since it continuously monitors data from `ship`s i.e. requires a callback function. Follow the pattern from this example to `subscribe` and handle data from any `app` and `path` on your `ship`. 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Urbit React Cookbook 2 | 3 | In this lesson you will learn how to build a React frontend that connects to your ship and interacts with it using the four basic forms of communication, namely: `poke`, `subscribe`, `thread`, `scry`. These examples will get you started building React apps for Urbit as well as familiarize you with the Urbit APIs required to get you writing your own custom functions. 4 | 5 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 6 | 7 | ## Getting Started 8 | 9 | First run `yarn install` to install project dependencies. 10 | 11 | Then boot a fake `~zod` ship connected to localhost port 8080 (alternatively you can select a different port by editing `src/App.tsx`) For instructions on booting fake ships see [this guide](https://github.com/timlucmiptev/gall-guide/blob/62f4647b614dc201796204a0214629375a1a56bb/workflow.md). 12 | 13 | Then run `yarn start` to boot the local React server which will run at `http://localhost:3000` by default. 14 | 15 | In a separate browser tab connect to your fake `~zod`'s Landscape page which is `http://localhost:8080` or a custom port of you changed it 16 | 17 | Back at the React app, enter your ships localhost address and code in the top right corner. In this example the host will be `http://localhost:8080`. Obtain your ship's code by enter `+code` in your ship's dojo. For `~zod` the code will be `lidlut-tabwed-pillex-ridrup`. Then press "Login." 18 | 19 | Now we've almost got everything setup and talking to each other. Finally enter `+cors-registry` in your ship's dojo. You will likely see two URLs in the `requests` entry: 20 | 21 | `~~http~3a.~2f.~2f.localhost~3a.3000` 22 | and 23 | `~~http~3a.~2f.~2f.localhost~3a.8080` 24 | 25 | You'll need to add it to the approved list by running: 26 | 27 | `|cors-approve ~~http~3a.~2f.~2f.localhost~3a.3000` 28 | and
29 | `|cors-approve ~~http~3a.~2f.~2f.localhost~3a.8080` 30 | 31 | Verfiy these commands worked by running `+cors-registry` again. 32 | 33 | ## Logging in 34 | 35 | [Click here](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/logginging.md) for a detailed walkthrough of the login flow you performed above 36 | 37 | ## Creating Groups 38 | 39 | How to call `threads` 40 | 41 | Start by adding a group using the form on the left of the React app. Enter a Group Name, Group Description and press "Create Group." Your browser will confirm the successful creation with an alert window. 42 | After clicking OK in the alert window navigate to your Landscape tab to confirm that the group was created and it's tile added. 43 | 44 | [Click here](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/creatinggroups.md) for a detailed walkthrough of the Creating Groups functions and UI 45 | 46 | ## Creating Channels 47 | 48 | How to create and maintain `subscriptions` 49 | 50 | Back in the React app, fill in the middle "Create Channel" inputs. Select your newly created group from the drop-down and enter a Chat Name and Description and press "Create Channel". This should also be confirmed by a window alert upon success. 51 | 52 | After clicking OK in the alert window navigate to your Landscape page to confirm that the channel was created within your previously created group. 53 | 54 | [Click here](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/creatingchannels.md) for a detailed walkthrough of the Creating Channels functions and UI 55 | 56 | ## Sending Messages 57 | 58 | Calling `thread`s and managing `subscriptions` 59 | 60 | NOTE: We are still waiting on an update to `@urbit/http-api` that uses the new `group-update` versioning syntax. Until then the steps below will not work. 61 | 62 | Again back in the React app, select a chat from the drop-down menu under "Send Message" and enter some text. Upon clicking the "Send Message" button you should once again receive a confirmation alert. 63 | 64 | Your message should now appear at the top of the React app. You can navigate back to your Landscape window to see the message you just sent from React displayed in the newly created channel. 65 | 66 | Notice that you can send a message to the channel from Landscape and that it will also appear at the top of the React app. 67 | 68 | [Click here](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/sendingmessages.md) for a detailed walkthrough of the Sending Messages functions and UI 69 | 70 | ## Adding Members 71 | 72 | How to send a `poke` 73 | 74 | In this input select a Group that you have created from the dropdown menu and enter a ship with '~' prefix. Then press "Add Member" 75 | 76 | Confirm that the member has been added via Group info in Landscape. You can find this information be clicking on the Group tile. Then the gear icon in the top left corner of the group. From there click on Participants and confirm the ship you added is listed 77 | 78 | Try adding a few different ships 79 | 80 | [Click here](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/addingmembers.md) for a detailed walkthrough of the Adding Members functions and UI 81 | 82 | ## Removing Members 83 | 84 | Using `poke`s 85 | 86 | First select one of the groups you created from the "Select a Group" drop down in this section 87 | 88 | After selecting a group the list of members will auto-populate in the "Select a Member" dropdown. Select a member from this list 89 | 90 | Now click "Remove Member" and then confirm that this user was indeed removed via your Landscape interface 91 | 92 | [Click here](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/removingmembers.md) for a detailed walkthrough of the Removing Members functions and UI 93 | 94 | ## Inviting Members 95 | 96 | Using `thread`s 97 | 98 | To fully test this function we recommend booting another fake ship on your local network. Follow the instructions in the introduction to this guide for support in creating and booting fake ships. Call this second one `~mus` 99 | 100 | After `~mus` is running, you should see `~zod is your neighbor` displayed in its terminal. Check which port it is running on by looking for a message similar to this: `http: web interface live on http://localhost:8081` in the startup text. Use that link to log into it's Landscape interface 101 | 102 | Back in the React interface, select a group under the "Invite Members" heading 103 | 104 | Enter the name of your new fake ship in the input below, `~mus` in my case 105 | 106 | Then enter a message and press "Send Invite" 107 | 108 | After clicking "Ok" in confirmation popup, navigate to the `~mus` Landscape interface. After a few moments you should receive a notification ontop of the Leap menu in the top left corner. Click on it to accept your invite and join the group. 109 | 110 | Once inside the group you will have access the channel(chat) you created in the previous step. Notice that you can send a message in the chat from `~mus` and it will display at the top of our React interface 111 | 112 | [Click here](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/invitingmembers.md) for a detailed walkthrough of the Inviting Members functions and UI 113 | 114 | ## Removing Channels 115 | 116 | Using `thread`s 117 | 118 | To test this function start by adding a new channel under the Create Channel heading. 119 | 120 | Verify that it has been added by checking in Landscape. You can also test it by selecting it from the "Select a Channel" dropdown selector under the "Remove Channels" header. Go ahead and select it from this menu and click "Remove Channel." 121 | 122 | Confirm the pop and then verify the channel has been removed from both the drop down menus and your Landscape tab. 123 | 124 | [Click here](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/removingchannels.md) for a detailed walkthrough of the Inviting Members functions and UI 125 | 126 | ## Removing Groups 127 | 128 | Using `thread`s 129 | 130 | Choose your group from "Select a Group" dropdown under the "Remove Group" header and click "Remove Group". 131 | 132 | Click OK and verify that the group and its tile has been removed from Landscape. 133 | 134 | [Click here](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/removinggroups.md) for a detailed walkthrough of the Inviting Members functions and UI 135 | 136 | ## Scrying Messages 137 | 138 | How to `scry` 139 | 140 | This function allows you to `scry` a variable number of recent messages from a given channel. To test this out go ahead and send a few messages to one of the channels you created. Bonus points if you send a few more from another ship that you added or invited 141 | 142 | Now select this channel from the dropdown under "Scrying Messages" 143 | 144 | In the "Count" input enter a number of messages to scry out of `graph-store` 145 | 146 | Finally press "Scry Messages" and check the results in the console 147 | 148 | [Click here](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/scryingmessages.md) for a detailed walkthrough of the Scrying Messages functions and UI 149 | -------------------------------------------------------------------------------- /logginging.md: -------------------------------------------------------------------------------- 1 | # Logging in 2 | 3 | ## Create API 4 | 5 | There are different ways that you could set up the login flow for connecting to an Urbit ship through JS. Notice that in this example app we are keeping all the code in one file, this is to serve as a reference more than recommended architecture for a functional app. With that in mind, we'll be using a very simple method to allow a user to login to their ship and store their credentials in `localStorage` for ease. It starts with a function we'll call `createApi` outside of our main `App()` function on line 32. 6 | 7 | ``` 8 | const createApi = (host: string, code: string) => 9 | _.memoize( 10 | (): UrbitInterface => { 11 | const urb = new Urbit(host, code); 12 | urb.ship = "zod"; 13 | urb.onError = (message) => console.log(message); 14 | urb.connect(); 15 | return urb; 16 | } 17 | ); 18 | ``` 19 | 20 | While this tutorial does its best to be self-contained, notice here that we are using the `memoize` method courtesy of `lodash` which we import at the top of `App.tsx`. This essentially caches the result of the function to reduce the amount of expensive computation our app has to do. 21 | 22 | Next thing to notice is that we're using two imports from `@urbit/http-api`. `UrbitInterface` is is a TypeScript interface that lives in `@urbit/http-api/dist/types.d.s` Read it's source to see the methods and variables it contains. We are passing it a variable called `urb` that will hold an `Urbit` object (which lives in `@urbit/http-api/dist/Urbit.d.ts`). 23 | 24 | The `Urbit` object itself accepts a `host` url and a ship `code`. The `host` is the port on `localhost` that our Urbit uses to interact with the web, and the `code` is the authentication code that we can use to open this connection. Once we pass in the `urb` object we can now call methods on it such as `.connect()` which establishes our connection to our ship. 25 | 26 | Refer to the rest of `UrbitInterface` to see other calls we will make in this tutorial, especially `poke` `thread` `scry` and `subscribe`, these are the four ways we will interact with our ship. 27 | 28 | Take note that after we connect to our `urb` we then have the `createApi()` function return the `urb` object itself. 29 | 30 | ## Determining Whether a User Has Already Logged in 31 | 32 | Refer to the breakout lesson on React Hooks for a more detailed explanation of `useState` and `useEffect`, for the purposes of logging in we'll just say that these two hooks allow us to check to see whether or not a user has previously logged in, and if so, to keep track of their login status with a state variable that is accessible to our entire app. 33 | 34 | The first state variable we define is `loggedIn` on line 46: 35 | 36 | `const [loggedIn, setLoggedIn] = useState();` 37 | 38 | We defined the variable name as `loggedIn` and `useState` then gives us a function for free to update it. Here we're calling it `setLoggedIn`. Notice how these two names are destructured from the `useState` hook which we are defining as a `boolean`. 39 | 40 | Next we define the second state vaiable `urb` on line 47: 41 | 42 | `const [urb, setUrb] = useState(); // Stores our Urbit connection. Notice we declare the type as UrbitInterface` 43 | 44 | Same as `loggedIn`, `urb` is a variable that will store our `urb` object and we get a function `setUrb` to add it to our state so that anywhere in our app we can call functions directly on our `urb`. 45 | 46 | Then on line 54 we have a function to `setloggedIn` and tell our app whether or not our user has already logged in: 47 | 48 | ``` 49 | useEffect(() => { 50 | if (localStorage.getItem("host") && localStorage.getItem("code")) { 51 | setLoggedIn(true); 52 | const _urb = createApi( 53 | localStorage.getItem("host")!, 54 | localStorage.getItem("code")! 55 | ); 56 | setUrb(_urb); 57 | return () => {}; 58 | } 59 | }, []); 60 | ``` 61 | 62 | The short explanation of useEffect is that it replaces the traditional React lifecycle and allows us to specify actions to perform after the intial render is complete. Here we're checking to see if `host` and `code` exist in `localStorage`. If so then we use our `setLoggedIn` function described above to set the `loggedIn` state variable to `true`. Now we can run our `createApi()` function from earlier, passing it the variables from `localStorage`. 63 | 64 | Remember that `createApi()` returns an `urb` object. Here we call it from within the variable `_urb` which will become our connected `urb` once `createApi()` finishes. We then run `setUrb(_urb)` (our `useState` function that stores `urb`) and thus our entire app now has access to our ship object. In the future we'll use this object a lot to call functions such as `urb.poke()` `urb.thread()` etc. 65 | 66 | Notice the empty array `[]` at the end of the function. `useEffect` allows us to give it variables or functions to monitor, and if they change the effect will run again with the new data. Since we only want this effect to run after the initial render we just pass an empty array `[]` so it only runs once. 67 | 68 | ## Login Flow 69 | 70 | The previous section covered situations in which a user has already used our app to login. If they have not, then the `useEffect()` function will skip over the `connect` function. Now let's look at how a new user can log in. The next function we see on line 68 is 71 | 72 | ``` 73 | const login = (host: string, code: string) => { 74 | localStorage.setItem("host", host); 75 | localStorage.setItem("code", code); 76 | const _urb = createApi(host, code); 77 | setUrb(_urb); 78 | setLoggedIn(true); 79 | return () => {}; 80 | }; 81 | ``` 82 | 83 | In a moment we'll see the UI that calls this function. For now note that we take a `host` and `code` string (rember that an `Urbit` object uses these two parameters to log in) and add them to `localStorage`. Then using the same format from the last function we looked at, we call our `createApi()` function, passing it the `host` and `code` credentials and then storing the resulting object in our state as `urb`. 84 | 85 | This time as well we set the `loggedIn` state variable to `true`. You should now be familiar with the pattern of using the functions that `useState` gives us to modify state variables. With this, we are connected to our ship, our whole app now has access to our ship to call functions, and our whole app knows that we are logged in. This will come in handy when we look at how we collect the login credentials from our user below. 86 | 87 | ## UI 88 | 89 | This section starts on line 413. The `
` tag is mostly boiler plate React TypeScript code for collecting and submitting `` data. We're just adding a `target` object that has a `host` and `code` key and handles the input from our text fields. We destructure each of those keys into its own variable, and then passing them into the `login()` function that we explained above. We will use some variation of this pattern for each of our input fields in this app. 90 | 91 | ``` 92 |
Login:
93 | { 95 | e.preventDefault(); 96 | const target = e.target as typeof e.target & { 97 | host: { value: string }; 98 | code: { value: string }; 99 | }; 100 | const host = target.host.value; 101 | const code = target.code.value; 102 | login(host, code); 103 | }} 104 | > 105 | ``` 106 | 107 | Now we will use our `loggedIn` state variable with a ternary operator to determine whether we should render UI for a user who has already logged in, or UI asking the user to login. We see this in the `placeholder` prop. 108 | 109 | `loggedIn ? localStorage.getItem("host")! : "Host"` 110 | 111 | Simply writing `loggedIn` actually means "if `loggedIn` is true." (You would write `!loggedIn` if you wanted "if `loggedIn` is false). The `?` is the equivalent of `then` and here we're having it render `host` from `localStorage`. As in, if the user is logged in then use `host` from `localStorage` as the placeholder in our text field. The `!` at the end of this line is to tell TypeScript that we know `host` will be a string. 112 | 113 | The `:` here serves as an `else` statement. If we don't already know the user's host then this placeholder serves as a prompt for them to enter it. 114 | 115 | Here is the rest of the code snippet. Notice we're doing the same thing for `code`. Again this is a very simple application of conditional rendering, you can use ternary operators to completely customize your UI for a user who is logged in versus one who isn't. 116 | 117 | ``` 118 | {/* We are using ternary operators to get if the use already has login info in localStorage. If so we render that info as a placeholder 119 | for each input form. Otherwise we render 'Host' or 'Code' as the placeholder*/} 120 | 127 |
128 | 135 |
136 | 137 |
138 | ``` 139 | -------------------------------------------------------------------------------- /sendingmessages.md: -------------------------------------------------------------------------------- 1 | # Sending Messages 2 | 3 | ## Setting up State Variables 4 | 5 | We're using two state variables here as we want to keep track of two things while we send messages. First we want to store a list of channel names (called `keys` in `graph-store`), and then we want to store a `log` of incoming messages so that we can render them in our app. 6 | 7 | ``` 8 | const [log, setLog] = useState(""); // State object for the log we keep of incoming messages for display 9 | const [keys, setKeys] = useState([]); // Same as groups but for channels(chats). I'm keeping the variable name 'keys' as that is the term used in graph-store 10 | 11 | ``` 12 | 13 | It's worth noting that we are storing the list of `keys` as an array of `Path`s. A `Path` is a string, for example `/ship/~zod/chat-name-777` and below we'll see how we can parse a `Path` to retrieve the information we need to send a message to the channel it represents. 14 | 15 | ## Logging Messages 16 | 17 | Let's skip down to line 136 to see the subscription we make to retrieve messages sent in our ship. It has a callback function that we will look at next: 18 | 19 | ``` 20 | // Now we use useEffect to establish our subscriptions to our ship. Notice that subscriptions are called directly on our Urbit object using 21 | // urb.subscribe. This first one parses chat messages to add to our log 22 | useEffect(() => { 23 | if (!urb || sub) return; 24 | urb 25 | .subscribe({ 26 | // Great boilerplate example for making subscriptions to graph-store 27 | app: "graph-store", 28 | path: "/updates", 29 | event: logHandler, // We'll explain this callback function next 30 | err: console.log, 31 | quit: console.log, 32 | }) 33 | .then((subscriptionId) => { 34 | setSub(subscriptionId); // Same as useCallback Hook we see the pattern of setting the state object from the returned data 35 | }); 36 | console.log(urb); 37 | }, [urb, sub, logHandler]); // And again here is what we're monitoring for changes 38 | ``` 39 | 40 | Generally this should look familiar to you as we used `subscribe` to generate a list of `groups` from `graph-store` in the last lesson. Same thing here but now we're subscribing on the `path` `/updates` and passing in a new function `logHandler` to parse the messages that come back. 41 | 42 | `logHandler` is defined on line 79: 43 | 44 | ``` 45 | // Callback function that we will pass into our graph-store subscription that logs incoming messages to chats courtesy of ~radur-sivmus! 46 | const logHandler = useCallback( 47 | (message) => { 48 | if (!("add-nodes" in message["graph-update"])) return; 49 | const newNodes: Record = 50 | message["graph-update"]["add-nodes"]["nodes"]; 51 | let newMessage = ""; 52 | Object.keys(newNodes).forEach((index) => { 53 | newNodes[index].post.contents.forEach((content: Content) => { 54 | if ("text" in content) { 55 | newMessage += content.text + " "; 56 | } else if ("url" in content) { 57 | newMessage += content.url + " "; 58 | } else if ("code" in content) { 59 | newMessage += content.code.expression; 60 | } else if ("mention" in content) { 61 | newMessage += "~" + content.mention + " "; 62 | } 63 | }); 64 | }); 65 | setLog(`${log}\n${newMessage}`); // This is the React Hooks pattern. The above code formats the message and then we use setLog to store it in state 66 | }, 67 | [log] // Again part of the React Hooks pattern. This keeps track of whether the log has changed to know when there is new data to process 68 | ); 69 | ``` 70 | 71 | Just like the last lesson we are using the `useCallback` Hook to pass in our handling function. Notice that we declare the variable `newNodes` with the type `Record` consisting of a `string` and the type `GraphNode` which is defined in `@urbit/api/dist/graph/types.d.ts`. We use `[""]` notation to select the `nodes` key in the `message` object. 72 | 73 | We then create an array from the `keys` in `newNodes` by using `Object.keys` and pass an `index` variable into the `.forEach()` method. That allows us to to get the `contents` of the message from each entry in `newNodes`. Notice here that we type our `content` variable with the `Content` type which lives in the same file as `GraphNode`. 74 | 75 | We then use a series of `if` statements to determine what type of data is in our message as Landscape allows `mentions` `code` snippets, `url`s, and of course plain `text`. We'll dive into each of these more in future lessons. 76 | 77 | And then finally, set use our `setLog` function to store the `newMessage` data in our `useState` variable. Now whenever a message is sent to a channel in our ship we will see it displayed in our app. 78 | 79 | ## Sending Messages 80 | 81 | We'll need to make another subscription to `graph-store` in order to send messages. This time on `path` `/keys`, we do this on line 171: 82 | 83 | ``` 84 | // Another graph-store subscription pattern this time to pull the list of channels(chats) that our ship belongs to. Again I'm leaving the varialbe 85 | // names that refer to chats as 'keys' to match the terminology of graph-store 86 | useEffect(() => { 87 | if (!urb || sub) return; 88 | urb 89 | .subscribe({ 90 | app: "graph-store", 91 | path: "/keys", 92 | event: handleKeys, 93 | err: console.log, 94 | quit: console.log, 95 | }) 96 | .then((subscriptionId) => { 97 | setSub(subscriptionId); 98 | }); 99 | }, [urb, sub, handleKeys]); 100 | ``` 101 | 102 | Pretty much the same as the first one, but now our callback function is `handleKeys`. Let's look at it on line 104: 103 | 104 | ``` 105 | // Callback function that we pass into the graph-store subscription to grab all of our ships keys i.e. chat names 106 | const handleKeys = useCallback( 107 | (keys) => { 108 | let keyArray: Path[] = []; 109 | keys["graph-update"]["keys"].forEach((key: Resource) => { 110 | keyArray.push(resourceAsPath(key)); 111 | }); 112 | setKeys(keyArray); 113 | }, 114 | [keys] 115 | ); 116 | ``` 117 | 118 | It's much simpler than `logHandler` since we are just pushing `keys` into an array of `Path`s. Note that a `key` is typed as a `Resource` which is defined as an object consisting of a `name` and a `ship`. We're going to import and use the function `resrouceAsPath` so that we can store this as a single `Path` string. This is a UI decision I made to easily render a `key` as an item in a dropdown menu(we'll see this below). You may not need to do this depending on what you're building. 119 | 120 | Finally we use `setKeys` to store our array of `keys` in our state variable. 121 | 122 | ## Local Function 123 | 124 | Now let's look at how we format user input (collected in the UI described in the next section) to send a message from our `ship`: 125 | 126 | ``` 127 | // Our function to send messages to a channel(chat) by the user in our React UI 128 | function sendMessageLocal(message: string, key: Path) { 129 | if (!urb || !urb.ship) return; 130 | 131 | // Notice that this requires an extra formatting functions. First we use createPost to format the message from the browser 132 | const post = createPost(urb.ship, [{ text: message }]); 133 | // Then we wrap our newly formatted post in the addPost() function and pass that into urb.thread(). Notice we'll have to translate our 134 | // key name (Path) back to a Resource in order for us to grab the name for the addPost() function 135 | const keyResource = resourceFromPath(key); 136 | // We've now formatted our user message properly for graph-store to parse via urb.thread() 137 | urb.thread(addPost(`~${urb.ship}`, keyResource.name, post)); 138 | alert("Message sent"); 139 | } 140 | ``` 141 | 142 | We take a `message` and `key` as arguments, but then we need to do some extra formatting that we haven't seen yet. Messages in Urbit are typed as `Post`s, so we import the format function `createPost` and pass it our `ship` name as well as an object with our `message` assigned to a `text` key. 143 | 144 | Remember that we wanted our key typed as a `string` to include as an item in a drop down menu, so now we coerce it back into a `Resource` by importing and using the `resourceFromPath()` function. 145 | 146 | We then create a `thread` and need one more formatting function, this time `addPost`. If you look at the source of this function in `@urbit/api/dist/graph/lib.d.ts`, you can see that we will need to add a `~` to our ship name which we do here manually. Now that our `Path` is a `Resource` again we can derive our `channel` name by using `keyResource.name`, and finally we pass in our newly created `post` variable. 147 | 148 | ## UI 149 | 150 | Finally on line 542 we can see the UI we render in order to collect this data from our user: 151 | 152 | ``` 153 | {/* Here we do the same as the channel input but for messages. This looks the same 154 | as chat creation since all of the formatting is done in our local functions above. 155 | We just need to present the user with a list of channels(chats) to choose and then an input field 156 | for their message */} 157 |
{ 159 | e.preventDefault(); 160 | const target = e.target as typeof e.target & { 161 | message: { value: string }; 162 | chat: { value: Path }; 163 | }; 164 | const message = target.message.value; 165 | const chat = target.chat.value; 166 | sendMessageLocal(message, chat); 167 | }} 168 | > 169 | 175 |
176 | 177 |
178 | 179 |
180 | ``` 181 | 182 | We've seen this UI pattern in the previous lesson. It's worth noting that we can `map` over our `key` array and render each `key` as a string since we converted the `key` from a `Resource` to a `Path` in our callback function. So users select a chat from the drop down, enter their message and when they press "Send Message" the variables are sent to `sendMessageLocal()` where they are formatted to a `thread` and sent to the proper chat. Our `logHandler` function above then renders these new messages to our app's UI as it receives them via the first subscription we set up in this section. 183 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from "react"; 2 | import _ from "lodash"; 3 | import Urbit, { UrbitInterface } from "@urbit/http-api"; 4 | import "./App.css"; 5 | import { 6 | Content, 7 | GraphNode, 8 | createGroup, 9 | createPost, 10 | addPost, 11 | createManagedGraph, 12 | dateToDa, 13 | deleteGroup, 14 | resourceFromPath, 15 | deleteGraph, 16 | Resource, 17 | resourceAsPath, 18 | Path, 19 | Scry, 20 | remove, 21 | deSig, 22 | addMembers, 23 | Group, 24 | removeMembers, 25 | } from "@urbit/api"; 26 | import { invite } from "@urbit/api/dist/groups"; 27 | 28 | // This is how we establish a connection with out ship. We pass the port that our fake ship is running on along with 29 | // its code into the Urbit object we imported above. Notice that we then manually assign the name of our ship by declaring 'urb.ship' 30 | // This gets called when our user enters their host and code credentials via UI form below 31 | 32 | const createApi = (host: string, code: string) => 33 | _.memoize( 34 | (): UrbitInterface => { 35 | const urb = new Urbit(host, code); 36 | urb.ship = "zod"; 37 | // urb 38 | // .connect() 39 | // .then((response) => alert("success").catch((err) => console.log(err))); 40 | try { 41 | urb.connect(); 42 | } catch (err) { 43 | console.log(err); 44 | } 45 | urb.onError = (message) => console.log(message); 46 | return urb; 47 | } 48 | ); 49 | 50 | // Here we create out app's functional component. We begin by using useState to create state objects for all of the data we're calling out of our ship 51 | // Also notice that we create a state object for urb that gets set when we call the createApi function 52 | const App = () => { 53 | const [loggedIn, setLoggedIn] = useState(); // Keeps track of whether our user is logged in for use throughout our app 54 | const [urb, setUrb] = useState(); // Stores our Urbit connection. Notice we declare the type as UrbitInterface 55 | const [sub, setSub] = useState(); // Currently managing all subscriptions with one state object. This will most likely change with future api fixes 56 | const [log, setLog] = useState(""); // State object for the log we keep of incoming messages for display 57 | const [groups, setGroups] = useState([]); // State object to keep track of the list of groups our ship belongs to 58 | const [keys, setKeys] = useState([]); // Same as above but for channels(chats). I'm keeping the variable name 'keys' as that is the term used in graph-store 59 | 60 | // We use useEffect to check if the user already has log in credentials stored in localStorage from a previous session. If so then we set our loggedIn 61 | // state variable to true and then run our createApi() function with the credentials from localStorage 62 | useEffect(() => { 63 | if (localStorage.getItem("host") && localStorage.getItem("code")) { 64 | setLoggedIn(true); 65 | const _urb = createApi( 66 | localStorage.getItem("host")!, 67 | localStorage.getItem("code")! 68 | ); 69 | setUrb(_urb); 70 | return () => {}; 71 | } 72 | }, []); 73 | 74 | // This is the function that stores the credentials our user enters into localStorage and then uses them to call the createApi function we defined above to 75 | // establish connection to our ship 76 | const login = (host: string, code: string) => { 77 | localStorage.setItem("host", host); 78 | localStorage.setItem("code", code); 79 | const _urb = createApi(host, code); 80 | setUrb(_urb); 81 | setLoggedIn(true); 82 | return () => {}; 83 | }; 84 | 85 | // Callback function that we will pass into our graph-store subscription that logs incoming messages to chats courtesy of ~radur-sivmus! 86 | const logHandler = useCallback( 87 | (message) => { 88 | if (!("add-nodes" in message["graph-update"])) return; 89 | const newNodes: Record = 90 | message["graph-update"]["add-nodes"]["nodes"]; 91 | let newMessage = ""; 92 | Object.keys(newNodes).forEach((index) => { 93 | console.log(newNodes[index]); 94 | newNodes[index].post.contents.forEach((content: Content) => { 95 | if ("text" in content) { 96 | newMessage += content.text + " "; 97 | } else if ("url" in content) { 98 | newMessage += content.url + " "; 99 | } else if ("code" in content) { 100 | newMessage += content.code.expression; 101 | } else if ("mention" in content) { 102 | newMessage += "~" + content.mention + " "; 103 | } 104 | }); 105 | }); 106 | setLog(`${log}\n${newMessage}`); // This is the React Hooks pattern. The above code formats the message and then we use setLog to store it in state 107 | }, 108 | [log] // Again part of the React Hooks pattern. This keeps track of whether the log has changed to know when there is new data to process 109 | ); 110 | 111 | // Callback function that we pass into the graph-store subscription to grab all of our ships keys i.e. chat names 112 | const handleKeys = useCallback( 113 | (keys) => { 114 | let keyArray: Path[] = []; 115 | keys["graph-update"]["keys"].forEach((key: Resource) => { 116 | keyArray.push(resourceAsPath(key)); 117 | }); 118 | setKeys(keyArray); 119 | }, 120 | [keys] 121 | ); 122 | 123 | // Callback function that we pass into the group-store (not graph-store!) subscription to grab all of the groups that our ship is a member of 124 | // GroupWName is a custom TypeScript interface I created so we can pass the name of a group along with it's contents. This may get integrated into the API in the future 125 | // For now it serves as a demo should you want to create your own for specific uses 126 | interface GroupWName { 127 | name: string; 128 | group: Group; 129 | } 130 | 131 | const handleGroups = useCallback( 132 | (groups) => { 133 | const groupsArray: GroupWName[] = []; 134 | Object.keys(groups.groupUpdate.initial).forEach((key) => { 135 | groupsArray.push({ name: key, group: groups.groupUpdate.initial[key] }); 136 | }); 137 | setGroups(groupsArray); 138 | }, 139 | [groups] 140 | ); 141 | 142 | // Now we use useEffect to establish our subscriptions to our ship. Notice that subscriptions are called directly on our Urbit object using 143 | // urb.subscribe. This first one parses chat messages to add to our log 144 | useEffect(() => { 145 | if (!urb || sub) return; 146 | urb 147 | .subscribe({ 148 | // Great boilerplate example for making subscriptions to graph-store 149 | app: "graph-store", 150 | path: "/updates", 151 | event: logHandler, // Refer to our logHandler function above to see how messages are parsed for display 152 | err: console.log, 153 | quit: console.log, 154 | }) 155 | .then((subscriptionId) => { 156 | setSub(subscriptionId); // Same as useCallback Hook we see the pattern of setting the state object from the returned data 157 | }); 158 | console.log(urb); 159 | }, [urb, sub, logHandler]); // And again here is what we're monitoring for changes 160 | 161 | // Almost the same as above but this time we're subscribing to group-store in order to get the names of the groups that our ship belongs to 162 | useEffect(() => { 163 | if (!urb || sub) return; 164 | urb 165 | .subscribe({ 166 | app: "group-store", 167 | path: "/groups", 168 | event: handleGroups, 169 | err: console.log, 170 | quit: console.log, 171 | }) 172 | .then((subscriptionId) => { 173 | setSub(subscriptionId); 174 | }); 175 | }, [urb, sub, handleGroups]); 176 | 177 | // Another graph-store subscription pattern this time to pull the list of channels(chats) that our ship belongs to. Again I'm leaving the varialbe 178 | // names that refer to chats as 'keys' to match the terminology of graph-store 179 | useEffect(() => { 180 | if (!urb || sub) return; 181 | urb 182 | .subscribe({ 183 | app: "graph-store", 184 | path: "/keys", 185 | event: handleKeys, 186 | err: console.log, 187 | quit: console.log, 188 | }) 189 | .then((subscriptionId) => { 190 | setSub(subscriptionId); 191 | }); 192 | }, [urb, sub, handleKeys]); 193 | 194 | // This is a simple kebab formatting function that will most likely be built into @urbit/api in future versions 195 | function formatGroupName(name: string) { 196 | return name 197 | .replace(/([a-z])([A-Z])/g, "$1-$2") 198 | .replace(/\s+/g, "-") 199 | .toLowerCase(); 200 | } 201 | 202 | // Now we start defining graph-store actions that are called directly on our Urbit object using urb.thread(). Threads are the main way that we send 203 | // commands to graph-store. This example uses them to create groups/chats and send messages. This first function is used to create a group 204 | function createGroupLocal(groupName: string, description: string) { 205 | if (!urb) return; 206 | urb.thread( 207 | // Notice that unlike subscriptions above, we pass a formatting function into our thread function. In this case it is createGroup 208 | // I'm using default values for the 'open' object but you can create a UI to allow users to input custom values. 209 | createGroup( 210 | // The name variable stays under the hood and we use our helper format function to create it from the groupName 211 | formatGroupName(groupName), 212 | { 213 | open: { 214 | banRanks: [], 215 | banned: [], 216 | }, 217 | }, 218 | groupName, 219 | description 220 | ) 221 | ); 222 | window.confirm(`Created group ${groupName}`); 223 | window.location.reload(); 224 | } 225 | 226 | // Another helper function from landscape that will eventually be built into @urbit/api used here to format chat names 227 | function stringToSymbol(str: string) { 228 | const ascii = str; 229 | let result = ""; 230 | for (let i = 0; i < ascii.length; i++) { 231 | const n = ascii.charCodeAt(i); 232 | if ((n >= 97 && n <= 122) || (n >= 48 && n <= 57)) { 233 | result += ascii[i]; 234 | } else if (n >= 65 && n <= 90) { 235 | result += String.fromCharCode(n + 32); 236 | } else { 237 | result += "-"; 238 | } 239 | } 240 | result = result.replace(/^[\-\d]+|\-+/g, "-"); 241 | result = result.replace(/^\-+|\-+$/g, ""); 242 | if (result === "") { 243 | return dateToDa(new Date()); 244 | } 245 | return result; 246 | } 247 | 248 | // Similar to createGroupLocal above, we use urb.thread() to create a channel via graph-store. 249 | function createChannelLocal( 250 | group: string, 251 | chat: string, 252 | description: string 253 | ) { 254 | if (!urb || !urb.ship) return; 255 | // Similar to stringToSymbol this is also a bit of formatting that will likely become a part of @urbit/api in the future. It is used to append 256 | // the random numbers the end of a channel names 257 | const resId: string = 258 | stringToSymbol(chat) + `-${Math.floor(Math.random() * 10000)}`; 259 | urb.thread( 260 | // Notice again we pass a formatting function into urb.thread this time it is createManagedGraph 261 | createManagedGraph(urb.ship, resId, chat, description, group, "chat") 262 | ); 263 | window.confirm(`Created chat ${chat}`); 264 | window.location.reload(); 265 | } 266 | 267 | // Our function to send messages to a channel(chat) by the user in our React UI 268 | function sendMessageLocal(message: string, key: Path) { 269 | if (!urb || !urb.ship) return; 270 | 271 | // Notice that this requires an extra formatting functions. First we use createPost to format the message from the browser 272 | const post = createPost(urb.ship, [{ text: message }]); 273 | // Then we wrap our newly formatted post in the addPost() function and pass that into urb.thread(). Notice we'll have to translate our 274 | // key name (Path) back to a Resource in order for us to grab the name for the addPost() function 275 | const keyResource = resourceFromPath(key); 276 | // We've now formatted our user message properly for graph-store to parse via urb.thread() 277 | urb.thread(addPost(`~${urb.ship}`, keyResource.name, post)); 278 | alert("Message sent"); 279 | } 280 | 281 | // This is our local function to add members to a group 282 | function addMembersLocal(group: Path, ship: string) { 283 | if (!urb) return; 284 | 285 | // Since addMembers() accepts multiple ships, we'll have to create an array out of our ship even though we are only sending in one at a time in our example 286 | const shipArray: string[] = []; 287 | shipArray.push(ship); 288 | // We also need to coerce our group Path into a Resource to accommodate addMembers() data types 289 | const groupResource = resourceFromPath(group); 290 | urb.poke(addMembers(groupResource, shipArray)); 291 | 292 | window.confirm(`Added ${ship} to ${group}`); 293 | window.location.reload(); 294 | } 295 | 296 | // Our local function to remove members from a group. Requires the same formatting steps as addMembersLocal() 297 | function removeMembersLocal(group: Path, ship: string) { 298 | if (!urb) return; 299 | 300 | const shipArray: string[] = []; 301 | shipArray.push(`~${ship}`); 302 | const groupResource = resourceFromPath(group); 303 | urb.poke(removeMembers(groupResource, shipArray)); 304 | 305 | window.confirm(`Removeed ${ship} from ${group}`); 306 | window.location.reload(); 307 | } 308 | 309 | // Local function to remove a group from our ship 310 | function removeGroupLocal(group: string) { 311 | if (!urb) return; 312 | const groupResource = resourceFromPath(group); 313 | // Here we're passing a thread the deleteGroup function from the groups library and destructuring the ship and name from 314 | // the group resource we created above 315 | urb.thread(deleteGroup(groupResource.ship, groupResource.name)); 316 | window.confirm(`Removed group ${group}`); 317 | window.location.reload(); 318 | } 319 | 320 | // Local function to remove a channel(chat) from a group on our ship 321 | function removeChannelLocal(channel: Path) { 322 | if (!urb) return; 323 | const channelResource = resourceFromPath(channel); 324 | // Similar to removeGroupLocal we're converting the Path string to a Resource type 325 | // Notice below that we use the deSig function. Different API functions have different formatting processes 326 | // deSig will remove the ~ from the ship name because deleteGraph is instructed to add one. Without deSig we would 327 | // end up with "~~zod" 328 | urb.thread(deleteGraph(deSig(channelResource.ship), channelResource.name)); 329 | window.confirm(`Removed channel ${channel}`); 330 | window.location.reload(); 331 | } 332 | 333 | // We're using a functional component here to render the UI because removing members from groups requires a little extra logic 334 | // We want the user to select between groups to render a list of each group's members. We need the extra steps since the member list is derived from the group Paths 335 | // which a user can toggle between. Therefore lists will have to be rendered according to user input 336 | const RenderRemoveMembers = () => { 337 | // Making this a functional component gives us access to its own useState hook for free. We'll use this to populate lists of members from user input 338 | const [selectedGroup, setSelectedGroup] = useState("default"); 339 | return ( 340 |
{ 342 | e.preventDefault(); 343 | const target = e.target as typeof e.target & { 344 | group: { value: string }; 345 | member: { value: string }; 346 | }; 347 | const group = groups[parseInt(selectedGroup)].name; 348 | const member = target.member.value; 349 | removeMembersLocal(group, member); 350 | }} 351 | > 352 | 365 |
366 | {/* This is the extra step needed to create a member list based on which group our user selects*/} 367 | 375 |
376 | 377 |
378 | ); 379 | }; 380 | 381 | // Local function to populate the @urbit/api invite() function which we send to our ship via thread 382 | function inviteLocal(group: string, ship: string, description: string) { 383 | if (!urb) return; 384 | 385 | const groupResource = resourceFromPath(group); 386 | const shipArray: string[] = []; 387 | shipArray.push(ship); 388 | urb.thread( 389 | invite(groupResource.ship, groupResource.name, shipArray, description) 390 | ); 391 | 392 | window.confirm(`Invited ${ship} to ${group}`); 393 | window.location.reload(); 394 | } 395 | 396 | function scryLocal(key: Path, count: string) { 397 | if (!urb) return; 398 | 399 | const keyResource = resourceFromPath(key); 400 | const scry: Scry = { 401 | app: "graph-store", 402 | path: `/newest/${keyResource.ship}/${keyResource.name}/${count}`, 403 | }; 404 | 405 | urb.scry(scry).then((messages) => { 406 | const nodes = messages["graph-update"]["add-nodes"]["nodes"]; 407 | Object.keys(nodes).forEach((index) => { 408 | console.log( 409 | `${nodes[index].post.author}:`, 410 | nodes[index].post.contents[0].text 411 | ); 412 | }); 413 | }); 414 | } 415 | 416 | return ( 417 |
418 |
419 | 420 | 421 | 428 | 465 | 466 |
422 |
423 |                 {/* Very simple UI to render our log from the state variable*/}
424 |                 Latest Message:
425 |                 
{log} 426 |
427 |
429 | {/* This is the template we'll use for forms that allow users to send data to our ship. We're using minimal code to keep track of the two 430 | text inputs for host address and code and then send them to our login() function*/} 431 |
Login:
432 |
{ 434 | e.preventDefault(); 435 | const target = e.target as typeof e.target & { 436 | host: { value: string }; 437 | code: { value: string }; 438 | }; 439 | const host = target.host.value; 440 | const code = target.code.value; 441 | login(host, code); 442 | }} 443 | > 444 | {/* We are using ternary operators to get if the use already has login info in localStorage. If so we render that info as a placeholder 445 | for each input form. Otherwise we use 'Host' or 'Code' as the placeholder*/} 446 | 453 |
454 | 461 |
462 | 463 |
464 |
467 | 468 | 469 | 474 | 479 | 484 | 485 | 486 | 518 | 553 | 582 | 583 | 584 | 589 | 594 | 599 | 600 | 601 | 631 | 635 | 669 | 670 | 671 | 676 | 681 | 686 | 687 | 688 | 709 | 730 | 755 | 756 |
470 |
471 |
Create Group
472 |
473 |
475 |
476 |
Create Channel
477 |
478 |
480 |
481 |
Send Message
482 |
483 |
487 | {/* Here's an example of how to collect data from a user to pass into our createGroupLocal function*/} 488 |
{ 490 | e.preventDefault(); 491 | const target = e.target as typeof e.target & { 492 | groupName: { value: string }; 493 | description: { value: string }; 494 | }; 495 | { 496 | /* We're just creating variables from the input fields defined below, createGroupLocal handles the formatting*/ 497 | } 498 | const groupName = target.groupName.value; 499 | const description = target.description.value; 500 | createGroupLocal(groupName, description); 501 | }} 502 | > 503 | 508 |
509 | 514 |
515 | 516 |
517 |
519 | {/* Same as group input for channel(chat) input. Only difference is we present the user's groups as dropdown options*/} 520 |
{ 522 | e.preventDefault(); 523 | const target = e.target as typeof e.target & { 524 | group: { value: string }; 525 | chat: { value: string }; 526 | description: { value: string }; 527 | }; 528 | const group = target.group.value; 529 | const chat = target.chat.value; 530 | const description = target.description.value; 531 | createChannelLocal(group, chat, description); 532 | }} 533 | > 534 | {/* Here we leverage our groups state variable to render a dropdown list of available groups to create channels(chats) in */} 535 | 541 |
542 | 543 |
544 | 549 |
550 | 551 |
552 |
554 | {/* Here we do the same as the channel input but for messages. This looks the same 555 | as chat creation since all of the formatting is done in our local functions above. 556 | We just need to present the user with a list of channels(chats) to choose and then an input field 557 | for their message */} 558 |
{ 560 | e.preventDefault(); 561 | const target = e.target as typeof e.target & { 562 | message: { value: string }; 563 | chat: { value: Path }; 564 | }; 565 | const message = target.message.value; 566 | const chat = target.chat.value; 567 | sendMessageLocal(message, chat); 568 | }} 569 | > 570 | 576 |
577 | 578 |
579 | 580 |
581 |
585 |
586 |
Add Members
587 |
588 |
590 |
591 |
Remove Members
592 |
593 |
595 |
596 |
Invite Members
597 |
598 |
602 |
{ 604 | e.preventDefault(); 605 | const target = e.target as typeof e.target & { 606 | group: { value: string }; 607 | member: { value: string }; 608 | }; 609 | const group = target.group.value; 610 | const member = target.member.value; 611 | addMembersLocal(group, member); 612 | }} 613 | > 614 | {/* Here we leverage our groups state variable to render a dropdown list of available groups that the user can add members to */} 615 | 621 |
622 | 627 |
628 | 629 |
630 |
632 | {/* Here we render our functional component to allow users to remove members from groups*/} 633 | 634 | 636 | {/* Same pattern as the simple functions above to format user input, this time for the invite() function*/} 637 |
{ 639 | e.preventDefault(); 640 | const target = e.target as typeof e.target & { 641 | group: { value: string }; 642 | ship: { value: string }; 643 | description: { value: string }; 644 | }; 645 | const group = target.group.value; 646 | const ship = target.ship.value; 647 | const description = target.description.value; 648 | inviteLocal(group, ship, description); 649 | }} 650 | > 651 | 657 |
658 | 659 |
660 | 665 |
666 | 667 |
668 |
672 |
673 |
Remove Channel
674 |
675 |
677 |
678 |
Remove Group
679 |
680 |
682 |
683 |
Scrying Messages
684 |
685 |
689 |
{ 691 | e.preventDefault(); 692 | const target = e.target as typeof e.target & { 693 | chat: { value: Path }; 694 | }; 695 | const chat = target.chat.value; 696 | removeChannelLocal(chat); 697 | }} 698 | > 699 | 705 |
706 | 707 |
708 |
710 |
{ 712 | e.preventDefault(); 713 | const target = e.target as typeof e.target & { 714 | group: { value: string }; 715 | }; 716 | const group = target.group.value; 717 | removeGroupLocal(group); 718 | }} 719 | > 720 | 726 |
727 | 728 |
729 |
731 |
{ 733 | e.preventDefault(); 734 | const target = e.target as typeof e.target & { 735 | chat: { value: Path }; 736 | count: { value: string }; 737 | }; 738 | const chat = target.chat.value; 739 | const count = target.count.value; 740 | scryLocal(chat, count); 741 | }} 742 | > 743 | 749 |
750 | 751 |
752 | 753 |
754 |
757 |
758 |
759 | ); 760 | }; 761 | 762 | export default App; 763 | --------------------------------------------------------------------------------