mirror of
https://github.com/urbit/developers.urbit.org.git
synced 2024-09-11 13:55:59 +03:00
Merge pull request #312 from sidnym-ladrut/i/310/modernize-react-examples
Modernize React Examples
This commit is contained in:
commit
5ea088cc0a
@ -10,175 +10,113 @@ React app front-end.
|
||||
|
||||
Node.js must be installed, and can be downloaded from their
|
||||
[website](https://nodejs.org/en/download). With that installed, we'll have the
|
||||
`npm` package manager available. The first thing we'll do is globally install
|
||||
the `create-react-app` package with the following command:
|
||||
`npm` package manager available and its utility binaries like `npx` to help
|
||||
set up our project. The first thing we'll do is create a project using the
|
||||
[`create-landscape-app`](https://www.npmjs.com/package/@urbit/create-landscape-app)
|
||||
template with the following command:
|
||||
|
||||
```sh
|
||||
npm install -g create-react-app
|
||||
```
|
||||
|
||||
Once installed, we can use it to create a new `journal-ui` directory and setup a
|
||||
new React app in it with the following command:
|
||||
|
||||
```sh
|
||||
create-react-app journal-ui
|
||||
npx @urbit/create-landscape-app
|
||||
✔ What should we call your application? … journal
|
||||
✔ What URL do you use to access Urbit? … http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
We can then open our new directory:
|
||||
|
||||
```sh
|
||||
cd journal-ui
|
||||
```sh {% copy=true %}
|
||||
cd journal/ui
|
||||
```
|
||||
|
||||
Its contents should look something like this:
|
||||
|
||||
```
|
||||
journal-ui
|
||||
├── node_modules
|
||||
ui
|
||||
├── index.html
|
||||
├── package.json
|
||||
├── package-lock.json
|
||||
├── public
|
||||
├── README.md
|
||||
├── postcss.config.js
|
||||
├── tailwind.config.js
|
||||
├── vite.config.js
|
||||
└── src
|
||||
```
|
||||
|
||||
## Install `http-api`
|
||||
## Install dependencies
|
||||
|
||||
Inside our React app directory, let's install the `@urbit/http-api` NPM package:
|
||||
Inside our React app directory, let's install the NPM packages used by
|
||||
our project:
|
||||
|
||||
```sh
|
||||
npm i @urbit/http-api
|
||||
```sh {% copy=true %}
|
||||
npm i
|
||||
```
|
||||
|
||||
We also install a handful of other packages for the UI components
|
||||
(`bootstrap@5.1.3 react-bootstrap@2.2.0 react-textarea-autosize@8.3.3
|
||||
date-fns@2.28.0 react-bottom-scroll-listener@5.0.0 react-day-picker@7.4.10`),
|
||||
but that's not important to our purposes here.
|
||||
This command will install the Urbit interface package (i.e. `@urbit/http-api`)
|
||||
and all the other packages used by our React application. When building from
|
||||
scratch with `create-landscape-app`, this includes a number of useful
|
||||
development libraries that enable automatic refresh on file edits (i.e. `vite`
|
||||
and `@vitejs/plugin-react-refresh`) and simple page styling (i.e.
|
||||
`tailwindcss`). The remainder of this tutorial will focus primarily on how the
|
||||
Urbit interface package is used to communicate with a live ship from within a
|
||||
React application.
|
||||
|
||||
## Additional tweaks
|
||||
## Basic app setup
|
||||
|
||||
Our front-end will be served directly from the ship by the `%docket` app, where
|
||||
a user will open it by clicking on its homescreen tile. Docket serves such
|
||||
front-ends with a base URL path of `/apps/[desk]/`, so in our case it will be
|
||||
`/apps/journal`. In order for our app to be built with correct resource paths,
|
||||
we must add the following line to `package.json`:
|
||||
With all the basics now in place, we can begin work on the app itself. For this
|
||||
simple demonstration, we'll be working just with the `src/app.jsx` file, which
|
||||
contains the rendering logic for our React application. Before we look at the
|
||||
full front-end source for our journal app, let's first review the simpler
|
||||
default code provided by `create-landscape-app` to cover some Urbit API and
|
||||
React basics.
|
||||
|
||||
```json
|
||||
"homepage": "/apps/journal/",
|
||||
### Urbit API setup
|
||||
|
||||
First, let's open up `src/app.jsx` and look at the import statements at the top
|
||||
of this file:
|
||||
|
||||
```javascript
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Urbit from '@urbit/http-api';
|
||||
import { scryCharges } from '@urbit/api';
|
||||
import { AppTile } from './components/AppTile';
|
||||
```
|
||||
|
||||
Our app also needs to know the name of the ship it's being served from in order
|
||||
to talk with it. The `%docket` agent serves a small file for this purpose at
|
||||
`[host]/session.js`. This file is very simple and just contains:
|
||||
The first two of these statements are very common in Urbit React applications;
|
||||
the first imports the React library and a few of its important functions (to be
|
||||
covered in a moment) and the second imports the `Urbit` class, which will be
|
||||
used subsequently to enable browser-to-ship communication.
|
||||
|
||||
```js
|
||||
window.ship = "sampel-palnet";
|
||||
Next, the code sets up the `Urbit` API object as a global variable, which
|
||||
allows the browser-to-ship connection to be established *exactly once* when the
|
||||
page is first being loaded:
|
||||
|
||||
```javascript
|
||||
const api = new Urbit('', '', window.desk);
|
||||
api.ship = window.ship;
|
||||
```
|
||||
|
||||
`sampel-palnet` will of course be replaced by the actual name of the ship. We
|
||||
include this script by adding the following line to the `<head>` section of
|
||||
`public/index.html`:
|
||||
|
||||
```
|
||||
<script src="/session.js"></script>
|
||||
```
|
||||
|
||||
## Basic API setup
|
||||
|
||||
With everything now setup, we can begin work on the app itself. In this case
|
||||
we'll just edit the existing `App.js` file in the `/src` directory. The first thing is to import the `Urbit` class from `@urbit/http-api`:
|
||||
|
||||
```js
|
||||
import Urbit from "@urbit/http-api";
|
||||
```
|
||||
|
||||
We also need to import a few other things, mostly relating to UI components (but
|
||||
these aren't important for our purposes here):
|
||||
|
||||
```js
|
||||
import React, { Component } from "react";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "react-day-picker/lib/style.css";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import Button from "react-bootstrap/Button";
|
||||
import Card from "react-bootstrap/Card";
|
||||
import Stack from "react-bootstrap/Stack";
|
||||
import Tab from "react-bootstrap/Tab";
|
||||
import Tabs from "react-bootstrap/Tabs";
|
||||
import ToastContainer from "react-bootstrap/ToastContainer";
|
||||
import Toast from "react-bootstrap/Toast";
|
||||
import Spinner from "react-bootstrap/Spinner";
|
||||
import CloseButton from "react-bootstrap/CloseButton";
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
import DayPickerInput from "react-day-picker/DayPickerInput";
|
||||
import endOfDay from "date-fns/endOfDay";
|
||||
import startOfDay from "date-fns/startOfDay";
|
||||
import { BottomScrollListener } from "react-bottom-scroll-listener";
|
||||
```
|
||||
|
||||
Inside the existing `App` class:
|
||||
|
||||
```js
|
||||
class App extends Component {
|
||||
```
|
||||
|
||||
...we'll clear out the existing demo code and start adding ours. The first thing
|
||||
is to define our app's state. We'll look at most of the state entries in the
|
||||
next section. For now, we'll just consider `status`.
|
||||
|
||||
```js
|
||||
state = {
|
||||
// .....
|
||||
status: null,
|
||||
// .....
|
||||
};
|
||||
```
|
||||
|
||||
Next, we'll setup the `Urbit` API object in `componentDidMount`. We could do
|
||||
this outside the `App` class since we're adding it to `window`, but we'll do it
|
||||
this way so it's all in one place:
|
||||
|
||||
```js
|
||||
componentDidMount() {
|
||||
window.urbit = new Urbit("");
|
||||
window.urbit.ship = window.ship;
|
||||
window.urbit.onOpen = () => this.setState({status: "con"});
|
||||
window.urbit.onRetry = () => this.setState({status: "try"});
|
||||
window.urbit.onError = (err) => this.setState({status: "err"});
|
||||
this.init();
|
||||
};
|
||||
```
|
||||
|
||||
The first thing we do is create a new instance of the `Urbit` class we imported
|
||||
from `@urbit/http-api`, and save it to `window.urbit`. The `Urbit` class
|
||||
constructor takes three arguments: `url`, `desk` and `code`, of which only `url`
|
||||
The first statement creates a new instance of the `Urbit` class we imported
|
||||
from `@urbit/http-api`, and saves it to the `api` variable. The `Urbit` class
|
||||
constructor takes three arguments: `url`, `code`, and `desk`, of which only `url`
|
||||
is mandatory.
|
||||
|
||||
- `url` is the URL of the ship we want to talk to. Since our React app will be
|
||||
served by the ship, we can just leave it as an empty `""` string and let
|
||||
served by the ship, we can just leave it as an empty `''` string and let
|
||||
`Urbit` use root-relative paths.
|
||||
- `desk` is only necessary if we want to run threads through Eyre, and since
|
||||
we're not going to do that, we can exclude it.
|
||||
- `code` is the web login code for authentication, but since the user will
|
||||
already have logged in, we can also exclude that.
|
||||
- `code` is the web login code for authentication. Since the user will already
|
||||
have logged in, we can also leave it as an empty `''` string.
|
||||
- `desk` is only necessary if we want to run threads through Eyre. This example
|
||||
doesn't submit any such requests, but the `desk` is set anyway for
|
||||
demonstration purposes.
|
||||
|
||||
Therefore, we call the class contructor with just the empty `url` string:
|
||||
The second statement sets the ship name in our `Urbit` instance. Eyre requires
|
||||
the ship name be specified in all requests; if we don't set it, Eyre will
|
||||
reject all the messages we send. Fortunately, `create-landscape-app` handles
|
||||
this detail by automatically initializing the active ship's name to the
|
||||
variable `window.ship`, so we just set `api.ship` to this value.
|
||||
|
||||
```js
|
||||
window.urbit = new Urbit("");
|
||||
```
|
||||
|
||||
Next, we need to set the ship name in our `Urbit` instance. Eyre requires the
|
||||
ship name be specified in all requests, so if we don't set it, Eyre will reject
|
||||
all the messages we send. We previously included `session.js` which sets
|
||||
`window.ship` to the ship name, so we just set `window.urbit.ship` as that:
|
||||
|
||||
```js
|
||||
window.urbit.ship = window.ship;
|
||||
```
|
||||
|
||||
Next, we set three callbacks: `onOpen`, `onRetry`, and `onError`. These
|
||||
callbacks are fired when the state of our channel connection changes:
|
||||
While not referenced in the `create-landscape-app` default code, the `Urbit`
|
||||
class has three additional callbacks that can be set: `onOpen`, `onRetry`, and
|
||||
`onError`. These callbacks are fired when the state of our channel connection
|
||||
changes:
|
||||
|
||||
- `onOpen` is called when a connection is established.
|
||||
- `onRetry` is called when a channel connection has been interrupted (such as by
|
||||
@ -188,22 +126,103 @@ callbacks are fired when the state of our channel connection changes:
|
||||
- `onError` is called with an `Error` message once all retries have failed, or
|
||||
otherwise when a fatal error occurs.
|
||||
|
||||
We'll look at how we handle these cases in the next section. For now, we'll just
|
||||
set the `status` entry in the state to either `"con"`, `"try"`, or `"err"` as
|
||||
the case may be. Note that it's not mandatory to set these callbacks, but
|
||||
leaving connection problems unhandled is usually a bad idea.
|
||||
We'll look at how we can use these callbacks in the next section. Note that
|
||||
it's not mandatory to set these callbacks, but leaving connection problems
|
||||
unhandled is usually a bad idea.
|
||||
|
||||
The last thing we do is call:
|
||||
### React app setup
|
||||
|
||||
```js
|
||||
this.init();
|
||||
Finally, let's take a quick look at the React rendering logic for our
|
||||
application. React rendering occurs within components, which are defined either
|
||||
as classes (e.g. `class A extends Component { /* ... */ }`) or functions (e.g.
|
||||
`function A() { /* ... */ }`). While recent React versions support both styles,
|
||||
the latter "modern" style is preferred and used by most Urbit React
|
||||
applications.
|
||||
|
||||
Our code defines a few components, but we'll just focus on the primary
|
||||
component for this tutorial; this component is defined as a functional
|
||||
component named `App`:
|
||||
|
||||
```javascript
|
||||
export function App() {
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
This function will fetch initial entries and subscribe for updates. We'll look
|
||||
at it in the next section.
|
||||
As is common for React components, the first thing we'll define in our `App`
|
||||
component is its state. In React, modifying a component's state causes it to be
|
||||
re-rendered, so state variables should be carefully chosen to constitute all
|
||||
"display-affecting" values. In modern React, component state is defined using
|
||||
the [`useState()`] hook, which returns a pair of `[stateVariable,
|
||||
setStateVariableFunction]`. Since our default `create-landscape-app` code just
|
||||
displays the list of apps installed on a ship, it only needs to store this list
|
||||
as its state:
|
||||
|
||||
```javascript
|
||||
const [apps, setApps] = useState();
|
||||
```
|
||||
|
||||
With the state established, we now define the code responsible for populating
|
||||
this state. The canonical way to grab data from an external service/system in
|
||||
React is to use the [`useEffect()`] hook. This function takes two arguments:
|
||||
(1) the callback function for loading the external data and (2) a list of all
|
||||
state variables dependencies, which will cause re-invocations of the first
|
||||
argument when modified. Our app just needs to load the list of apps on our ship
|
||||
(called `charges`) once, so its [`useEffect()`] invocation is simple:
|
||||
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
const charges = (await api.scry(scryCharges)).initial;
|
||||
setApps(charges);
|
||||
}
|
||||
|
||||
init();
|
||||
}, []);
|
||||
```
|
||||
|
||||
The last step is to return the HTML that will be used to render our component
|
||||
in the browser. This HTML must adhere to the syntactic rules of
|
||||
[JSX](https://en.wikipedia.org/wiki/JSX_(JavaScript)), which allow for greater
|
||||
flexibility through extensions like embedded JavaScript (contained in curly
|
||||
brace enclosures). Our component renders each app it found when scrying our
|
||||
ship as a tile accompanied by its title and description:
|
||||
|
||||
```javascript {% mode="collapse" %}
|
||||
return (
|
||||
<main className="flex items-center justify-center min-h-screen">
|
||||
<div className="max-w-md space-y-6 py-20">
|
||||
<h1 className="text-3xl font-bold">Welcome to hut</h1>
|
||||
<p>Here's your urbit's installed apps:</p>
|
||||
{apps && (
|
||||
<ul className="space-y-4">
|
||||
{Object.entries(apps).map(([desk, app]) => (
|
||||
<li key={desk} className="flex items-center space-x-3 text-sm leading-tight">
|
||||
<AppTile {...app} />
|
||||
<div className="flex-1 text-black">
|
||||
<p>
|
||||
<strong>{app.title || desk}</strong>
|
||||
</p>
|
||||
{app.info && <p>{app.info}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
```
|
||||
|
||||
With this brief primer complete, we'll take a closer look at our journal
|
||||
application's front-end and how it utilizes the Urbit HTTP API in the next
|
||||
section.
|
||||
|
||||
## Resources
|
||||
|
||||
- [React Tutorial](https://react.dev/learn/tutorial-tic-tac-toe) - A tutorial
|
||||
walking through the basics of writing a modern React application.
|
||||
|
||||
- [HTTP API Guide](/guides/additional/http-api-guide) - Reference documentation for
|
||||
`@urbit/http-api`.
|
||||
|
||||
@ -214,3 +233,7 @@ at it in the next section.
|
||||
- [`@urbit/http-api` source
|
||||
code](https://github.com/urbit/urbit/tree/master/pkg/npm/http-api) - The
|
||||
source code for the `@urbit/http-api` NPM package.
|
||||
|
||||
|
||||
[`usestate()`]: https://react.dev/reference/react/useState
|
||||
[`useeffect()`]: https://react.dev/reference/react/useEffect
|
||||
|
@ -3,58 +3,72 @@ title = "7. React app logic"
|
||||
weight = 8
|
||||
+++
|
||||
|
||||
With the basic things setup, we can now go over the logic of our app. We'll just
|
||||
focus on functions that are related to ship communications using the `Urbit`
|
||||
object we previously setup, and ignore UI components and other helper functions.
|
||||
Now that we've reviewed the basics of setting up an Urbit React app, we can
|
||||
dive into the more complex logic that drives our [journal app's
|
||||
front-end](https://github.com/urbit/docs-examples/tree/main/journal-app/ui).
|
||||
We'll focus on the app's main component `App` (defined in
|
||||
[`src/app.jsx`](https://github.com/urbit/docs-examples/tree/main/journal-app/ui/src/app.jsx))
|
||||
and how it leverages functions related to ship communications using the `Urbit`
|
||||
object. For more information on UI components and other helper functions, see
|
||||
the [resources section](#resources).
|
||||
|
||||
## State
|
||||
|
||||
In the previous section we just mentioned the connection `status` field of our
|
||||
state. Here's the full state of our App:
|
||||
In the previous section, we introduced how React components use [`useState()`]
|
||||
to declare state variables within components. The main `App` component in our
|
||||
journal app contains a number of these statements to manage its many
|
||||
constituents and sub-components:
|
||||
|
||||
```js {% copy=true %}
|
||||
state = {
|
||||
entries: [], // list of journal entries for display
|
||||
drafts: {}, // edits which haven't been submitted yet
|
||||
newDraft: {}, // new entry which hasn't been submitted yet
|
||||
results: [], // search results
|
||||
searchStart: null, // search query start date
|
||||
searchEnd: null, // search query end date
|
||||
resultStart: null, // search results start date
|
||||
resultEnd: null, // search results end date
|
||||
searchTime: null, // time of last search
|
||||
latestUpdate: null, // most recent update we've received
|
||||
entryToDelete: null, // deletion target for confirmation modal
|
||||
status: null, // connection status (con, try, err)
|
||||
errorCount: 0, // number of errors so far
|
||||
errors: new Map(), // list of error messages for display
|
||||
};
|
||||
```javascript
|
||||
// Control/Meta State //
|
||||
const [subEvent, setSubEvent] = useState({});
|
||||
const [latestUpdate, setLatestUpdate] = useState(null);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [errorCount, setErrorCount] = useState(0);
|
||||
const [errors, setErrors] = useState(new Map());
|
||||
|
||||
// Journal State //
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [drafts, setDrafts] = useState({});
|
||||
const [newDraft, setNewDraft] = useState({});
|
||||
const [entryToDelete, setEntryToDelete] = useState(null);
|
||||
|
||||
// Search State //
|
||||
const [results, setResults] = useState([]);
|
||||
const [searchMeta, setSearchMeta] = useState({
|
||||
time: null,
|
||||
start: null,
|
||||
end: null,
|
||||
});
|
||||
```
|
||||
|
||||
We'll see how these are used subsequently.
|
||||
|
||||
## Initialize
|
||||
|
||||
The first thing our app does is call `init()`:
|
||||
After defining its state, the next thing our `App` component does is define a
|
||||
function called `init()`, which is one of the first functions called during its
|
||||
bootstrapping process:
|
||||
|
||||
```js
|
||||
init = () => {
|
||||
this.getEntries().then(
|
||||
```javascript
|
||||
const init = () => {
|
||||
getEntries().then(
|
||||
(result) => {
|
||||
this.handleUpdate(result);
|
||||
this.setState({ latestUpdate: result.time });
|
||||
this.subscribe();
|
||||
setSubEvent(result);
|
||||
setLatestUpdate(result.time);
|
||||
subscribe();
|
||||
},
|
||||
(err) => {
|
||||
this.setErrorMsg("Connection failed");
|
||||
this.setState({ status: "err" });
|
||||
addError("Connection failed");
|
||||
setStatus("err");
|
||||
}
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
This function just calls `getEntries()` to retrieve the initial list of journal
|
||||
entries then, if that succeeded, it calls `subscribe()` to subscribe for new
|
||||
entries; then, if that succeeded, it publishes this update with `setSubEvent()`
|
||||
and `setLatestUpdate()` and then calls `subscribe()` to subscribe for new
|
||||
updates. If the initial entry retrieval failed, we set the connection `status`
|
||||
and save an error message in the `errors` map. We'll look at what we do with
|
||||
errors later.
|
||||
@ -63,13 +77,13 @@ errors later.
|
||||
|
||||
![entries screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/entries.png)
|
||||
|
||||
The `getEntries` function scries our `%journal` agent for up to 10 entries
|
||||
before the oldest we currently have. We call this initially, and then each time
|
||||
The `getEntries()` function scries our `%journal` agent for up to 10 entries
|
||||
before the oldest we currently have. We call this initially and then each time
|
||||
the user scrolls to the bottom of the list.
|
||||
|
||||
```js
|
||||
getEntries = async () => {
|
||||
const { entries: e } = this.state;
|
||||
```javascript
|
||||
const getEntries = async () => {
|
||||
const e = entries;
|
||||
const before = e.length === 0 ? Date.now() : e[e.length - 1].id;
|
||||
const max = 10;
|
||||
const path = `/entries/before/${before}/${max}`;
|
||||
@ -93,27 +107,27 @@ direct GET requests allow other marks too.
|
||||
The `Urbit.scry` method returns a Promise which will contain an HTTP error
|
||||
message if the scry failed. We handle it with a `.then` expression back in the
|
||||
function that called it, either [`init()`](#initialize) or `moreEntries()`. If
|
||||
the Promise is successfuly, the results are passed to the
|
||||
[`handleUpdate`](#updates) function which appends the new entries to the
|
||||
existing ones in state.
|
||||
the Promise is successfully evaluated, the results are passed to the
|
||||
[`setSubEvent()`](#updates) function, which appends the new entries to the
|
||||
existing ones via a [`useEffect()`] hook (more on this [below](#updates)).
|
||||
|
||||
## Subscription
|
||||
|
||||
A subscription to the `/updates` path of our `%journal` agent is opened with our
|
||||
`subscribe()` function:
|
||||
|
||||
```js
|
||||
subscribe = () => {
|
||||
```javascript
|
||||
const subscribe = () => {
|
||||
try {
|
||||
window.urbit.subscribe({
|
||||
app: "journal",
|
||||
path: "/updates",
|
||||
event: this.handleUpdate,
|
||||
err: () => this.setErrorMsg("Subscription rejected"),
|
||||
quit: () => this.setErrorMsg("Kicked from subscription"),
|
||||
event: setSubEvent,
|
||||
err: () => addError("Subscription rejected"),
|
||||
quit: () => addError("Kicked from subscription"),
|
||||
});
|
||||
} catch {
|
||||
this.setErrorMsg("Subscription failed");
|
||||
addError("Subscription failed");
|
||||
}
|
||||
};
|
||||
```
|
||||
@ -124,7 +138,8 @@ object:
|
||||
- `app` - the target agent.
|
||||
- `path` - the `%watch` path we're subscribing to.
|
||||
- `event` - a function to handle each fact the agent sends out. We call our
|
||||
`handleUpdate` function, which we'll describe below.
|
||||
`setSubEvent()` function to set off a cascade to update the interface;
|
||||
this process is described [below](#updates).
|
||||
- `err` - a function to call if the subscription request is rejected (nacked).
|
||||
We just display an error in this case.
|
||||
- `quit` - a function to call if we get kicked from the subscription. We also
|
||||
@ -137,86 +152,121 @@ keep track of these IDs in your app's state.
|
||||
|
||||
## Updates
|
||||
|
||||
This `handleUpdate` function handles all updates we receive. It's called
|
||||
whenever an event comes in for our subscription, and it's also called with the
|
||||
results of [`getEntries`](#getting-entries) and [`getUpdates`](#error-handling)
|
||||
(described later).
|
||||
The architecture for updating a React interface based on incoming facts from an
|
||||
`Urbit` subscription tends to follow a common pattern constituted of three
|
||||
major parts:
|
||||
|
||||
It's a bit complex, but basically it just checks whether the JSON object is
|
||||
`add`, `edit`, `delete`, or `entries`, and then updates the state appropriately.
|
||||
The object it's receiving is just the `$update` structure converted to JSON by
|
||||
the mark conversion functions we wrote previously.
|
||||
1. A [`useState()`] call that creates an update object field as part of the
|
||||
main component's state:
|
||||
```javascript
|
||||
const [subEvent, setSubEvent] = useState({});
|
||||
```
|
||||
2. An `Urbit.subscribe` call that passes the update object's setter function as
|
||||
its `event` field:
|
||||
```javascript
|
||||
window.urbit.subscribe({/* ... */, event: setSubEvent});
|
||||
```
|
||||
3. A [`useEffect()`] invocation that triggers off of the update object, which
|
||||
contains the logic for handling subscription updates:
|
||||
```javascript
|
||||
useEffect(() => {/* ... */}, [subEvent]);
|
||||
```
|
||||
|
||||
```js
|
||||
handleUpdate = (upd) => {
|
||||
const { entries, drafts, results, latestUpdate } = this.state;
|
||||
if (upd.time !== latestUpdate) {
|
||||
if ("entries" in upd) {
|
||||
this.setState({ entries: entries.concat(upd.entries) });
|
||||
} else if ("add" in upd) {
|
||||
const { time, add } = upd;
|
||||
const eInd = this.spot(add.id, entries);
|
||||
const rInd = this.spot(add.id, results);
|
||||
const toE =
|
||||
entries.length === 0 || add.id > entries[entries.length - 1].id;
|
||||
const toR = this.inSearch(add.id, time);
|
||||
The key piece of this architecture is the [`useEffect()`] trigger, which is
|
||||
called whenever an event comes in on the subscription wire (achieved by
|
||||
including the subscription object `subEvent` as a re-invocation trigger in
|
||||
[`useEffect()`]'s second argument). In our application, this hook is also
|
||||
triggered by calls to [`getEntries()`](#getting-entries) and
|
||||
[`getUpdates()`](#error-handling), which will be described in greater detail
|
||||
later.
|
||||
|
||||
The trigger code is a bit complex, but in broad brushstrokes it just checks the
|
||||
header of the incoming JSON object (i.e. one of `add`, `edit`, `delete`, or
|
||||
`entries`) and then updates the state appropriately. The object it's receiving
|
||||
is just the `$update` structure converted to JSON by the mark conversion
|
||||
functions we wrote previously.
|
||||
|
||||
```javascript {% mode="collapse" %}
|
||||
useEffect(() => {
|
||||
const getDataIndex = (id, data) => {
|
||||
let low = 0;
|
||||
let high = data.length;
|
||||
while (low < high) {
|
||||
let mid = (low + high) >>> 1;
|
||||
if (data[mid].id > id) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
};
|
||||
|
||||
const isInSearch = (id, time) => (
|
||||
searchMeta.time !== null &&
|
||||
time >= searchMeta.time &&
|
||||
searchMeta.start.getTime() <= id &&
|
||||
searchMeta.end.getTime() >= id
|
||||
);
|
||||
|
||||
if (subEvent.time !== latestUpdate) {
|
||||
if ("entries" in subEvent) {
|
||||
setEntries(entries.concat(subEvent.entries));
|
||||
} else if ("add" in subEvent) {
|
||||
const { time, add } = subEvent;
|
||||
const eInd = getDataIndex(add.id, entries);
|
||||
const rInd = getDataIndex(add.id, results);
|
||||
const toE = entries.length === 0 || add.id > entries[entries.length - 1].id;
|
||||
const toR = isInSearch(add.id, time);
|
||||
toE && entries.splice(eInd, 0, add);
|
||||
toR && results.splice(rInd, 0, add);
|
||||
this.setState({
|
||||
...(toE && { entries: entries }),
|
||||
...(toR && { results: results }),
|
||||
latestUpdate: time,
|
||||
});
|
||||
} else if ("edit" in upd) {
|
||||
const { time, edit } = upd;
|
||||
toE && setEntries([...entries]);
|
||||
toR && setResults([...results]);
|
||||
setLatestUpdate(time);
|
||||
} else if ("edit" in subEvent) {
|
||||
const { time, edit } = subEvent;
|
||||
const eInd = entries.findIndex((e) => e.id === edit.id);
|
||||
const rInd = results.findIndex((e) => e.id === edit.id);
|
||||
const toE = eInd !== -1;
|
||||
const toR = rInd !== -1 && this.inSearch(edit.id, time);
|
||||
const toR = rInd !== -1 && isInSearch(edit.id, time);
|
||||
if (toE) entries[eInd] = edit;
|
||||
if (toR) results[rInd] = edit;
|
||||
(toE || toR) && delete drafts[edit.id];
|
||||
this.setState({
|
||||
...(toE && { entries: entries }),
|
||||
...(toR && { results: results }),
|
||||
...((toE || toR) && { drafts: drafts }),
|
||||
latestUpdate: time,
|
||||
});
|
||||
} else if ("del" in upd) {
|
||||
const { time, del } = upd;
|
||||
toE && setEntries([...entries]);
|
||||
toR && setResults([...results]);
|
||||
(toE || toR) && setDrafts({...drafts});
|
||||
setLatestUpdate(time);
|
||||
} else if ("del" in subEvent) {
|
||||
const { time, del } = subEvent;
|
||||
const eInd = entries.findIndex((e) => e.id === del.id);
|
||||
const rInd = results.findIndex((e) => e.id === del.id);
|
||||
const toE = eInd !== -1;
|
||||
const toR = this.inSearch(del.id, time) && rInd !== -1;
|
||||
const toR = isInSearch(del.id, time) && rInd !== -1;
|
||||
toE && entries.splice(eInd, 1);
|
||||
toR && results.splice(rInd, 1);
|
||||
(toE || toR) && delete drafts[del.id];
|
||||
this.setState({
|
||||
...(toE && { entries: entries }),
|
||||
...(toR && { results: results }),
|
||||
...((toE || toR) && { drafts: drafts }),
|
||||
latestUpdate: time,
|
||||
});
|
||||
toE && setEntries([...entries]);
|
||||
toR && setResults([...results]);
|
||||
(toE || toR) && setDrafts({...drafts});
|
||||
setLatestUpdate(time);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [subEvent]);
|
||||
```
|
||||
|
||||
## Add, edit, delete
|
||||
|
||||
![add screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/add.png)
|
||||
|
||||
When a user writes a new journal entry and hits submit, the `submitNew` function
|
||||
is called. It uses the `Urbit.poke` method to poke our `%journal` agent.
|
||||
When a user writes a new journal entry and hits submit, the `createEntry()`
|
||||
function is called. It uses the `Urbit.poke` method to poke our `%journal`
|
||||
agent.
|
||||
|
||||
```js
|
||||
submitNew = (id, txt) => {
|
||||
```javascript
|
||||
const createEntry = (id, txt) => {
|
||||
window.urbit.poke({
|
||||
app: "journal",
|
||||
mark: "journal-action",
|
||||
json: { add: { id: id, txt: txt } },
|
||||
onSuccess: () => this.setState({ newDraft: {} }),
|
||||
onError: () => this.setErrorMsg("New entry rejected"),
|
||||
onSuccess: () => setDraft({}),
|
||||
onError: () => setError("New entry rejected"),
|
||||
});
|
||||
};
|
||||
```
|
||||
@ -239,35 +289,38 @@ The `Urbit.poke` method takes five arguments:
|
||||
`onSuccess` and `onError` are optional, but it's usually desirable to handle
|
||||
these cases.
|
||||
|
||||
The `delete` and `submitEdit` functions are similar to `submitNew`, but for the
|
||||
`%del` and `%edit` actions rather than `%add`:
|
||||
The `deleteEntry()` and `editEntry()` functions are similar to `createEntry()`,
|
||||
but for the `%del` and `%edit` actions rather than `%add`:
|
||||
|
||||
![edit screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/edit.png)
|
||||
|
||||
```js
|
||||
submitEdit = (id, txt) => {
|
||||
if (txt !== null) {
|
||||
```javascript
|
||||
const editEntry = (id, txt) => {
|
||||
if (txt === null) {
|
||||
delete drafts[id];
|
||||
setDrafts({...drafts});
|
||||
} else {
|
||||
window.urbit.poke({
|
||||
app: "journal",
|
||||
mark: "journal-action",
|
||||
json: { edit: { id: id, txt: txt } },
|
||||
onError: () => this.setErrorMsg("Edit rejected"),
|
||||
onError: () => setError("Edit rejected"),
|
||||
});
|
||||
} else this.cancelEdit(id);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
![delete screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/delete.png)
|
||||
|
||||
```js
|
||||
delete = (id) => {
|
||||
```javascript
|
||||
const deleteEntry = (id) => {
|
||||
window.urbit.poke({
|
||||
app: "journal",
|
||||
mark: "journal-action",
|
||||
json: {"del": {"id": id}},
|
||||
onError: ()=>this.setErrorMsg("Deletion rejected")
|
||||
})
|
||||
this.setState({rmModalShow: false, entryToDelete: null})
|
||||
json: { del: { id: id } },
|
||||
onError: () => setError("Deletion rejected"),
|
||||
});
|
||||
setDeleteId(null);
|
||||
};
|
||||
```
|
||||
|
||||
@ -279,73 +332,66 @@ our agent.
|
||||
|
||||
![search screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/search.png)
|
||||
|
||||
When searching for entries between two dates, the `getSearch` function is
|
||||
When searching for entries between two dates, the `searchEntries()` function is
|
||||
called, which uses the `Urbit.scry` method to scry for the results in a similar
|
||||
fashion to [`getEntries`](#getting-entries), but using the
|
||||
`/x/entries/between/[start]/[end]` endpoint.
|
||||
|
||||
```js
|
||||
getSearch = async () => {
|
||||
const { searchStart: ss, searchEnd: se, latestUpdate: lu } = this.state;
|
||||
if (lu !== null && ss !== null && se !== null) {
|
||||
let start = ss.getTime();
|
||||
let end = se.getTime();
|
||||
if (start < 0) start = 0;
|
||||
if (end < 0) end = 0;
|
||||
const path = `/entries/between/${start}/${end}`;
|
||||
window.urbit
|
||||
.scry({
|
||||
app: "journal",
|
||||
path: path,
|
||||
})
|
||||
.then(
|
||||
(result) => {
|
||||
this.setState({
|
||||
searchTime: result.time,
|
||||
searchStart: null,
|
||||
searchEnd: null,
|
||||
resultStart: ss,
|
||||
resultEnd: se,
|
||||
results: result.entries,
|
||||
});
|
||||
},
|
||||
(err) => {
|
||||
this.setErrorMsg("Search failed");
|
||||
}
|
||||
);
|
||||
} else {
|
||||
lu !== null && this.setErrorMsg("Searh failed");
|
||||
}
|
||||
```javascript
|
||||
const searchEntries = async () => {
|
||||
const start = Math.max(inputStart.getTime(), 0);
|
||||
const end = Math.max(inputEnd.getTime(), 0);
|
||||
window.urbit.scry({
|
||||
app: "journal",
|
||||
path: `/entries/between/${start}/${end}`,
|
||||
}).then(
|
||||
(result) => {
|
||||
setInputStart(null);
|
||||
setInputEnd(null);
|
||||
setResults(result.entries);
|
||||
setSearchMeta({
|
||||
time: result.time,
|
||||
start: inputStart,
|
||||
end: inputEnd
|
||||
});
|
||||
},
|
||||
(err) => {
|
||||
setError("Search failed");
|
||||
}
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
When the channel connection is interrupted, the `Urbit` object will begin trying to reconnect. On each attempt, it sets the connection `status` to `"try"`, as we specified for the `onRetry` callback. When this is set, a "reconnecting" message is displayed at the bottom of the screen:
|
||||
When the channel connection is interrupted, the `Urbit` object will begin
|
||||
trying to reconnect. On each attempt, it sets the connection `status` to
|
||||
`"try"`, as we specified for the `onRetry` callback. When this is set, a
|
||||
"reconnecting" message is displayed at the bottom of the screen:
|
||||
|
||||
![reconnecting screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/reconnecting.png)
|
||||
|
||||
If all three reconnection attempts fail, the `onError` callback is fired and we replace the "reconnecting" message with a "reconnect" button:
|
||||
If all three reconnection attempts fail, the `onError` callback is fired and we
|
||||
replace the "reconnecting" message with a "reconnect" button:
|
||||
|
||||
![reconnect screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/reconnect.png)
|
||||
|
||||
When clicked, the following function is called:
|
||||
|
||||
```js
|
||||
reconnect = () => {
|
||||
```javascript
|
||||
const reconnect = () => {
|
||||
window.urbit.reset();
|
||||
const latest = this.state.latestUpdate;
|
||||
if (latest === null) {
|
||||
this.init();
|
||||
if (latestUpdate === null) {
|
||||
init();
|
||||
} else {
|
||||
this.getUpdates().then(
|
||||
getUpdates().then(
|
||||
(result) => {
|
||||
result.logs.map((e) => this.handleUpdate(e));
|
||||
this.subscribe();
|
||||
result.logs.map(setSubEvent);
|
||||
subscribe();
|
||||
},
|
||||
(err) => {
|
||||
this.setErrorMsg("Connection failed");
|
||||
this.setState({ status: "err" });
|
||||
addError("Connection failed");
|
||||
setStatus("err");
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -363,10 +409,9 @@ Since we've reset the channel, we don't know if we've missed any updates. Rather
|
||||
than having to refresh our whole state, we can use the `getUpdates()` function
|
||||
to get any missing update:
|
||||
|
||||
```js
|
||||
getUpdates = async () => {
|
||||
const { latestUpdate: latest } = this.state;
|
||||
const since = latest === null ? Date.now() : latest;
|
||||
```javascript
|
||||
const getUpdates = async () => {
|
||||
const since = latestUpdate === null ? Date.now() : latestUpdate;
|
||||
const path = `/updates/since/${since}`;
|
||||
return window.urbit.scry({
|
||||
app: "journal",
|
||||
@ -381,18 +426,21 @@ recent than `latestUpdate`, which is always set to the last logged action we
|
||||
received. The `getUpdates` function returns a Promise to the `reconnect`
|
||||
function above which called it. The `reconnect` function handles it in a `.then`
|
||||
expression, where the success case passes each update retrieved to the
|
||||
[`handleUpdate`](#updates) function, updating our state.
|
||||
[`setSubEvent()`](#updates) function, updating our state.
|
||||
|
||||
Lastly, as well as handling channel connection errors, we also handle errors
|
||||
such as poke nacks or failed scries by printing error messages added to the
|
||||
`error` map by the `setErrorMsg` function. You could of course handle nacks,
|
||||
kicks, scry failures, etc differently than just printing an error, it depends on
|
||||
the needs of your app.
|
||||
`error` map by the `setErrorMsg()` function. You could of course handle nacks,
|
||||
kicks, scry failures, etc differently than just printing an error; it depends
|
||||
on the needs of your app.
|
||||
|
||||
![search failed screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/search-failed.png)
|
||||
|
||||
## Resources
|
||||
|
||||
- [React Tutorial](https://react.dev/learn/tutorial-tic-tac-toe) - A tutorial
|
||||
walking through the basics of writing a modern React application.
|
||||
|
||||
- [HTTP API Guide](/guides/additional/http-api-guide) - Reference documentation for
|
||||
`@urbit/http-api`.
|
||||
|
||||
@ -403,3 +451,7 @@ the needs of your app.
|
||||
- [`@urbit/http-api` source
|
||||
code](https://github.com/urbit/urbit/tree/master/pkg/npm/http-api) - The
|
||||
source code for the `@urbit/http-api` NPM package.
|
||||
|
||||
|
||||
[`usestate()`]: https://react.dev/reference/react/useState
|
||||
[`useeffect()`]: https://react.dev/reference/react/useEffect
|
||||
|
@ -34,20 +34,20 @@ There's a handful of extra files we need in the root of our desk:
|
||||
|
||||
We only have one agent to start, so `desk.bill` is very simple:
|
||||
|
||||
```
|
||||
``` {% copy=true %}
|
||||
:~ %journal
|
||||
==
|
||||
```
|
||||
|
||||
Likewise, `sys.kelvin` just contains:
|
||||
|
||||
```
|
||||
``` {% copy=true %}
|
||||
[%zuse 417]
|
||||
```
|
||||
|
||||
The `desk.docket-0` file is slightly more complicated:
|
||||
|
||||
```
|
||||
``` {% copy=true %}
|
||||
:~
|
||||
title+'Journal'
|
||||
info+'Dear diary...'
|
||||
@ -107,24 +107,22 @@ Once created, we can mount it to the unix filesystem.
|
||||
|
||||
In the dojo of a fake ship:
|
||||
|
||||
```
|
||||
> |merge %journal our %webterm
|
||||
>=
|
||||
> |mount %journal
|
||||
>=
|
||||
``` {% copy=true %}
|
||||
|new-desk %journal
|
||||
|mount %journal
|
||||
```
|
||||
|
||||
Now we can browse to it in the unix terminal:
|
||||
|
||||
```sh
|
||||
cd ~/zod/journal
|
||||
```sh {% copy=true %}
|
||||
cd /path/to/zod/journal
|
||||
```
|
||||
|
||||
Currently it has the same files as the `%webterm` desk, so we need to delete
|
||||
those:
|
||||
|
||||
```sh
|
||||
rm -r .
|
||||
```sh {% copy=true %}
|
||||
rm -rI /path/to/zod/journal/*
|
||||
```
|
||||
|
||||
Apart from the kernel and standard library, desks need to be totally
|
||||
@ -134,28 +132,28 @@ For example, since our app contains a number of `.hoon` files, we need the
|
||||
everything it needs is to copy in the "dev" versions of the `%base` and
|
||||
`%garden` desks. To do this, we first clone the Urbit git repository:
|
||||
|
||||
```sh
|
||||
```sh {% copy=true %}
|
||||
git clone https://github.com/urbit/urbit.git urbit-git
|
||||
```
|
||||
|
||||
If we navigate to the `pkg` directory in the cloned repo:
|
||||
|
||||
```sh
|
||||
cd ~/urbit-git/pkg
|
||||
```sh {% copy=true %}
|
||||
cd /path/to/urbit-git/pkg
|
||||
```
|
||||
|
||||
...we can combine the `base-dev` and `garden-dev` desks with the included
|
||||
`symbolic-merge.sh` script:
|
||||
|
||||
```sh
|
||||
```sh {% copy=true %}
|
||||
./symbolic-merge.sh base-dev journal
|
||||
./symbolic-merge.sh garden-dev journal
|
||||
```
|
||||
|
||||
Now, we copy the contents of the new `journal` folder into our empty desk:
|
||||
|
||||
```sh
|
||||
cp -rL journal/* ~/zod/journal/
|
||||
```sh {% copy=true %}
|
||||
cp -rL journal/* /path/to/zod/journal/
|
||||
```
|
||||
|
||||
Note we've used the `L` flag to resolve symbolic links, because the dev-desks
|
||||
@ -163,22 +161,25 @@ contain symlinks to files in the actual `arvo` and `garden` folders.
|
||||
|
||||
We can copy across all of our own files too:
|
||||
|
||||
```sh
|
||||
cp -r ~/ourfiles/* ~/zod/journal/
|
||||
```sh {% copy=true %}
|
||||
cp -r /path/to/ourfiles/* /path/to/zod/journal/
|
||||
```
|
||||
|
||||
Finally, in the dojo, we can commit the whole lot:
|
||||
|
||||
```
|
||||
``` {% copy=true %}
|
||||
|commit %journal
|
||||
```
|
||||
|
||||
## Glob
|
||||
|
||||
The next step is to build our front-end and upload the files to our ship. In the
|
||||
`journal-ui` folder containing our React app, we can run:
|
||||
The next step is to build our front-end and upload the files to our ship. If
|
||||
you haven't yet downloaded the journal front-end source files, you can grab
|
||||
them from [their repository](https://github.com/urbit/docs-examples). In the
|
||||
folder containing our React app (`journal-app/ui` relative to the repository
|
||||
base directory), we can run:
|
||||
|
||||
```sh
|
||||
```sh {% copy=true %}
|
||||
npm run build
|
||||
```
|
||||
|
||||
@ -186,7 +187,7 @@ This will create a `build` directory containing the compiled front-end files. To
|
||||
upload it to our ship, we need to first install the `%journal` desk. In the
|
||||
dojo:
|
||||
|
||||
```
|
||||
``` {% copy=true %}
|
||||
|install our %journal
|
||||
```
|
||||
|
||||
@ -208,7 +209,7 @@ If we now return to the homescreen of our ship, we'll see our tile displayed, an
|
||||
The last thing we need to do is publish our app, so other users can install it
|
||||
from our ship. To do that, we just run the following command in the dojo:
|
||||
|
||||
```
|
||||
``` {% copy=true %}
|
||||
:treaty|publish %journal
|
||||
```
|
||||
|
||||
|
@ -28,7 +28,7 @@ has three folders inside:
|
||||
1. `bare-desk`: just the hoon files created here without any dependencies.
|
||||
2. `full-desk`: `bare-desk` plus all dependencies. Note some files are
|
||||
symlinked, so if you're copying them you'll need to do `cp -rL`.
|
||||
3. `react-frontend`: the React front-end files.
|
||||
3. `ui`: the React front-end files.
|
||||
|
||||
Let's get started.
|
||||
|
||||
@ -53,7 +53,7 @@ curl -L https://urbit.org/install/linux-aarch64/latest | tar xzk --transform='s/
|
||||
#### macOS (`x86_64`)
|
||||
|
||||
```shell {% copy=true %}
|
||||
curl -L https://urbit.org/install/macos-x86_64/latest | tar xzk -s '/.*/urbit/'
|
||||
curl -L https://urbit.org/install/macos-x86_64/latest | tar xzk -s '/.*/urbit/'
|
||||
```
|
||||
|
||||
#### macOS (`aarch64`)
|
||||
@ -1117,7 +1117,7 @@ in `hut/mar/hut/do.hoon` and `hut/mar/hut/did.hoon` respectively.
|
||||
::
|
||||
++ grow
|
||||
|%
|
||||
:: this mark is primarily used inbound from the
|
||||
:: this mark is primarily used inbound from the
|
||||
:: front-end, so we only need a simple %noun
|
||||
:: conversion method here
|
||||
::
|
||||
@ -1333,248 +1333,256 @@ in `hut/mar/hut/do.hoon` and `hut/mar/hut/did.hoon` respectively.
|
||||
Our back-end is complete, so we can now work on our React front-end. We'll just
|
||||
look at the basic setup process here, but you can get the full React app by
|
||||
cloning [this repo on Github](https://github.com/urbit/docs-examples) and run
|
||||
`npm i` in `chat-app/react-frontend`. Additional commentary on the code is in
|
||||
the [additional commentary](#additional-commentary) section below.
|
||||
`npm i` in `chat-app/ui`. Additional commentary on the code is in the
|
||||
[additional commentary](#additional-commentary) section below.
|
||||
|
||||
#### Basic setup process
|
||||
|
||||
When creating it from scratch, we can first run `create-react-app` like usual:
|
||||
When creating it from scratch, first make sure you have Node.js installed on
|
||||
your computer (you can download it from their
|
||||
[website](https://nodejs.org/en/download)) and then run `create-landscape-app`:
|
||||
|
||||
```shell {% copy=true %}
|
||||
npx create-react-app hut-ui
|
||||
cd hut-ui
|
||||
```shell
|
||||
npx @urbit/create-landscape-app
|
||||
✔ What should we call your application? … hut
|
||||
✔ What URL do you use to access Urbit? … http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
To make talking to our ship easy, we'll install the `@urbit/http-api` module:
|
||||
This will generate a React project in the `hut/ui` directory with all the
|
||||
basic necessities for Urbit front-end development. Next, run the following
|
||||
commands to install the project's dependencies:
|
||||
|
||||
```
|
||||
npm i @urbit/http-api
|
||||
```shell
|
||||
cd hut/ui
|
||||
npm i
|
||||
```
|
||||
|
||||
`http-api` handles most of the tricky parts of communicating with our ship for
|
||||
us, and has a simple set of methods for doing things like pokes, subscriptions,
|
||||
receiving updates, etc.
|
||||
|
||||
The next thing we need to do is edit `package.json`. We'll change the name of
|
||||
the app, and we'll also add an additional `"homepage"` entry. Front-ends are
|
||||
serve at `/apps/<name>`, so we need to set that as the root for when we build
|
||||
it:
|
||||
|
||||
```json
|
||||
"name": "hut",
|
||||
"homepage": "/apps/hut/",
|
||||
```
|
||||
|
||||
Next, we need to edit `public/index.html` and add a script import to the
|
||||
`<head>` section. `http-api` needs to know the name of our ship in order to talk
|
||||
to it, so our ship serves a simple script at `/session.js` that just does
|
||||
`window.ship = "sampel-palnet";`.
|
||||
|
||||
```html
|
||||
<script src="/session.js"></script>
|
||||
```
|
||||
|
||||
We can now open `src/App.js`, wipe its contents, and start writing our own app.
|
||||
The first thing is to import the `Urbit` class from `@urbit/http-api`:
|
||||
We can now open `src/app.jsx`, wipe its contents, and start writing our own
|
||||
app. The first thing is to import the `Urbit` class from `@urbit/http-api`:
|
||||
|
||||
```javascript
|
||||
import React, {Component} from "react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import Urbit from "@urbit/http-api";
|
||||
// .....
|
||||
```
|
||||
|
||||
In our App class, we'll create a new `Urbit` instance and tell it our ship name.
|
||||
We'll also add some connection state callbacks. Our app is simple and will just
|
||||
display the connection status in the top-right corner.
|
||||
We'll create an `App` component that will create a new `Urbit` instance on load
|
||||
to monitor our front-end's connection with our ship. Our app is simple and will
|
||||
just display the connection status in the top-left corner:
|
||||
|
||||
```javascript
|
||||
constructor(props) {
|
||||
super(props);
|
||||
window.urbit = new Urbit("");
|
||||
window.urbit.ship = window.ship;
|
||||
// ......
|
||||
window.urbit.onOpen = () => this.setState({conn: "ok"});
|
||||
window.urbit.onRetry = () => this.setState({conn: "try"});
|
||||
window.urbit.onError = () => this.setState({conn: "err"});
|
||||
// ......
|
||||
};
|
||||
export function App() {
|
||||
const [status, setStatus] = useState("try");
|
||||
|
||||
useEffect(() => {
|
||||
window.urbit = new Urbit("");
|
||||
window.urbit.ship = window.ship;
|
||||
|
||||
window.urbit.onOpen = () => setStatus("con");
|
||||
window.urbit.onRetry = () => setStatus("try");
|
||||
window.urbit.onError = () => setStatus("err");
|
||||
|
||||
const subscription = window.urbit.subscribe({
|
||||
app: "hut",
|
||||
path: "/all",
|
||||
event: (e) => console.log(e),
|
||||
});
|
||||
|
||||
return () => window.urbit.unsubscribe(subscription);
|
||||
}, []);
|
||||
|
||||
return (<h1>{status}</h1>);
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
constructor(props) {
|
||||
super(props);
|
||||
window.urbit = new Urbit("");
|
||||
window.urbit.ship = window.ship;
|
||||
// ......
|
||||
window.urbit.onOpen = () => this.setState({conn: "ok"});
|
||||
window.urbit.onRetry = () => this.setState({conn: "try"});
|
||||
window.urbit.onError = () => this.setState({conn: "err"});
|
||||
// ......
|
||||
};
|
||||
```
|
||||
After we've finished writing our React app, we can build it and view the
|
||||
resulting files in the `dist` directory:
|
||||
|
||||
After we've finished writing our React app, we can build it:
|
||||
|
||||
```shell {% copy=true %}
|
||||
```shell
|
||||
npm run build
|
||||
ls dist
|
||||
```
|
||||
|
||||
#### Additional commentary
|
||||
|
||||
There are a fair few functions our front-end uses, so we'll just look at a
|
||||
handful. The first is `doPoke`, which (as the name suggests) sends a poke to a
|
||||
ship. It takes the poke in JSON form. It then calls the `poke` method of our
|
||||
`Urbit` object to perform the poke.
|
||||
There are a fair few functions in the
|
||||
[complete front-end source for `%hut`](https://github.com/urbit/docs-examples);
|
||||
we'll just look at a handful to cover the basics. The first is the `appPoke`
|
||||
in `src/lib.js`, which (as the name suggests) sends a poke to a ship. It takes
|
||||
the poke in JSON form and calls the `poke` method of our `Urbit` object to
|
||||
perform the poke:
|
||||
|
||||
```javascript
|
||||
doPoke = jon => {
|
||||
window.urbit.poke({
|
||||
export function appPoke(jon) {
|
||||
return api.poke({
|
||||
app: "hut",
|
||||
mark: "hut-do",
|
||||
json: jon,
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Here's an example of a `%join`-type `act` in JSON form:
|
||||
An example of sending a `poke` with a `%join`-type `act` in JSON form can be
|
||||
found in the `src/components/SelectGid.jsx` source file:
|
||||
|
||||
```javascript
|
||||
joinGid = () => {
|
||||
const joinSelect = this.state.joinSelect
|
||||
if (joinSelect === "def") return;
|
||||
const [host, name] = joinSelect.split("/");
|
||||
this.doPoke(
|
||||
{"join": {
|
||||
"gid" : {"host": host, "name": name},
|
||||
"who" : this.our
|
||||
}}
|
||||
);
|
||||
this.setState({joinSelect: "def"})
|
||||
const handleJoin = () => {
|
||||
if (joinSelect !== "def") {
|
||||
const [host, name] = joinSelect.split("/");
|
||||
appPoke({
|
||||
"join": {
|
||||
"gid" : {"host": host, "name": name},
|
||||
"who" : OUR
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Our front-end will subscribe to updates for all groups our `%hut` agent is
|
||||
currently tracking. To do so, it calls the `subscribe` method of the `Urbit`
|
||||
object with the `path` to subscribe to and an `event` callback to handle each
|
||||
update it receives. Our agent publishes all updates on the local-only `/all`
|
||||
path.
|
||||
object (aliased to `api` in our example) with the `path` to subscribe to and an
|
||||
`event` callback to handle each update it receives. Our agent publishes all
|
||||
updates on the local-only `/all` path. Here's the source in the `src/app.jsx`
|
||||
file:
|
||||
|
||||
```javascript
|
||||
subscribe = () => {
|
||||
window.urbit.subscribe({
|
||||
app: "hut",
|
||||
path: "/all",
|
||||
event: this.handleUpdate
|
||||
});
|
||||
};
|
||||
const subscription = api.subscribe({
|
||||
app: "hut",
|
||||
path: "/all",
|
||||
event: setSubEvent,
|
||||
});
|
||||
```
|
||||
|
||||
Here's the `handleUpdate` function we gave as a callback. The update will be one
|
||||
of our `hut-upd` types in JSON form, so we just switch on the type and handle it
|
||||
as appropriate.
|
||||
Notice that the above call to `subscribe` passes the `setSubEvent` function.
|
||||
This is part of a common pattern for Urbit React applications wherein a state
|
||||
variable is used to track new events and cause component re-rendering. The
|
||||
broad outline for this workflow is as follows:
|
||||
|
||||
1. Create a component subscription event variable with:
|
||||
```javascript
|
||||
const [subEvent, setSubEvent] = useState();
|
||||
```
|
||||
2. Call the `subscribe` function, passing `setSubEvent` as the `event` keyword
|
||||
argument:
|
||||
```javascript
|
||||
urbit.subscribe({ /* ... */, event: setSubEvent });
|
||||
```
|
||||
3. Create a subscription handler function that updates when new events are
|
||||
available with:
|
||||
```javascript
|
||||
useEffect(() => {/* handler goes here */}, [subEvent]);
|
||||
```
|
||||
|
||||
The source for the final `useEffect` portion of this workflow (found in the
|
||||
`src/app.jsx` file) can be found below:
|
||||
|
||||
```javascript {% mode="collapse" %}
|
||||
handleUpdate = upd => {
|
||||
const {huts, msgJar, joined, currentGid, currentHut} = this.state;
|
||||
if ("initAll" in upd) {
|
||||
upd.initAll.huts.forEach(obj =>
|
||||
huts.set(this.gidToStr(obj.gid), new Set(obj.names))
|
||||
);
|
||||
this.setState({
|
||||
huts: huts,
|
||||
msgJar: new Map(
|
||||
upd.initAll.msgJar.map(obj => [this.hutToStr(obj.hut), obj.msgs])
|
||||
),
|
||||
joined: new Map(
|
||||
upd.initAll.joined.map(obj =>
|
||||
[this.gidToStr(obj.gid), new Set(obj.ppl)]
|
||||
)
|
||||
)
|
||||
})
|
||||
} else if ("init" in upd) {
|
||||
upd.init.msgJar.forEach(obj =>
|
||||
msgJar.set(this.hutToStr(obj.hut), obj.msgs)
|
||||
);
|
||||
this.setState({
|
||||
msgJar: msgJar,
|
||||
huts: huts.set(
|
||||
this.gidToStr(upd.init.huts[0].gid),
|
||||
new Set(upd.init.huts[0].names)
|
||||
),
|
||||
joined: joined.set(
|
||||
this.gidToStr(upd.init.joined[0].gid),
|
||||
new Set(upd.init.joined[0].ppl)
|
||||
)
|
||||
})
|
||||
} else if ("new" in upd) {
|
||||
const gidStr = this.gidToStr(upd.new.hut.gid);
|
||||
const hutStr = this.hutToStr(upd.new.hut);
|
||||
(huts.has(gidStr))
|
||||
? huts.get(gidStr).add(upd.new.hut.name)
|
||||
: huts.set(gidStr, new Set(upd.new.hut.name));
|
||||
this.setState({
|
||||
huts: huts,
|
||||
msgJar: msgJar.set(hutStr, upd.new.msgs)
|
||||
})
|
||||
} else if ("post" in upd) {
|
||||
const hutStr = this.hutToStr(upd.post.hut);
|
||||
(msgJar.has(hutStr))
|
||||
? msgJar.get(hutStr).push(upd.post.msg)
|
||||
: msgJar.set(hutStr, [upd.post.msg]);
|
||||
this.setState(
|
||||
{msgJar: msgJar},
|
||||
() => {
|
||||
(hutStr === this.state.currentHut)
|
||||
&& this.scrollToBottom();
|
||||
useEffect(() => {
|
||||
const updateFuns = {
|
||||
"initAll": (update) => {
|
||||
update.huts.forEach(obj =>
|
||||
huts.set(gidToStr(obj.gid), new Set(obj.names))
|
||||
);
|
||||
|
||||
setHuts(new Map(huts));
|
||||
setChatContents(new Map(
|
||||
update.msgJar.map(o => [hutToStr(o.hut), o.msgs])
|
||||
));
|
||||
setChatMembers(new Map(
|
||||
update.joined.map(o => [gidToStr(o.gid), new Set(o.ppl)])
|
||||
));
|
||||
}, "init": (update) => {
|
||||
setChatContents(new Map(update.msgJar.reduce(
|
||||
(a, n) => a.set(hutToStr(n.hut), n.msgs)
|
||||
, chatContents)));
|
||||
setHuts(new Map(huts.set(
|
||||
gidToStr(update.huts[0].gid),
|
||||
new Set(update.huts[0].names)
|
||||
)));
|
||||
setChatMembers(new Map(chatMembers.set(
|
||||
gidToStr(update.joined[0].gid),
|
||||
new Set(update.joined[0].ppl)
|
||||
)));
|
||||
}, "new": (update) => {
|
||||
const gidStr = gidToStr(update.hut.gid);
|
||||
const hutStr = hutToStr(update.hut);
|
||||
if (huts.has(gidStr)) {
|
||||
huts.get(gidStr).add(update.hut.name);
|
||||
} else {
|
||||
huts.set(gidStr, new Set(update.hut.name));
|
||||
}
|
||||
)
|
||||
} else if ("join" in upd) {
|
||||
const gidStr = this.gidToStr(upd.join.gid);
|
||||
(joined.has(gidStr))
|
||||
? joined.get(gidStr).add(upd.join.who)
|
||||
: joined.set(gidStr, new Set([upd.join.who]));
|
||||
this.setState({joined: joined})
|
||||
} else if ("quit" in upd) {
|
||||
const gidStr = this.gidToStr(upd.quit.gid);
|
||||
if ("~" + window.ship === upd.quit.who) {
|
||||
(huts.has(gidStr)) &&
|
||||
huts.get(gidStr).forEach(name =>
|
||||
msgJar.delete(gidStr + "/" + name)
|
||||
|
||||
setHuts(new Map(huts));
|
||||
setChatMembers(new Map(chatMembers.set(hutStr, update.msgs)));
|
||||
}, "post": (update) => {
|
||||
const newHut = hutToStr(update.hut);
|
||||
if (chatContents.has(newHut)) {
|
||||
chatContents.set(newHut, [...chatContents.get(newHut), update.msg]);
|
||||
} else {
|
||||
chatContents.set(newHut, [update.msg]);
|
||||
}
|
||||
|
||||
setChatContents(new Map(chatContents));
|
||||
}, "join": (update) => {
|
||||
const gidStr = gidToStr(update.gid);
|
||||
if (chatMembers.has(gidStr)) {
|
||||
chatMembers.get(gidStr).add(update.who)
|
||||
} else {
|
||||
chatMembers.set(gidStr, new Set([update.who]));
|
||||
}
|
||||
|
||||
setChatMembers(new Map(chatMembers));
|
||||
setJoinSelect("def");
|
||||
}, "quit": (update) => {
|
||||
const gidStr = gidToStr(update.gid);
|
||||
if (update.who === OUR) {
|
||||
huts.delete(gidStr);
|
||||
chatMembers.delete(gidStr);
|
||||
if(huts.has(gidStr)) {
|
||||
huts.get(gidStr).forEach(name =>
|
||||
chatContents.delete(gidStr + "/" + name)
|
||||
);
|
||||
}
|
||||
|
||||
setHuts(new Map(huts));
|
||||
setChatMembers(new Map(chatMembers));
|
||||
setChatContents(new Map(chatContents));
|
||||
setCurrGid((currGid === gidStr) ? null : currGid);
|
||||
setCurrHut((currHut === null)
|
||||
? null
|
||||
: (`${currHut.split("/")[0]}/${currHut.split("/")[1]}` === gidStr)
|
||||
? null
|
||||
: currHut
|
||||
);
|
||||
huts.delete(gidStr);
|
||||
joined.delete(gidStr);
|
||||
this.setState({
|
||||
msgJar: msgJar,
|
||||
huts: huts,
|
||||
joined: joined,
|
||||
currentGid: (currentGid === gidStr)
|
||||
? null : currentGid,
|
||||
currentHut: (currentHut === null) ? null :
|
||||
(
|
||||
currentHut.split("/")[0] + "/" + currentHut.split("/")[1]
|
||||
=== gidStr
|
||||
)
|
||||
? null : currentHut,
|
||||
make: (currentGid === gidStr) ? "" : this.state.make
|
||||
})
|
||||
} else {
|
||||
(joined.has(gidStr)) &&
|
||||
joined.get(gidStr).delete(upd.quit.who);
|
||||
this.setState({joined: joined})
|
||||
}
|
||||
} else if ("del" in upd) {
|
||||
const gidStr = this.gidToStr(upd.del.hut.gid);
|
||||
const hutStr = this.hutToStr(upd.del.hut);
|
||||
(huts.has(gidStr)) &&
|
||||
huts.get(gidStr).delete(upd.del.hut.name);
|
||||
msgJar.delete(hutStr);
|
||||
this.setState({
|
||||
huts: huts,
|
||||
msgJar: msgJar,
|
||||
currentHut: (currentHut === hutStr) ? null : currentHut
|
||||
})
|
||||
setViewSelect("def");
|
||||
setHutInput((currGid === gidStr) ? "" : hutInput);
|
||||
} else {
|
||||
if (chatMembers.has(gidStr)) {
|
||||
chatMembers.get(gidStr).delete(update.who);
|
||||
}
|
||||
|
||||
setChatMembers(new Map(chatMembers));
|
||||
}
|
||||
}, "del": (update) => {
|
||||
const gidStr = gidToStr(update.hut.gid);
|
||||
const hutStr = hutToStr(update.hut);
|
||||
if (huts.has(gidStr)) {
|
||||
huts.get(gidStr).delete(update.hut.name);
|
||||
}
|
||||
chatContents.delete(hutStr);
|
||||
|
||||
setHuts(new Map(huts));
|
||||
setChatContents(new Map(chatContents));
|
||||
setCurrHut((currHut === hutStr) ? null : currHut);
|
||||
},
|
||||
};
|
||||
|
||||
const eventTypes = Object.keys(subEvent);
|
||||
if (eventTypes.length > 0) {
|
||||
const eventType = eventTypes[0];
|
||||
updateFuns[eventType](subEvent[eventType]);
|
||||
}
|
||||
};
|
||||
}, [subEvent]);
|
||||
```
|
||||
|
||||
## Desk config
|
||||
@ -1587,7 +1595,7 @@ this by adding a `sys.kelvin` file to the root of our `hut` directory:
|
||||
|
||||
```shell {% copy=true %}
|
||||
cd hut
|
||||
echo "[%zuse 417]" > sys.kelvin
|
||||
echo "[%zuse 415]" > sys.kelvin
|
||||
```
|
||||
|
||||
We also need to specify which agents to start when our desk is installed. We do
|
||||
@ -1644,7 +1652,7 @@ delete those files and copy in our own instead. In the normal shell, do the
|
||||
following:
|
||||
|
||||
```shell {% copy=true %}
|
||||
rm -r dev-comet/hut/*
|
||||
rm -rI dev-comet/hut/*
|
||||
cp -r hut/* dev-comet/hut/
|
||||
```
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user