Your spaces
By the end of this page you can make named spaces, such as Book club and Climbing, and switch between them.
Background
Section titled “Background”A space is a group of identities that replicate the same data. ECHO keeps a database in each one; the contact book uses spaces only for who is in them. In Keyhive terms a space is a group, and the identity that creates it is its first admin. Admins invite people, change what members may do and remove them.
Space.create makes a space, and Space.spaces lists every space your identity belongs to. Your identity also has a
personal space, which only your own devices join; Space.spaces leaves it out.
-
Create a space.
Space.createreturns the new space’s id, name and state at once. The space opens a moment later, so wait forSpace.waitReadybefore you use it, then select it. New space calls this.src/app/Spaces.tsx import * asEffect from 'effect/Effect';import Effect2 collapsed linesimportReact, {(alias) namespace Reactimport ReactuseState } from 'react';function useState<S>(initialState: S | (() => S)): [S, React.Dispatch<React.SetStateAction<S>>] (+1 overload)Returns a stateful value, and a function to update it.
import {Space } from '@dxos/halo';import Space18 collapsed linesimport {useHaloServices } from '@dxos/halo-react';const useHaloServices: () => Context<HaloServices>Returns the HALO services context. Throws if no
HaloProvider
is above in the tree.
import { typeSpaceId } from '@dxos/keys';type SpaceId = string & {__SpaceId: true;}const SpaceId: Codec<SpaceId, string, never, never> & {byteLength: number;encode: (value: Uint8Array) => SpaceId;decode: (value: SpaceId) => Uint8Array;isValid: (value: unknown) => value is SpaceId;make: (value: string) => SpaceId;random: () => SpaceId;}A unique identifier for a space. Space keys are generated by creating a keypair, and then taking the first 20 bytes of the SHA-256 hash of the public key and encoding them to multibase RFC4648 base-32 format (prefixed with B, see Multibase Table).
import {Prompt } from './ui.tsx';const Prompt: ({ label, action, initial, multiline, compact, autoFocus, onSubmit, }: {label: string;action: string;initial?: string;multiline?: boolean;compact?: boolean;autoFocus?: boolean;onSubmit: (value: string) => Promise<unknown>;}) => React.JSX.ElementA field and a button, which shows the error if
onSubmitfails./** Picks the current space, and makes new ones. */export constSpacePicker = ({const SpacePicker: ({ spaces, current, onSelect, }: {spaces: readonly Space.Info[];current?: SpaceId;onSelect: (id: SpaceId) => void;}) => React.JSX.ElementPicks the current space, and makes new ones.
spaces,spaces: readonly {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[]current,current: SpaceId | undefinedonSelect,onSelect: (id: SpaceId) => void}: {spaces: readonlyspaces: readonly {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[]Space.import SpaceInfo[];type Info = {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}Public view of a space. A plain snapshot — database access stays on the ECHO
Databaseservice keyed bySpaceId
, not on this value.
current?:current?: SpaceId | undefinedSpaceId;type SpaceId = string & {__SpaceId: true;}A unique identifier for a space. Space keys are generated by creating a keypair, and then taking the first 20 bytes of the SHA-256 hash of the public key and encoding them to multibase RFC4648 base-32 format (prefixed with B, see Multibase Table).
onSelect: (onSelect: (id: SpaceId) => voidid:id: SpaceIdSpaceId) => void;type SpaceId = string & {__SpaceId: true;}A unique identifier for a space. Space keys are generated by creating a keypair, and then taking the first 20 bytes of the SHA-256 hash of the public key and encoding them to multibase RFC4648 base-32 format (prefixed with B, see Multibase Table).
}) => {constservices =const services: Context<HaloServices>useHaloServices();function useHaloServices(): Context<HaloServices>Returns the HALO services context. Throws if no
HaloProvider
is above in the tree.
const [creating,const creating: booleansetCreating] =const setCreating: React.Dispatch<React.SetStateAction<boolean>>useState(false);useState<boolean>(initialState: boolean | (() => boolean)): [boolean, React.Dispatch<React.SetStateAction<boolean>>] (+1 overload)Returns a stateful value, and a function to update it.
/** Creates a space called `name`, waits until it opens, and selects it. */consthandleCreate = (const handleCreate: (name: string) => Promise<void>Creates a space called
name, waits until it opens, and selects it.name: string) =>name: stringEffect.import Effectgen(function* () {const gen: <Effect.Effect<void, SpaceError, Space.Service>, {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}>(f: () => Generator<Effect.Effect<void, SpaceError, Space.Service>, {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}, never>) => Effect.Effect<{readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}, SpaceError, Space.Service> (+1 overload)Provides a way to write effectful code using generator functions, simplifying control flow and error handling.
When to use
Use when you want to write effectful code that looks and behaves like synchronous code, while still handling asynchronous tasks, errors, and complex control flow such as loops and conditions.
Generator functions work similarly to
async/awaitbut keep errors, requirements, and interruption in the Effect type. You canyield*values from effects and return the final result at the end.Example (Sequencing effects with generators)
import { Data, Effect } from "effect"class DiscountRateError extends Data.TaggedError("DiscountRateError")<{}> {}const addServiceCharge = (amount: number) => amount + 1const applyDiscount = (total: number,discountRate: number): Effect.Effect<number, DiscountRateError> =>discountRate === 0? Effect.fail(new DiscountRateError()): Effect.succeed(total - (total * discountRate) / 100)const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))export const program = Effect.gen(function*() {const transactionAmount = yield* fetchTransactionAmountconst discountRate = yield* fetchDiscountRateconst discountedAmount = yield* applyDiscount(transactionAmount,discountRate)const finalAmount = addServiceCharge(discountedAmount)return `Final amount to charge: ${finalAmount}`})await Effect.runPromise(program) // => "Final amount to charge: 96"constspace = yield*const space: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}Space.import Spacecreate({const create: (options?: Space.CreateOptions) => Effect.Effect<Space.Info, SpaceError, Space.Service>Create a new space (requires
Service
).
name });name?: string | undefinedyield*Space.import SpacewaitReady(const waitReady: (id: SpaceId) => Effect.Effect<void, SpaceError, Space.Service>Resolve once the space is ready (requires
Service
).
space.const space: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}id);id: SpaceIdreturnspace;const space: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}}).pipe(Pipeable.pipe<Effect.Effect<{readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}, SpaceError, Space.Service>, Effect.Effect<{readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}, SpaceError, never>, Promise<{readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}>>(this: Effect.Effect<...>, ab: (_: Effect.Effect<{readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}, SpaceError, Space.Service>) => Effect.Effect<...>, bc: (_: Effect.Effect<...>) => Promise<...>): Promise<...> (+21 overloads)Effect.import Effectprovide(const provide: <HaloServices>(context: Context<HaloServices>) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, HaloServices>> (+5 overloads)Provides dependencies to an effect using layers or a context. Use
options.localto build the layer every time; by default, layers are shared between provide calls.Example (Providing dependencies with a layer)
import { Context, Effect, Layer } from "effect"interface Database {readonly query: (sql: string) => Effect.Effect<string>}const Database = Context.Service<Database>("Database")const DatabaseLayer = Layer.succeed(Database)({query: Effect.fn("Database.query")((sql: string) => Effect.succeed(`Result for: ${sql}`))})const program = Effect.gen(function*() {const db = yield* Databasereturn yield* db.query("SELECT * FROM users")})const provided = Effect.provide(program, DatabaseLayer)await Effect.runPromise(provided) // => "Result for: SELECT * FROM users"services),const services: Context<HaloServices>Effect.import EffectrunPromise)const runPromise: <A, E>(effect: Effect.Effect<A, E>, options?: Effect.RunOptions | undefined) => Promise<A>Executes an effect and returns the result as a
Promise.When to use
Use when you need to execute an effect and work with the result using
Promisesyntax, typically for compatibility with other promise-based code.If the effect succeeds, the promise will resolve with the result. If the effect fails, the promise will reject with an error.
Example (Running a successful effect as a Promise)
import { Effect } from "effect"await Effect.runPromise(Effect.succeed(1)) // => 1Example (Running effects as promises)
//Example: Handling a Failing Effect as a Rejected Promiseimport { Effect } from "effect"const output: Array<unknown> = []await Effect.runPromise(Effect.fail("my error")).catch(() => {void output.push("rejected")})output // => ["rejected"].then((Promise<{ readonly id: SpaceId; readonly state: "inactive" | "closed" | "ready"; readonly name?: string | undefined; }>.then<void, never>(onfulfilled?: ((value: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}) => void | PromiseLike<void>) | null | undefined, onrejected?: ((reason: any) => PromiseLike<never>) | null | undefined): Promise<void>Attaches callbacks for the resolution and/or rejection of the Promise.
space) =>space: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}onSelect(onSelect: (id: SpaceId) => voidspace.space: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}id));id: SpaceId -
List your spaces.
useSpacesreturns the spaces that are ready, and re-renders when one is created, joined or left. It followsSpace.spaces, which also lists spaces that are not open. The dropdown shows the named ones, by name.src/app/ContactBook.tsx importReact, {(alias) namespace Reactimport ReactuseMemo,function useMemo<T>(factory: () => T, deps: React.DependencyList): TuseMemowill only recompute the memoized value when one of thedepshas changed.useState } from 'react';function useState<S>(initialState: S | (() => S)): [S, React.Dispatch<React.SetStateAction<S>>] (+1 overload)Returns a stateful value, and a function to update it.
2 collapsed linesimport { typeIdentity } from '@dxos/halo';import Identityimport {useIdentity,const useIdentity: () => Identity.Info | undefinedReturns the local identity, or
undefinedif none exists. Reactive: re-renders when the identity is created or its profile changes. Replaces@dxos/react-client'suseIdentity.useSpaces } from '@dxos/halo-react';const useSpaces: ({ all }?: UseSpacesOptions) => readonly Info[]Returns the spaces known to the local identity. By default only
readyspaces are returned; pass{ all: true }for every space. Reactive. Replaces@dxos/react-client'suseSpaces.39 collapsed linesimport { typeSpaceId } from '@dxos/keys';type SpaceId = string & {__SpaceId: true;}const SpaceId: Codec<SpaceId, string, never, never> & {byteLength: number;encode: (value: Uint8Array) => SpaceId;decode: (value: SpaceId) => Uint8Array;isValid: (value: unknown) => value is SpaceId;make: (value: string) => SpaceId;random: () => SpaceId;}A unique identifier for a space. Space keys are generated by creating a keypair, and then taking the first 20 bytes of the SHA-256 hash of the public key and encoding them to multibase RFC4648 base-32 format (prefixed with B, see Multibase Table).
import { typeStage,type Stage = "name" | "spaces" | "invite" | "contacts" | "devices" | "recovery"How far a page of the learning path has built the app, in the order the path builds it.
reached } from '../stage.ts';const reached: (stage: Stage, at: Stage) => booleanWhether
stageincludes whatatadds.import {Contacts,const Contacts: () => React.JSX.ElementEveryone you share a space with, once each, with the spaces you share.
Members } from './Contacts.tsx';const Members: ({ spaceId }: {spaceId: SpaceId;}) => React.JSX.ElementThe members of one space, before the app lists contacts.
import {Devices } from './Devices.tsx';const Devices: () => React.JSX.ElementYour identity's devices, and a way to add another.
import {InviteBar } from './Invitations.tsx';const InviteBar: ({ spaceId }: {spaceId?: SpaceId;}) => React.JSX.ElementInvite someone to the current space, or join a space with a code.
import {ProfileChip } from './Profile.tsx';const ProfileChip: ({ name }: {name?: string;}) => React.JSX.ElementYour name, which you click to change.
import {Recovery } from './Recovery.tsx';const Recovery: () => React.JSX.ElementYour recovery codes: make one, see the ones that work, and revoke one.
import {SpacePicker } from './Spaces.tsx';const SpacePicker: ({ spaces, current, onSelect, }: {spaces: readonly Info[];current?: SpaceId;onSelect: (id: SpaceId) => void;}) => React.JSX.ElementPicks the current space, and makes new ones.
import {Boundary } from './ui.tsx';class BoundaryShows the error in place of
childrenwhen they fail to render.import {Welcome } from './Welcome.tsx';const Welcome: ({ stage }: {stage: Stage;}) => React.JSX.ElementThe device before it has an identity: start one, join one from another device, or recover one.
export typeContactBookProps = {type ContactBookProps = {stage: Stage;}stage:stage: StageStage;type Stage = "name" | "spaces" | "invite" | "contacts" | "devices" | "recovery"How far a page of the learning path has built the app, in the order the path builds it.
};/*** The contact book on one device, with what `stage` has built. It runs against* the HALO services of the `HaloProvider` above it.*/export constContactBook = ({const ContactBook: ({ stage }: ContactBookProps) => React.JSX.ElementThe contact book on one device, with what
stagehas built. It runs against the HALO services of theHaloProviderabove it.stage }:stage: StageContactBookProps) => {type ContactBookProps = {stage: Stage;}/** The identity, re-rendering on each change; undefined before it exists. */constidentity =const identity: {readonly did: IdentityDid;readonly identityKey?: string | undefined;readonly displayName?: string | undefined;readonly data?: {readonly [x: string]: any;} | undefined;} | undefinedThe identity, re-rendering on each change; undefined before it exists.
useIdentity();function useIdentity(): Identity.Info | undefinedReturns the local identity, or
undefinedif none exists. Reactive: re-renders when the identity is created or its profile changes. Replaces@dxos/react-client'suseIdentity.returnidentity ? (const identity: {readonly did: IdentityDid;readonly identityKey?: string | undefined;readonly displayName?: string | undefined;readonly data?: {readonly [x: string]: any;} | undefined;} | undefinedThe identity, re-rendering on each change; undefined before it exists.
<Signedinconst Signedin: ({ stage, identity, }: {stage: Stage;identity: Identity.Info;}) => React.JSX.Elementstage={stage: Stagestage}stage: Stageidentity={identity: {readonly did: IdentityDid;readonly identityKey?: string | undefined;readonly displayName?: string | undefined;readonly data?: {readonly [x: string]: any;} | undefined;}identity} />const identity: {readonly did: IdentityDid;readonly identityKey?: string | undefined;readonly displayName?: string | undefined;readonly data?: {readonly [x: string]: any;} | undefined;}The identity, re-rendering on each change; undefined before it exists.
) : (<Welcomeconst Welcome: ({ stage }: {stage: Stage;}) => React.JSX.ElementThe device before it has an identity: start one, join one from another device, or recover one.
stage={stage: Stagestage} />stage: Stage);};typeTab = 'people' | 'devices' | 'recovery';type Tab = "devices" | "recovery" | "people"constSignedin = ({const Signedin: ({ stage, identity, }: {stage: Stage;identity: Identity.Info;}) => React.JSX.Elementstage,stage: Stageidentity,identity: {readonly did: IdentityDid;readonly identityKey?: string | undefined;readonly displayName?: string | undefined;readonly data?: {readonly [x: string]: any;} | undefined;}}: {stage:stage: StageStage;type Stage = "name" | "spaces" | "invite" | "contacts" | "devices" | "recovery"How far a page of the learning path has built the app, in the order the path builds it.
identity:identity: {readonly did: IdentityDid;readonly identityKey?: string | undefined;readonly displayName?: string | undefined;readonly data?: {readonly [x: string]: any;} | undefined;}Identity.import IdentityInfo;type Info = {readonly did: IdentityDid;readonly identityKey?: string | undefined;readonly displayName?: string | undefined;readonly data?: {readonly [x: string]: any;} | undefined;}Public view of the local identity. Keyed by the DID;
identityKey(hex) is retained for consumers that seed deterministic UI (avatar/hue) or address the legacy credential key.datacarries arbitrary profile metadata (e.g. avatar emoji, hue).}) => {/** The spaces that are open, re-rendering on each change. */constspaces =const spaces: readonly {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[]The spaces that are open, re-rendering on each change.
useSpaces();function useSpaces({ all }?: UseSpacesOptions): readonly Info[]Returns the spaces known to the local identity. By default only
readyspaces are returned; pass{ all: true }for every space. Reactive. Replaces@dxos/react-client'suseSpaces./** The named ones, by name. */constnamed =const named: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[]The named ones, by name.
useMemo(useMemo<{readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[]>(factory: () => {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[], deps: React.DependencyList): {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[]useMemowill only recompute the memoized value when one of thedepshas changed.() =>spacesconst spaces: readonly {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[]The spaces that are open, re-rendering on each change.
.filter((ReadonlyArray<{ readonly id: SpaceId; readonly state: "inactive" | "closed" | "ready"; readonly name?: string | undefined; }>.filter(predicate: (value: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}, index: number, array: readonly {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[]) => unknown, thisArg?: any): {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[] (+1 overload)Returns the elements of an array that meet the condition specified in a callback function.
space) =>space: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}space.space: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}name)name?: string | undefined.sort((Array<{ readonly id: SpaceId; readonly state: "inactive" | "closed" | "ready"; readonly name?: string | undefined; }>.sort(compareFn?: ((a: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}, b: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}) => number) | undefined): {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[]Sorts an array in place. This method mutates the array and returns a reference to the same array.
a,a: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}b) => (b: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}a.a: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}name ?? '').name?: string | undefinedlocaleCompare(String.localeCompare(that: string, locales?: Intl.LocalesArgument, options?: Intl.CollatorOptions): number (+2 overloads)Determines whether two strings are equivalent in the current or specified locale.
b.b: {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}name ?? '')),name?: string | undefined[spaces],const spaces: readonly {readonly id: SpaceId;readonly state: "inactive" | "closed" | "ready";readonly name?: string | undefined;}[]The spaces that are open, re-rendering on each change.
);
Try it
Section titled “Try it”Alice has a name and no spaces. Press New space, name it, and make another; the dropdown switches between them.
What you can do now
Section titled “What you can do now”You can create spaces and list them in a component. Next, Invite people lets someone else in.