├── 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 |
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 | You need to enable JavaScript to run this app.
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 |
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 |
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 |
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 |
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 |
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 |
85 | );
86 | };
87 | ```
88 |
89 | Two things are different from the UI we've seen so far. First notice that in the first `` tag, which the users use to select a `group`, the `value` for each `option` is the `index` passed into the `map` function. We see that in the `onChange` prop of this `` 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 `` tag. Here we are rendering a second dropdown menu. Notice that we give it a "Select a Member" placeholder option and then use a ternary operation. We use `groups[0]` to confirm that the `groups` array is loaded before we continue. Also that `selectedGroup !== "default"`. Notice that in our state variable we set the default value of the `group` variable to "default" in `useState()`. This let's us check to make sure that the user has made a selection from the `groups` dropdown thus giving us an `index` that we can use to fetch the `members` out of.
92 |
93 | This is why we're storing `index` in state, now we can render the rest of our `` by mapping over the `member` array which is stored in `groups[index]`. This is what `groups[parseInt(selectedGroup)].group.members.map((member)` gives us.
94 |
95 | Last thing to note here is that on line 626 where we want to render this form we just have to write ` ` and React takes care of the rest.
96 |
--------------------------------------------------------------------------------
/creatingchannels.md:
--------------------------------------------------------------------------------
1 | # Creating Channels
2 |
3 | Interestingly enough, before we start talking about creating channels we actually have to cover subscribing and storing `group`s. This is because a channel has to live inside of a `group` and in order for our users to specify which `group` they want their channels to live in, they need to know what `group`s exist in our ship. To accomplish this we need to introduce the `subscribe` method. We'll cover that before seeing the Local Function / UI pattern established in the previous example.
4 |
5 | A brief note that under the hood the `subscribe` method leverages `eyre` to handle the json parsing. `Eyre` receives the data you're subscribed to, if that piece of data has a `mark` to transform to json, then `eyre` will handle that.
6 |
7 | ## Setting up State Variables
8 |
9 | On line 49 we create a state variable that will store an array of Group Names:
10 |
11 | `const [groups, setGroups] = useState([]);`
12 |
13 | Notice that we don't import `GroupWName` this is a custom type interface I made that will allow us to easily access the name of a group and alongside its details.
14 |
15 | This is defined on line 119. It consists of a `name` `string` and a `group` `Group` which you can see in our imports from `@urbit/api"` at the top of `App.tsx` There are other ways you could store this data but I wanted to include this as a simple example of rolling your own interfaces should you need to.
16 |
17 | ```
18 | interface GroupWName {
19 | name: string;
20 | group: Group;
21 | }
22 | ```
23 |
24 | ## Setting up Subscriptions
25 |
26 | Skipping down to line 156 we see:
27 |
28 | ```
29 | useEffect(() => {
30 | if (!urb || sub) return;
31 | urb
32 | .subscribe({
33 | app: "group-store",
34 | path: "/groups",
35 | event: handleGroups,
36 | err: console.log,
37 | quit: console.log,
38 | })
39 | .then((subscriptionId) => {
40 | setSub(subscriptionId);
41 | });
42 | }, [urb, sub, handleGroups]);
43 | ```
44 |
45 | See the breakout lesson on Hooks for more details on `useEffect` (breakout lessons forthcoming), but for now you just need to know that `useEffect` will run actions after the initial render is complete. The initial render will create our state instance of `urb` described in the [Logging In](https://github.com/witfyl-ravped/urbit-react-cookbook/blob/main/logginging.md) lesson and now we can leverage `useEffect` to setup a subscription to it.
46 |
47 | We confirm it was setup properly by running the `if(!urb)` check, then proceed to call `.subscribe()` directly on our `urb`. By digging into the types library in `@urbit/http` we see that `subscribe` takes a `SubscriptionRequestInterface` (defined in `@urbit/http-api/dist/types.d.ts` as an `app` and a `path` along with the ability to log `err` and `quit` return messages as well as take a callback function via `event`).
48 |
49 | The `subscribe` parameters are pretty straightforward. We're creating a subscription to `group-store` on path `/groups`. Then we're passing in the function `handleGroups()` which we will look at in a moment, and `console.log`ing `err` and `quit` messages. Since `subscribe` returns data we can then use `.then()` to create a `Promise` for us to send our `sub` state variable the subscription id that we get back.
50 |
51 | Finally we pass in an array as a second argument that will re-subscribe if any of it's contents change.
52 |
53 | Now let's jump back up to line 125 where we define the `handleGroups()` callback function:
54 |
55 | ```
56 | const handleGroups = useCallback(
57 | (groups) => {
58 | const groupsArray: GroupWName[] = [];
59 | Object.keys(groups.groupUpdate.initial).forEach((key) => {
60 | groupsArray.push({ name: key, group: groups.groupUpdate.initial[key] });
61 | });
62 | setGroups(groupsArray);
63 | },
64 | [groups]
65 | );
66 | ```
67 |
68 | Here we use the `useCallback` hook, structured very similarly to `useEffect`. We start by assigning `groups`to the data we get back from `subscribe`. Then we make an array to accept our custom interface `GroupWName`. We use the custom interface because the `groups` object returned by `subscribe` uses the group name as the key for the rest of the group data. So we push a custom object into `groupsArray` that extracts the `group` name and pairs it with the rest of the `group` object info. We'll see later that we now have easy access to each `groups`'s `name` and corresponding data.
69 |
70 | We then set our state `groups` variable equal to our new array and then use the second argument of `useCallback` to re-render if `groups` changes.
71 |
72 | ## Local Function
73 |
74 | Now let's move down to line 242. Just like we did for `group`s in the last lesson, here will make a `createChannelLocal()` function to format the data we collect from our user and send it to our ship as via `urb.thread()`:
75 |
76 | ```
77 | // Similar to createGroupLocal in last lesson, we use urb.thread() to create a channel via graph-store.
78 | function createChannelLocal(
79 | group: string,
80 | chat: string,
81 | description: string
82 | ) {
83 | if (!urb || !urb.ship) return;
84 | // 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
85 | // the random numbers the end of a channel names
86 | const resId: string =
87 | stringToSymbol(chat) + `-${Math.floor(Math.random() * 10000)}`;
88 | urb.thread(
89 | // Notice again we pass a formatting function into urb.thread this time it is createManagedGraph
90 | createManagedGraph(urb.ship, resId, chat, description, group, "chat")
91 | );
92 | window.confirm(`Created chat ${chat}`);
93 | window.location.reload();
94 | }
95 | ```
96 |
97 | In this example we use the formatting function `createManagedGraph()` which lives in `@urbit/api/dist/graph/lib.d.ts`. You can read its source there and to see the name of each of the arguments we're passing in above. Also notice the `resId` which uses the `stringToSymbol()` function. That's a formatting function from Landscape that we won't go over here as it will likely become a part of `@urbit/api` in the future. Also notice that we append a random number to `resId`, you may have noticed that `channel` names include a random ID under the hood and this is where we provide one for our `channel`s.
98 |
99 | ## UI
100 |
101 | Finally on line 506 we render a form that will look very similar to the one we made to create groups in the last lesson.
102 |
103 | ```
104 |
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 `
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 |
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 |
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 |
422 |
423 | {/* Very simple UI to render our log from the state variable*/}
424 | Latest Message:
425 | {log}
426 |
427 |
428 |
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 |
464 |
465 |
466 |
467 |
468 |
469 |
470 |
473 |
474 |
475 |
476 |
Create Channel
477 |
478 |
479 |
480 |
483 |
484 |
485 |
486 |
487 | {/* Here's an example of how to collect data from a user to pass into our createGroupLocal function*/}
488 |
517 |
518 |
519 | {/* Same as group input for channel(chat) input. Only difference is we present the user's groups as dropdown options*/}
520 |
552 |
553 |
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 |
581 |
582 |
583 |
584 |
585 |
588 |
589 |
590 |
591 |
Remove Members
592 |
593 |
594 |
595 |
596 |
Invite Members
597 |
598 |
599 |
600 |
601 |
602 |
630 |
631 |
632 | {/* Here we render our functional component to allow users to remove members from groups*/}
633 |
634 |
635 |
636 | {/* Same pattern as the simple functions above to format user input, this time for the invite() function*/}
637 |
668 |
669 |
670 |
671 |
672 |
673 |
Remove Channel
674 |
675 |
676 |
677 |
680 |
681 |
682 |
683 |
Scrying Messages
684 |
685 |
686 |
687 |
688 |
689 |
708 |
709 |
710 |
729 |
730 |
731 |
754 |
755 |
756 |
757 |
758 |
759 | );
760 | };
761 |
762 | export default App;
763 |
--------------------------------------------------------------------------------