Merge remote-tracking branch 'origin/release/next-js' into james/transclusion-polish

This commit is contained in:
James Acklin 2021-05-05 10:59:09 -04:00
commit ff49268d6f
78 changed files with 1256 additions and 374 deletions

View File

@ -124,15 +124,15 @@
::
++ poke-noun
|= non=*
?> ?=(%rewatch-dms non)
=/ graphs=(list resource)
~(tap in get-keys:gra)
:- ~
%_ state
watching
%- ~(gas in watching)
(murn graphs |=(rid=resource ?:((should-watch:ha rid) `[rid ~] ~)))
==
[~ state]
:: ?> ?=(%rewatch-dms non)
:: =/ graphs=(list resource)
:: ~(tap in get-keys:gra)
:: %_ state
:: watching
:: %- ~(gas in watching)
:: (murn graphs |=(rid=resource ?:((should-watch:ha rid) `[rid ~] ~)))
:: ==
::
++ hark-graph-hook-action
|= =action:hook
@ -195,13 +195,15 @@
::
?(%remove-graph %archive-graph)
(remove-graph resource.q.update)
::
::
%remove-posts
(remove-posts resource.q.update indices.q.update)
::
::
%add-nodes
=* rid resource.q.update
(check-nodes ~(val by nodes.q.update) rid)
=/ assoc=(unit association:metadata)
(peek-association:met %graph rid)
(check-nodes ~(val by nodes.q.update) rid assoc)
==
:: this is awful, but notification kind should always switch
:: on the index, so hopefully doesn't matter
@ -255,9 +257,11 @@
(get-graph-mop:gra rid)
=/ node=(unit node:graph-store)
(bind (peek:orm:graph-store graph) |=([@ =node:graph-store] node))
=/ assoc=(unit association:metadata)
(peek-association:met %graph rid)
=^ cards state
(check-nodes (drop node) rid)
?. (should-watch:ha rid)
(check-nodes (drop node) rid assoc)
?. (should-watch:ha rid assoc)
[cards state]
:_ state(watching (~(put in watching) [rid ~]))
(weld cards (give:ha ~[/updates] %listen [rid ~]))
@ -265,20 +269,18 @@
++ check-nodes
|= $: nodes=(list node:graph-store)
rid=resource
assoc=(unit association:metadata)
==
=/ group=(unit resource)
(peek-group:met %graph rid)
?~ group
~& no-group+rid
?~ assoc
~& no-assoc+rid
`state
=/ metadatum=(unit metadatum:metadata)
(peek-metadatum:met %graph rid)
?~ metadatum `state
=* group group.u.assoc
=* metadatum metadatum.u.assoc
=/ module=term
?: ?=(%empty -.config.u.metadatum) %$
?: ?=(%group -.config.u.metadatum) %$
module.config.u.metadatum
abet:check:(abed:handle-update:ha rid nodes u.group module)
?: ?=(%empty -.config.metadatum) %$
?: ?=(%group -.config.metadatum) %$
module.config.metadatum
abet:check:(abed:handle-update:ha rid nodes group module)
--
::
++ on-peek on-peek:def
@ -340,12 +342,11 @@
$(contents t.contents)
::
++ should-watch
|= rid=resource
|= [rid=resource assoc=(unit association:metadata)]
^- ?
=/ group-rid=(unit resource)
(peek-group:met %graph rid)
?~ group-rid %.n
?| !(is-managed:grp u.group-rid)
?~ assoc
%.n
?| !(is-managed:grp group.u.assoc)
&(watch-on-self =(our.bowl entity.rid))
==
::
@ -364,7 +365,9 @@
update-core(rid r, updates upds, group grp, module mod)
::
++ get-conversion
(^get-conversion rid)
:: LA: this tube should be cached in %hark-graph-hook state
:: instead of just trying to keep it warm, as the scry overhead is large
~+ (^get-conversion rid)
::
++ abet
^- (quip card _state)
@ -418,7 +421,8 @@
update-core
=* pos p.post.node
=+ !< notif-kind=(unit notif-kind:hook)
(get-conversion !>([0 pos]))
%- get-conversion
!>(`indexed-post:graph-store`[0 pos])
?~ notif-kind
update-core
=/ desc=@t

Binary file not shown.

View File

@ -8,9 +8,10 @@
"@reach/disclosure": "^0.10.5",
"@reach/menu-button": "^0.10.5",
"@reach/tabs": "^0.10.5",
"@react-spring/web": "^9.1.1",
"@tlon/indigo-dark": "^1.0.6",
"@tlon/indigo-light": "^1.0.7",
"@tlon/indigo-react": "^1.2.21",
"@tlon/indigo-react": "^1.2.22",
"@tlon/sigil-js": "^1.4.3",
"@urbit/api": "file:../npm/api",
"any-ascii": "^0.1.7",
@ -38,6 +39,7 @@
"react-markdown": "^4.3.1",
"react-oembed-container": "^1.0.0",
"react-router-dom": "^5.2.0",
"react-use-gesture": "^9.1.3",
"react-virtuoso": "^0.20.3",
"react-visibility-sensor": "^5.1.1",
"remark-breaks": "^2.0.1",

View File

@ -1,7 +1,7 @@
import BaseApi from './base';
import { StoreState } from '../store/type';
import { Patp } from '@urbit/api';
import { ContactEdit } from '@urbit/api/contacts';
import { ContactEditField } from '@urbit/api/contacts';
import _ from 'lodash';
export default class ContactsApi extends BaseApi<StoreState> {
@ -14,7 +14,7 @@ export default class ContactsApi extends BaseApi<StoreState> {
return this.storeAction({ remove: { ship } });
}
edit(ship: Patp, editField: ContactEdit) {
edit(ship: Patp, editField: ContactEditField) {
/* editField can be...
{nickname: ''}
{email: ''}

View File

@ -3,7 +3,7 @@ import { StoreState } from '../store/type';
import { Patp, Path, Resource } from '@urbit/api';
import _ from 'lodash';
import { makeResource, resourceFromPath } from '../lib/group';
import { GroupPolicy, Enc, Post, Content } from '@urbit/api';
import { GroupPolicy, Enc, Post, Content, GraphNode } from '@urbit/api';
import { numToUd, unixToDa, decToUd, deSig, resourceAsPath } from '~/logic/lib/util';
export const createBlankNodeWithChildPost = (
@ -211,7 +211,7 @@ export default class GraphApi extends BaseApi<StoreState> {
return this.addNodes(ship, name, nodes);
}
addNode(ship: Patp, name: string, node: Object) {
addNode(ship: Patp, name: string, node: GraphNode) {
const nodes = {};
nodes[node.post.index] = node;

View File

@ -7,8 +7,4 @@ export default class LocalApi extends BaseApi<StoreState> {
this.store.handleEvent({ data: { baseHash } });
});
}
dehydrate() {
this.store.dehydrate();
}
}

View File

@ -2,11 +2,12 @@ import BaseApi from './base';
import { StoreState } from '../store/type';
import { Key,
Value,
Bucket
Bucket,
SettingsUpdate
} from '@urbit/api/settings';
export default class SettingsApi extends BaseApi<StoreState> {
private storeAction(action: SettingsEvent): Promise<any> {
private storeAction(action: SettingsUpdate): Promise<any> {
return this.action('settings-store', 'settings-event', action);
}
@ -47,14 +48,14 @@ export default class SettingsApi extends BaseApi<StoreState> {
}
async getAll() {
const { all } = await this.scry("settings-store", "/all");
this.store.handleEvent({data:
{"settings-data": { all } }
const { all } = await this.scry('settings-store', '/all');
this.store.handleEvent({ data:
{ 'settings-data': { all } }
});
}
async getBucket(bucket: Key) {
const data = await this.scry('settings-store', `/bucket/${bucket}`);
const data: Record<string, unknown> = await this.scry('settings-store', `/bucket/${bucket}`);
this.store.handleEvent({ data: { 'settings-data': {
'bucket-key': bucket,
'bucket': data.bucket
@ -62,7 +63,7 @@ export default class SettingsApi extends BaseApi<StoreState> {
}
async getEntry(bucket: Key, entry: Key) {
const data = await this.scry('settings-store', `/entry/${bucket}/${entry}`);
const data: Record<string, unknown> = await this.scry('settings-store', `/entry/${bucket}/${entry}`);
this.store.handleEvent({ data: { 'settings-data': {
'bucket-key': bucket,
'entry-key': entry,

View File

@ -1,6 +1,6 @@
import bigInt, { BigInteger } from 'big-integer';
import f from 'lodash/fp';
import { Unreads, NotificationGraphConfig } from '@urbit/api';
import { Unreads, NotificationGraphConfig, IndexedNotification } from '@urbit/api';
export function getLastSeen(
unreads: Unreads,
@ -44,3 +44,16 @@ export function isWatching(
watch => watch.graph === graph && watch.index === index
);
}
export function getNotificationKey(time: BigInteger, notification: IndexedNotification): string {
const base = time.toString();
if('graph' in notification.index) {
const { graph, index } = notification.index.graph;
return `${base}-${graph}-${index}`;
} else if('group' in notification.index) {
const { group } = notification.index.group;
return `${base}-${group}`;
}
return `${base}-unknown`;
}

View File

@ -1,14 +1,13 @@
/* eslint-disable max-lines */
import { useEffect, useState, useCallback, useMemo } from 'react';
import _ from 'lodash';
import { IconRef } from '~/types';
import f, { compose, memoize } from 'lodash/fp';
import bigInt, { BigInteger } from 'big-integer';
import { Association, Contact } from '@urbit/api';
import useLocalState from '../state/local';
import produce, { enableMapSet } from 'immer';
import { enableMapSet } from 'immer';
import useSettingsState from '../state/settings';
import { State, UseStore } from 'zustand';
import { Cage } from '~/types/cage';
import { BaseState } from '../state/base';
import anyAscii from 'any-ascii';
enableMapSet();
@ -24,7 +23,9 @@ export const MOMENT_CALENDAR_DATE = {
sameElse: '~YYYY.M.D'
};
export const getModuleIcon = (mod: string) => {
type GraphModule = 'link' | 'post' | 'chat' | 'publish';
export const getModuleIcon = (mod: GraphModule): IconRef => {
if (mod === 'link') {
return 'Collection';
}
@ -33,7 +34,7 @@ export const getModuleIcon = (mod: string) => {
return 'Dashboard';
}
return _.capitalize(mod);
return _.capitalize(mod) as IconRef;
};
export function wait(ms: number) {
@ -172,9 +173,9 @@ export function dateToDa(d: Date, mil = false) {
);
}
export function deSig(ship: string) {
export function deSig(ship: string): string {
if (!ship) {
return null;
return '';
}
return ship.replace('~', '');
}
@ -226,11 +227,11 @@ export function writeText(str: string) {
}
// trim patps to match dojo, chat-cli
export function cite(ship: string) {
export function cite(ship: string): string {
let patp = ship,
shortened = '';
if (patp === null || patp === '') {
return null;
return '';
}
if (patp.startsWith('~')) {
patp = patp.substr(1);
@ -425,7 +426,7 @@ export const useHovering = (): useHoveringInterface => {
};
const DM_REGEX = /ship\/~([a-z]|-)*\/dm--/;
export function getItemTitle(association: Association) {
export function getItemTitle(association: Association): string {
if (DM_REGEX.test(association.resource)) {
const [, , ship, name] = association.resource.split('/');
if (ship.slice(1) === window.ship) {
@ -433,6 +434,6 @@ export function getItemTitle(association: Association) {
}
return cite(ship);
}
return association.metadata.title || association.resource;
return association.metadata.title ?? association.resource ?? '';
}

View File

@ -1,5 +1,4 @@
import React from "react";
import { ReactElement } from "react";
import { UseStore } from "zustand";
import { BaseState } from "../state/base";

View File

@ -18,10 +18,10 @@ export function getTitleFromWorkspace(
export function getGroupFromWorkspace(
workspace: Workspace
): string | undefined {
): string {
if (workspace.type === 'group') {
return workspace.group;
}
return undefined;
return '';
}

View File

@ -45,16 +45,15 @@ export interface BaseState<StateType> extends State {
set: (fn: (state: StateType) => void) => void;
}
export const createState = <StateType extends BaseState<any>>(
export const createState = <T extends {}>(
name: string,
properties: Omit<StateType, 'set'>,
properties: T,
blacklist: string[] = []
): UseStore<StateType> => create(persist((set, get) => ({
// TODO why does this typing break?
): UseStore<T & BaseState<T>> => create(persist((set, get) => ({
set: fn => stateSetter(fn, set),
...properties
}), {
blacklist,
name: stateStorageKey(name),
version: process.env.LANDSCAPE_SHORTHASH
version: process.env.LANDSCAPE_SHORTHASH as any
}));

View File

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React from 'react';
import f from 'lodash/fp';
import create, { State } from 'zustand';
import { persist } from 'zustand/middleware';
@ -7,7 +7,7 @@ import { BackgroundConfig, RemoteContentPolicy, TutorialProgress, tutorialProgre
export interface LocalState {
theme: "light" | "dark" | "auto";
theme: 'light' | 'dark' | 'auto';
hideAvatars: boolean;
hideNicknames: boolean;
remoteContentPolicy: RemoteContentPolicy;
@ -21,6 +21,7 @@ export interface LocalState {
hideLeapCats: LeapCategories[];
setTutorialRef: (el: HTMLElement | null) => void;
dark: boolean;
mobile: boolean;
background: BackgroundConfig;
omniboxShown: boolean;
suspendedFocus?: HTMLElement;
@ -35,8 +36,9 @@ export const selectLocalState =
const useLocalState = create<LocalStateZus>(persist((set, get) => ({
dark: false,
mobile: false,
background: undefined,
theme: "auto",
theme: 'auto',
hideAvatars: false,
hideNicknames: false,
hideLeapCats: [],

View File

@ -1,6 +1,6 @@
export const tutorialProgress = ['hidden', 'start', 'group-desc', 'channels', 'chat', 'link', 'publish', 'profile', 'leap', 'notifications', 'done', 'exit'] as const;
export const leapCategories = ["mychannel", "messages", "updates", "profile", "logout"] as const;
export const leapCategories = ["mychannel", "messages", "updates", "profile", "logout"];
export type LeapCategories = typeof leapCategories[number];

View File

@ -93,16 +93,21 @@ class App extends React.Component {
new GlobalSubscription(this.store, this.api, this.appChannel);
this.updateTheme = this.updateTheme.bind(this);
this.updateMobile = this.updateMobile.bind(this);
this.faviconString = this.faviconString.bind(this);
}
componentDidMount() {
this.subscription.start();
const theme = this.getTheme();
this.themeWatcher = window.matchMedia('(prefers-color-scheme: dark)');
this.mobileWatcher = window.matchMedia(`(max-width: ${theme.breakpoints[0]})`);
this.themeWatcher.onchange = this.updateTheme;
this.mobileWatcher.onchange = this.updateMobile;
setTimeout(() => {
// Something about how the store works doesn't like changing it
// before the app has actually rendered, hence the timeout.
this.updateMobile(this.mobileWatcher);
this.updateTheme(this.themeWatcher);
}, 500);
this.api.local.getBaseHash();
@ -117,6 +122,7 @@ class App extends React.Component {
componentWillUnmount() {
this.themeWatcher.onchange = undefined;
this.mobileWatcher.onchange = undefined;
}
updateTheme(e) {
@ -125,6 +131,12 @@ class App extends React.Component {
});
}
updateMobile(e) {
this.props.set(state => {
state.mobile = e.matches;
});
}
faviconString() {
let background = '#ffffff';
if (this.props.contacts.hasOwnProperty(`~${window.ship}`)) {
@ -141,12 +153,16 @@ class App extends React.Component {
return dataurl;
}
render() {
const { state, props } = this;
const theme =
((props.dark && props?.display?.theme == "auto") ||
getTheme() {
const { props } = this;
return ((props.dark && props?.display?.theme == "auto") ||
props?.display?.theme == "dark"
) ? dark : light;
}
render() {
const { state } = this;
const theme = this.getTheme();
const ourContact = this.props.contacts[`~${this.ship}`] || null;
return (

View File

@ -6,8 +6,6 @@ import { Sigil } from '~/logic/lib/sigil';
import { createPost } from '~/logic/api/graph';
import tokenizeMessage, { isUrl } from '~/logic/lib/tokenizeMessage';
import GlobalApi from '~/logic/api/global';
import { Envelope } from '~/types/chat-update';
import { StorageState } from '~/types';
import { Contact, Contacts, Content, Post } from '@urbit/api';
import { Row, BaseImage, Box, Icon, LoadingSpinner } from '@tlon/indigo-react';
import withStorage from '~/views/components/withStorage';

View File

@ -3,44 +3,23 @@ import bigInt from 'big-integer';
import React, {
useState,
useEffect,
useMemo,
useRef,
Component,
PureComponent,
useCallback
useMemo
} from 'react';
import moment from 'moment';
import _ from 'lodash';
import VisibilitySensor from 'react-visibility-sensor';
import { Box, Row, Text, Rule, BaseImage, Icon, Col } from '@tlon/indigo-react';
import { Sigil } from '~/logic/lib/sigil';
import OverlaySigil from '~/views/components/OverlaySigil';
import {
uxToHex,
cite,
writeText,
useShowNickname,
useHideAvatar,
useHovering,
daToUnix
} from '~/logic/lib/util';
import {
Group,
Association,
Contacts,
Post,
Groups,
Associations
} from '~/types';
import TextContent from '../../../landscape/components/Graph/content/text';
import CodeContent from '../../../landscape/components/Graph/content/code';
import RemoteContent from '~/views/components/RemoteContent';
import { Mention } from '~/views/components/MentionText';
import { Post } from '@urbit/api';
import { Dropdown } from '~/views/components/Dropdown';
import styled from 'styled-components';
import useLocalState from '~/logic/state/local';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
import Timestamp from '~/views/components/Timestamp';
import useContactState, {useContact} from '~/logic/state/contact';
import { useIdlingState } from '~/logic/lib/idling';
import ProfileOverlay from '~/views/components/ProfileOverlay';

View File

@ -48,7 +48,7 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
return isWriter(group, association.resource);
}
renderItem = React.forwardRef(({ index, scrollWindow }, ref) => {
renderItem = React.forwardRef<HTMLDivElement>(({ index, scrollWindow }, ref) => {
const { props } = this;
const { association, graph, api } = props;
const [, , ship, name] = association.resource.split("/");

View File

@ -180,7 +180,6 @@ function getNodeUrl(
const graphUrl = `/~landscape${groupPath}/resource/${mod}${graph}`;
const idx = index.slice(1).split("/");
if (mod === "publish") {
console.log(idx);
const [noteId, kind, commId] = idx;
const selected = kind === "2" ? `?selected=${commId}` : "";
return `${graphUrl}/note/${noteId}${selected}`;

View File

@ -26,6 +26,7 @@ import { useLazyScroll } from '~/logic/lib/useLazyScroll';
import useHarkState from '~/logic/state/hark';
import useInviteState from '~/logic/state/invite';
import useMetadataState from '~/logic/state/metadata';
import {getNotificationKey} from '~/logic/lib/hark';
type DatedTimebox = [BigInteger, Timebox];
@ -121,13 +122,14 @@ export default function Inbox(props: {
);
return (
<Col p="1" ref={scrollRef} position="relative" height="100%" overflowY="auto">
<Col p="1" ref={scrollRef} position="relative" height="100%" overflowY="auto" overflowX="hidden">
<Invites pendingJoin={props.pendingJoin} api={api} />
{[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => {
const timeboxes = notificationsByDayMap.get(day)!;
return timeboxes.length > 0 && (
<DaySection
key={day}
time={day}
label={day === 'latest' ? 'Today' : moment(day).calendar(null, calendar)}
timeboxes={timeboxes}
archive={Boolean(props.showArchive)}
@ -166,6 +168,7 @@ function DaySection({
label,
archive,
timeboxes,
time,
api,
}) {
const lent = timeboxes.map(([,nots]) => nots.length).reduce(f.add, 0);
@ -178,7 +181,7 @@ function DaySection({
{_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i: number) =>
_.map(nots.sort(sortIndexedNotification), (not, j: number) => (
<Notification
key={j}
key={getNotificationKey(time, not)}
api={api}
notification={not}
archived={archive}

View File

@ -1,5 +1,5 @@
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import { Row, Box, Icon } from "@tlon/indigo-react";
import { Row, Box, Icon, Button } from "@tlon/indigo-react";
import _ from "lodash";
import {
GraphNotificationContents,
@ -19,7 +19,13 @@ import { GraphNotification } from "./graph";
import { BigInteger } from "big-integer";
import { useHovering } from "~/logic/lib/util";
import useHarkState from "~/logic/state/hark";
import {IS_MOBILE} from "~/logic/lib/platform";
import useLocalState from "~/logic/state/local";
import { IS_MOBILE } from "~/logic/lib/platform";
import styled from "styled-components";
import { useSpring, animated } from "@react-spring/web";
import { useDrag } from "react-use-gesture";
import { SwipeMenu } from "~/views/components/SwipeMenu";
import {getNotificationKey} from "~/logic/lib/hark";
interface NotificationProps {
notification: IndexedNotification;
@ -62,6 +68,8 @@ export function NotificationWrapper(props: {
}) {
const { api, time, notification, children } = props;
const isMobile = useLocalState(s => s.mobile);
const onArchive = useCallback(async () => {
if (!(time && notification)) {
return;
@ -83,7 +91,7 @@ export function NotificationWrapper(props: {
return api.hark[func](notification);
}, [notification, api, isMuted]);
const onClick = () => {
const onClick = (e: any) => {
if (!(time && notification) || notification.notification.read) {
return;
}
@ -92,44 +100,54 @@ export function NotificationWrapper(props: {
const { hovering, bind } = useHovering();
const changeMuteDesc = isMuted ? "Unmute" : "Mute";
return (
<Box
onClick={onClick}
bg={
(notification ? notification?.notification?.read : false)
? "washedGray"
: "washedBlue"
}
borderRadius={2}
display="grid"
gridTemplateColumns={["1fr 24px", "1fr 200px"]}
gridTemplateRows="auto"
gridTemplateAreas="'header actions' 'main main'"
p={2}
<SwipeMenu
key={(time && notification && getNotificationKey(time, notification)) ?? 'unknown'}
m={2}
{...bind}
menuWidth={100}
disabled={!isMobile}
menu={
<Button onClick={onArchive} ml="2" height="100%" width="92px" primary destructive>
Remove
</Button>
}
>
{children}
<Row
alignItems="flex-start"
gapX="2"
gridArea="actions"
justifyContent="flex-end"
opacity={[1, (hovering || IS_MOBILE) ? 1 : 0]}
<Box
onClick={onClick}
bg={
(notification ? notification?.notification?.read : false)
? "washedGray"
: "washedBlue"
}
borderRadius={2}
display="grid"
gridTemplateColumns={["1fr 24px", "1fr 200px"]}
gridTemplateRows="auto"
gridTemplateAreas="'header actions' 'main main'"
p={2}
{...bind}
>
{time && notification && (
<StatelessAsyncAction
name={time.toString()}
borderRadius={1}
onClick={onArchive}
backgroundColor="white"
>
<Icon lineHeight="24px" size={16} icon="X" />
</StatelessAsyncAction>
)}
</Row>
</Box>
{children}
<Row
alignItems="flex-start"
gapX="2"
gridArea="actions"
justifyContent="flex-end"
opacity={[0, hovering ? 1 : 0]}
>
{time && notification && (
<StatelessAsyncAction
name={time.toString()}
borderRadius={1}
onClick={onArchive}
backgroundColor="white"
>
<Icon lineHeight="24px" size={16} icon="X" />
</StatelessAsyncAction>
)}
</Row>
</Box>
</SwipeMenu>
);
}

View File

@ -30,7 +30,7 @@ export function ProfileImages(props: any): ReactElement {
const { contact, hideCover, ship } = { ...props };
const hexColor = contact?.color ? `#${uxToHex(contact.color)}` : '#000000';
const anchorRef = useRef<HTMLElement | null>(null)
const anchorRef = useRef<HTMLDivElement>(null)
useTutorialModal('profile', ship === `~${window.ship}`, anchorRef);

View File

@ -41,8 +41,8 @@ const DropdownOptions = styled(Box)`
export function Dropdown(props: DropdownProps): ReactElement {
const { children, options, offsetX = 0, offsetY = 0, flexShrink = 1 } = props;
const dropdownRef = useRef<HTMLElement>(null);
const anchorRef = useRef<HTMLElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const anchorRef = useRef<HTMLDivElement>(null);
const { pathname } = useLocation();
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({});

View File

@ -45,7 +45,7 @@ type DropdownSearchProps<C> = PropFunc<typeof Box> &
DropdownSearchExtraProps<C>;
export function DropdownSearch<C>(props: DropdownSearchProps<C>): ReactElement {
const textarea = useRef<HTMLTextAreaElement>();
const textarea = useRef<HTMLTextAreaElement>(null);
const {
candidates,
getKey,

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { RefObject } from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { Box } from '@tlon/indigo-react';
@ -21,11 +21,11 @@ interface HoverBoxLinkProps {
to: string;
}
export const HoverBoxLink = React.forwardRef(({
export const HoverBoxLink = React.forwardRef<HTMLAnchorElement, HoverBoxLinkProps & PropFunc<typeof HoverBox>>(({
to,
children,
...rest
}: HoverBoxLinkProps & PropFunc<typeof HoverBox>, ref) => (
}, ref) => (
<Link ref={ref} to={to}>
<HoverBox {...rest}>{children}</HoverBox>
</Link>

View File

@ -24,7 +24,7 @@ const prompt = (field, uploading, meta, clickUploadButton) => {
if (!field.value && !uploading && meta.error === undefined) {
return (
<Text
black
color='black'
fontWeight='500'
position='absolute'
left={2}

View File

@ -3,7 +3,7 @@ import { Box } from '@tlon/indigo-react';
import { PropFunc } from '~/types/util';
interface ModalOverlayProps {
spacing: PropFunc<typeof Box>['m'];
spacing?: PropFunc<typeof Box>['m'];
dismiss: () => void;
}
type Props = ModalOverlayProps & PropFunc<typeof Box>;

View File

@ -55,8 +55,8 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
const [coords, setCoords] = useState({});
const [visible, setVisible] = useState(false);
const history = useHistory();
const outerRef = useRef<HTMLElement | null>(null);
const innerRef = useRef<HTMLElement | null>(null);
const outerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const hideAvatars = useSettingsState(state => state.calm.hideAvatars);
const hideNicknames = useSettingsState(state => state.calm.hideNicknames);
const isOwn = useMemo(() => window.ship === ship, [ship]);

View File

@ -5,7 +5,7 @@ import { LoadingSpinner, Action } from '@tlon/indigo-react';
interface AsyncActionProps {
children: ReactNode;
name: string;
name?: string;
disabled?: boolean;
onClick: (e: React.MouseEvent) => Promise<void>;
}

View File

@ -24,11 +24,11 @@ export function StatelessAsyncToggle({
} = useStatelessAsyncClickable(onClick, name);
return state === 'error' ? (
<Text mr="2">Error</Text>
<Text>Error</Text>
) : state === 'loading' ? (
<LoadingSpinner mr="2" foreground={'white'} background="gray" />
<LoadingSpinner foreground={'white'} background="gray" />
) : state === 'success' ? (
<Text mr="2">Done</Text>
<Text mx="2">Done</Text>
) : (
<Toggle onClick={handleClick} {...rest} />
);

View File

@ -121,13 +121,7 @@ const StatusBar = (props) => {
)
}
>
<Text color='#000000'>
Submit{' '}
<Text color='#000000' display={['none', 'inline']}>
an
</Text>{' '}
issue
</Text>
<Icon icon="Bug" color="#000000" />
</StatusBarItem>
<StatusBarItem
width='32px'

View File

@ -0,0 +1,93 @@
import React, { useMemo, useState, ReactNode, ReactChildren } from "react";
import { animated, useSpring } from "@react-spring/web";
import { useDrag } from "react-use-gesture";
import { Box, Row } from "@tlon/indigo-react";
import styled from "styled-components";
import { PropFunc } from "~/types";
const DEFAULT_THRESHOLD = 10;
const AnimBox = styled(animated(Box))`
touch-action: pan-y;
`;
const AnimRow = styled(animated(Row))`
touch-action: pan-y;
`;
const NoScrollBox = styled(Box)`
touch-action: pan-y;
`;
export function SwipeMenu(
props: {
children: ReactNode;
disabled?: boolean;
menu: ReactNode;
menuWidth: number;
threshold?: number;
} & PropFunc<typeof Box>
) {
const [open, setOpen] = useState(false);
const [dragging, setDragging] = useState(false);
const {
children,
disabled = false,
menu,
menuWidth,
threshold = DEFAULT_THRESHOLD,
...rest
} = props;
const [{ x, opacity }, springApi] = useSpring(() => ({
x: 0,
opacity: 0,
config: {
tension: 240,
friction: 30
}
}));
const activationDistance = threshold - menuWidth;
const sliderBind = useDrag(
({ active, movement: [x], tap }) => {
if (dragging !== active) {
setDragging(active);
}
if (active && x < activationDistance) {
setOpen(true);
} else if (active && x > -1 * threshold) {
setOpen(false);
}
return springApi.start({
x: active ? Math.min(0, x) : open ? -1 * menuWidth : 0,
opacity: open
? 1
: active
? Math.abs(Math.min(1, Math.min(0, x) / activationDistance))
: 0,
});
},
{
enabled: !disabled,
}
);
return (
<NoScrollBox {...rest} position="relative">
<AnimBox {...sliderBind()}>
<AnimBox style={{ x }}>{children}</AnimBox>
</AnimBox>
<AnimRow
top="0px"
position="absolute"
zIndex={1}
height="100%"
right="0px"
style={{
translateX: x.to((x) => x + menuWidth),
opacity,
}}
>
{menu}
</AnimRow>
</NoScrollBox>
);
}

View File

@ -1,7 +1,7 @@
import React from 'react';
import _ from 'lodash';
import { Box } from '@tlon/indigo-react';
import { PropFunc } from '@urbit/api';
import { PropFunc } from '~/types';
export type Direction = 'East' | 'South' | 'West' | 'North';
type TriangleProps = PropFunc<typeof Box> & {

View File

@ -1,6 +1,6 @@
import { useEffect, MutableRefObject } from "react";
import { TutorialProgress } from "@urbit/api";
import useLocalState, { selectLocalState } from "~/logic/state/local";
import { useEffect, MutableRefObject } from 'react';
import { TutorialProgress } from '~/types';
import useLocalState, { selectLocalState } from '~/logic/state/local';
const localSelector = selectLocalState(['tutorialProgress', 'setTutorialRef']);
@ -16,7 +16,7 @@ export function useTutorialModal(
setTutorialRef(anchorRef.current);
}
return () => {}
return () => {};
}, [tutorialProgress, show, anchorRef]);
return show && onProgress === tutorialProgress;

View File

@ -1,7 +1,10 @@
import React from 'react';
import useStorage, {IuseStorage} from '~/logic/lib/useStorage';
import useStorage, { IuseStorage } from '~/logic/lib/useStorage';
const withStorage = <P, C extends React.ComponentType<P>>(Component: C, params = {}) => {
const withStorage = <P, C extends React.ComponentType<P & IuseStorage>>(
Component: C,
params = {}
) => {
return React.forwardRef<C, Omit<C, keyof IuseStorage>>((props, ref) => {
const storage = useStorage(params);

View File

@ -1,15 +1,15 @@
import React from "react";
import { Post, ReferenceContent } from "@urbit/api";
import { Box } from "@tlon/indigo-react";
import React from 'react';
import { Content, Post, ReferenceContent } from '@urbit/api';
import { Box } from '@tlon/indigo-react';
import GlobalApi from "~/logic/api/global";
import TextContent from "./content/text";
import CodeContent from "./content/code";
import RemoteContent from "~/views/components/RemoteContent";
import { Mention } from "~/views/components/MentionText";
import { PermalinkEmbed } from "~/views/apps/permalinks/embed";
import { referenceToPermalink } from "~/logic/lib/permalinks";
import { PropFunc } from "~/types";
import GlobalApi from '~/logic/api/global';
import TextContent from './content/text';
import CodeContent from './content/code';
import RemoteContent from '~/views/components/RemoteContent';
import { Mention } from '~/views/components/MentionText';
import { PermalinkEmbed } from '~/views/apps/permalinks/embed';
import { referenceToPermalink } from '~/logic/lib/permalinks';
import { PropFunc } from '~/types';
function GraphContentWideInner(
props: {
@ -23,60 +23,55 @@ function GraphContentWideInner(
return (
<Box {...rest}>
{post.contents.map((content, i) => {
switch (Object.keys(content)[0]) {
case "text":
return (
<TextContent
key={i}
api={api}
fontSize={1}
lineHeight={"20px"}
content={content}
/>
);
case "code":
return <CodeContent key={i} content={content} />;
case "reference":
const { link } = referenceToPermalink(content as ReferenceContent);
return (
<PermalinkEmbed
link={link}
api={api}
transcluded={transcluded}
showOurContact={showOurContact}
/>
);
case "url":
return (
<Box
key={i}
flexShrink={0}
fontSize={1}
lineHeight="20px"
color="black"
width="fit-content"
maxWidth="min(500px, 100%)"
>
<RemoteContent
key={content.url}
url={content.url}
transcluded={transcluded}
/>
</Box>
);
case "mention":
const first = (i) => i === 0;
return (
<Mention
key={i}
first={first(i)}
ship={content.mention}
api={api}
/>
);
default:
return null;
{post.contents.map((content: Content, i) => {
if ('text' in content) {
return (
<TextContent
key={i}
api={api}
fontSize={1}
lineHeight={'20px'}
content={content}
/>
);
} else if ('code' in content) {
return <CodeContent key={i} content={content} />;
} else if ('reference' in content) {
const { link } = referenceToPermalink(content as ReferenceContent);
return (
<PermalinkEmbed
link={link}
api={api}
transcluded={transcluded}
showOurContact={showOurContact}
/>
);
} else if ('url' in content) {
return (
<Box
key={i}
flexShrink={0}
fontSize={1}
lineHeight="20px"
color="black"
width="fit-content"
maxWidth="min(500px, 100%)"
>
<RemoteContent
key={content.url}
url={content.url}
transcluded={transcluded}
/>
</Box>
);
} else if ('mention' in content) {
const first = i => i === 0;
return (<Mention
key={i}
first={first(i)}
ship={content.mention}
api={api}
/>);
}
})}
</Box>

View File

@ -1,5 +1,6 @@
import React, { ReactElement, ReactNode, useRef } from 'react';
import { Metadata, PropFunc } from '@urbit/api';
import { Metadata } from '@urbit/api';
import { PropFunc } from '~/types';
import { Col, Row, Text } from '@tlon/indigo-react';
import { MetadataIcon } from './MetadataIcon';
import { useTutorialModal } from '~/views/components/useTutorialModal';

View File

@ -31,7 +31,7 @@ const GroupSwitcherItem = ({ to, children, bottom = false, ...rest }) => (
</Link>
);
function RecentGroups(props: { recent: string[]; associations: Associations }) {
function RecentGroups(props: { recent: string[] }) {
const { recent } = props;
if (recent.length < 2) {
return null;

View File

@ -1,10 +1,9 @@
import React, { useEffect, ReactNode } from 'react';
import React, { useEffect } from 'react';
import {
Switch,
Route,
RouteComponentProps
} from 'react-router-dom';
import { Col, Box, Text } from '@tlon/indigo-react';
import _ from 'lodash';
import Helmet from 'react-helmet';
@ -28,7 +27,6 @@ import { getGroupFromWorkspace } from '~/logic/lib/workspace';
import { GroupHome } from './Home/GroupHome';
import { EmptyGroupHome } from './Home/EmptyGroupHome';
import { Workspace } from '~/types/workspace';
import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group';
import useHarkState from '~/logic/state/hark';
import useMetadataState from '~/logic/state/metadata';
@ -42,17 +40,12 @@ type GroupsPaneProps = StoreState & {
export function GroupsPane(props: GroupsPaneProps) {
const { baseUrl, api, workspace } = props;
const associations = useMetadataState(state => state.associations);
const contacts = useContactState(state => state.contacts);
const notificationsCount = useHarkState(state => state.notificationsCount);
const relativePath = (path: string) => baseUrl + path;
const groupPath = getGroupFromWorkspace(workspace);
const groups = useGroupState(state => state.groups);
const groupContacts = Object.assign({}, ...Array.from(groups?.[groupPath]?.members ?? []).filter(e => contacts[`~${e}`]).map(e => {
return {[e]: contacts[`~${e}`]};
})) || {};
const rootIdentity = contacts?.["/~/default"]?.[window.ship];
const groupAssociation =
(groupPath && associations.groups[groupPath]) || undefined;
const group = (groupPath && groups[groupPath]) || undefined;
@ -75,8 +68,6 @@ export function GroupsPane(props: GroupsPaneProps) {
const popovers = (routeProps: RouteComponentProps, baseUrl: string) =>
( <>
{groupPath && ( <PopoverRoutes
contacts={groupContacts || {}}
rootIdentity={rootIdentity}
association={groupAssociation!}
group={group!}
api={api}
@ -202,12 +193,13 @@ export function GroupsPane(props: GroupsPaneProps) {
</title>
</Helmet>
<Skeleton
{...props}
mobileHide={shouldHideSidebar}
recentGroups={recentGroups}
baseUrl={baseUrl}
{...props}>
>
{ workspace.type === 'group' ? (
<GroupHome
<GroupHome
api={api}
baseUrl={baseUrl}
groupPath={groupPath}

View File

@ -1,27 +1,24 @@
import React, { useCallback } from "react";
import { ModalOverlay } from "~/views/components/ModalOverlay";
import { Formik, Form, FormikHelpers } from "formik";
import React, { useCallback } from 'react';
import { ModalOverlay } from '~/views/components/ModalOverlay';
import { Formik, Form, FormikHelpers } from 'formik';
import {
GroupFeedPermissions,
GroupFeedPermsInput,
} from "./Post/GroupFeedPerms";
import { Text, Button, Col, Row } from "@tlon/indigo-react";
import { AsyncButton } from "~/views/components/AsyncButton";
import GlobalApi from "~/logic/api/global";
import { resourceFromPath, Tag, resourceAsPath } from "@urbit/api";
import useGroupState, { useGroup } from "~/logic/state/group";
GroupFeedPermsInput
} from './Post/GroupFeedPerms';
import { Text, Button, Col, Row } from '@tlon/indigo-react';
import { AsyncButton } from '~/views/components/AsyncButton';
import GlobalApi from '~/logic/api/global';
import { resourceFromPath, Tag, resourceAsPath } from '@urbit/api';
import { useHistory } from 'react-router-dom';
import useMetadataState from "~/logic/state/metadata";
interface FormSchema {
permissions: GroupFeedPermissions;
permissions: any;
}
export function EnableGroupFeed(props: {
groupPath: string;
dismiss: () => void;
api: GlobalApi;
baseUrl: string;
}) {
const { api, groupPath, baseUrl } = props;
@ -31,9 +28,9 @@ export function EnableGroupFeed(props: {
};
const initialValues: FormSchema = {
permissions: "everyone",
permissions: 'everyone'
};
const onSubmit =
const onSubmit =
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
const resource = resourceFromPath(groupPath);
const feed = resourceAsPath(

View File

@ -44,6 +44,7 @@ interface NewChannelProps {
api: GlobalApi;
group?: string;
workspace: Workspace;
baseUrl?: string;
}
export function NewChannel(props: NewChannelProps): ReactElement {
@ -118,7 +119,7 @@ export function NewChannel(props: NewChannelProps): ReactElement {
<Box
pb='3'
display={workspace?.type === 'messages' ? 'none' : ['block', 'none']}
onClick={() => history.push(props.baseUrl)}
onClick={() => history.push(props?.baseUrl ?? '/')}
>
<Text>{'<- Back'}</Text>
</Box>

View File

@ -2,7 +2,8 @@ import React, {
useState,
useMemo,
useCallback,
ChangeEvent
ChangeEvent,
ReactElement
} from 'react';
import {
Col,
@ -30,10 +31,8 @@ import { roleForShip, resourceFromPath } from '~/logic/lib/group';
import { Dropdown } from '~/views/components/Dropdown';
import GlobalApi from '~/logic/api/global';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import useLocalState from '~/logic/state/local';
import useContactState from '~/logic/state/contact';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
import {deSig} from '@urbit/api';
const TruncText = styled(Text)`
white-space: nowrap;

View File

@ -2,8 +2,6 @@ import React, { useRef, useCallback, ReactElement } from 'react';
import { Route, Switch, RouteComponentProps, Link } from 'react-router-dom';
import { Box, Col, Text } from '@tlon/indigo-react';
import { GroupNotificationsConfig, Associations } from '@urbit/api';
import { Contacts, Contact } from '@urbit/api/contacts';
import { Group } from '@urbit/api/groups';
import { Association } from '@urbit/api/metadata';
@ -15,7 +13,6 @@ import { DeleteGroup } from './DeleteGroup';
import { resourceFromPath } from '~/logic/lib/group';
import { ModalOverlay } from '~/views/components/ModalOverlay';
import { SidebarItem } from '~/views/landscape/components/SidebarItem';
import { StorageState } from '~/types';
export function PopoverRoutes(
props: {
@ -23,8 +20,6 @@ export function PopoverRoutes(
group: Group;
association: Association;
api: GlobalApi;
notificationsGroupConfig: GroupNotificationsConfig;
rootIdentity: Contact;
} & RouteComponentProps
): ReactElement {
const relativeUrl = (url: string) => `${props.baseUrl}/popover${url}`;

View File

@ -15,7 +15,7 @@ import useGroupState from '~/logic/state/group';
import useContactState from '~/logic/state/contact';
import useHarkState from '~/logic/state/hark';
import useMetadataState from '~/logic/state/metadata';
import {Workspace} from '~/types';
import { Workspace } from '~/types';
type ResourceProps = StoreState & {
association: Association;
@ -25,23 +25,20 @@ type ResourceProps = StoreState & {
};
export function Resource(props: ResourceProps): ReactElement {
const { association, api, notificationsGraphConfig } = props;
const { association, api } = props;
const groups = useGroupState(state => state.groups);
const notificationsCount = useHarkState(state => state.notificationsCount);
const associations = useMetadataState(state => state.associations);
const contacts = useContactState(state => state.contacts);
const app = association.metadata?.config?.graph || association['app-name'];
const rid = association.resource;
const selectedGroup = association.group;
const relativePath = (p: string) =>
`${props.baseUrl}/resource/${app}${rid}${p}`;
const { resource: rid, group: selectedGroup } = association;
const relativePath = (p: string) => `${props.baseUrl}/resource/${app}${rid}${p}`;
const skelProps = { api, association, groups, contacts };
let title = props.association.metadata.title;
if ('workspace' in props) {
if ('group' in props.workspace && props.workspace.group in associations.groups) {
if ('group' in props.workspace && props.workspace.group in associations.groups) {
title = `${associations.groups[props.workspace.group].metadata.title} - ${props.association.metadata.title}`;
}
}
return (
<>
<Helmet defer={false}>

View File

@ -47,7 +47,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
? getItemTitle(association)
: association?.metadata?.title;
let recipient = "";
let recipient = '';
const contacts = useContactState(state => state.contacts);

View File

@ -6,13 +6,8 @@ import {
import GlobalApi from '~/logic/api/global';
import { GroupSwitcher } from '../GroupSwitcher';
import {
Associations,
Workspace,
Groups,
Invites,
Rolodex
} from '@urbit/api';
import { Workspace } from '~/types';
import { SidebarListConfig } from './types';
import { SidebarListHeader } from './SidebarListHeader';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import { getGroupFromWorkspace } from '~/logic/lib/workspace';
@ -21,7 +16,6 @@ import { SidebarList } from './SidebarList';
import { roleForShip } from '~/logic/lib/group';
import { useTutorialModal } from '~/views/components/useTutorialModal';
import useGroupState from '~/logic/state/group';
import useMetadataState from '~/logic/state/metadata';
const ScrollbarLessCol = styled(Col)`
scrollbar-width: none !important;
@ -32,7 +26,6 @@ const ScrollbarLessCol = styled(Col)`
`;
interface SidebarProps {
children: ReactNode;
recentGroups: string[];
api: GlobalApi;
selected?: string;
@ -61,7 +54,7 @@ export function Sidebar(props: SidebarProps): ReactElement | null {
const role = groups?.[groupPath] ? roleForShip(groups[groupPath], window.ship) : undefined;
const isAdmin = (role === 'admin') || (workspace?.type === 'home');
const anchorRef = useRef<HTMLElement | null>(null);
const anchorRef = useRef<HTMLDivElement>(null);
useTutorialModal('channels', true, anchorRef);
return (
@ -91,7 +84,6 @@ export function Sidebar(props: SidebarProps): ReactElement | null {
selected={selected || ''}
workspace={workspace}
api={props.api}
history={props.history}
/>
<SidebarList
config={config}

View File

@ -2,36 +2,20 @@ import React, { ReactElement, useRef } from 'react';
import urbitOb from 'urbit-ob';
import { Icon, Row, Box, Text, BaseImage } from '@tlon/indigo-react';
import { Groups, Association, Rolodex } from '@urbit/api';
import { Association } from '@urbit/api';
import { HoverBoxLink } from '~/views/components/HoverBox';
import { Sigil } from '~/logic/lib/sigil';
import { getModuleIcon, getItemTitle, uxToHex } from '~/logic/lib/util';
import { useTutorialModal } from '~/views/components/useTutorialModal';
import { TUTORIAL_HOST, TUTORIAL_GROUP } from '~/logic/lib/tutorialModal';
import { SidebarAppConfigs, SidebarItemStatus } from './types';
import { SidebarAppConfigs } from './types';
import { Workspace } from '~/types/workspace';
import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
import Dot from '~/views/components/Dot';
function SidebarItemIndicator(props: { status?: SidebarItemStatus }) {
switch (props.status) {
case 'disconnected':
return <Icon ml={2} fill="red" icon="X" />;
case 'unsubscribed':
return <Icon ml={2} icon="Circle" fill="gray" />;
case 'mention':
return <Icon ml={2} icon="Circle" />;
case 'loading':
return <Icon ml={2} icon="Bullet" />;
default:
return null;
}
}
// eslint-disable-next-line max-lines-per-function
export function SidebarItem(props: {
hideUnjoined: boolean;
@ -48,7 +32,7 @@ export function SidebarItem(props: {
const rid = association?.resource;
const groupPath = association?.group;
const groups = useGroupState(state => state.groups);
const anchorRef = useRef<HTMLElement | null>(null);
const anchorRef = useRef<HTMLAnchorElement>(null);
const { hideAvatars, hideNicknames } = useSettingsState(selectCalmState);
const contacts = useContactState(state => state.contacts);
useTutorialModal(
@ -99,11 +83,11 @@ export function SidebarItem(props: {
return null;
}
let img = null;
let img: null | JSX.Element = null;
if (urbitOb.isValidPatp(title)) {
if (contacts?.[title]?.avatar && !hideAvatars) {
img = <BaseImage referrerPolicy="no-referrer" src={contacts[title].avatar} width='16px' height='16px' borderRadius={2} />;
img = <BaseImage referrerPolicy="no-referrer" src={contacts?.[title].avatar ?? ''} width='16px' height='16px' borderRadius={2} />;
} else {
img = <Sigil ship={title} color={`#${uxToHex(contacts?.[title]?.color || '0x0')}`} icon padding={2} size={16} />;
}
@ -137,7 +121,7 @@ export function SidebarItem(props: {
<Icon
display="block"
color={isSynced ? 'black' : 'lightGray'}
icon={getModuleIcon(mod) as any}
icon={getModuleIcon(mod)}
/>
)
}

View File

@ -15,7 +15,7 @@ import { Groups, Rolodex, Associations } from '@urbit/api';
import { FormikOnBlur } from '~/views/components/FormikOnBlur';
import { Dropdown } from '~/views/components/Dropdown';
import { SidebarListConfig } from './types';
import { SidebarListConfig } from './types';
import { getGroupFromWorkspace } from '~/logic/lib/workspace';
import { roleForShip } from '~/logic/lib/group';
import { NewChannel } from '~/views/landscape/components/NewChannel';
@ -32,7 +32,7 @@ export function SidebarListHeader(props: {
baseUrl: string;
selected: string;
workspace: Workspace;
handleSubmit: (c: SidebarListConfig) => void;
handleSubmit: (s: any) => void;
}): ReactElement {
const history = useHistory();
const onSubmit = useCallback(
@ -75,7 +75,7 @@ export function SidebarListHeader(props: {
borderBottom={1}
borderColor="lightGray"
backgroundColor={['transparent',
history.location.pathname.includes(`/~landscape${groupPath}/feed`)
history.location.pathname.includes(`/~landscape${groupPath}/feed`)
? (
'washedGray'
) : (

View File

@ -18,5 +18,5 @@ export interface SidebarAppConfig {
}
export type SidebarAppConfigs = {
[a in 'chat' | 'link' | 'publish']: SidebarAppConfig;
graph: SidebarAppConfig;
};

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactElement } from 'react';
import { Row, Icon, Text } from '@tlon/indigo-react';
import { IconRef, PropFunc } from '~/types/util';

View File

@ -52,8 +52,7 @@ export function Skeleton(props: SkeletonProps): ReactElement {
baseUrl={props.baseUrl}
mobileHide={props.mobileHide}
workspace={props.workspace}
history={props.history}
/>
/>
</ErrorBoundary>
{props.children}
</Body>

View File

@ -43,7 +43,7 @@ export function TutorialModal(props: { api: GlobalApi }) {
const {
title,
description,
arrow,
arrow = 'North',
alignX,
alignY,
offsetX,

View File

@ -1,4 +1,4 @@
import React, { Component, useEffect, useCallback, ReactElement } from 'react';
import React, { useEffect, useCallback, ReactElement } from 'react';
import { Route, Switch, RouteComponentProps } from 'react-router-dom';
import Helmet from 'react-helmet';
@ -18,8 +18,7 @@ import { Loading } from '../components/Loading';
import { Workspace } from '~/types/workspace';
import GlobalSubscription from '~/logic/subscription/global';
import useGraphState from '~/logic/state/graph';
import useHarkState, { withHarkState } from '~/logic/state/hark';
import withState from '~/logic/lib/withState';
import useHarkState from '~/logic/state/hark';
import moment from 'moment';

41
pkg/npm/api/README.md Normal file
View File

@ -0,0 +1,41 @@
# Urbit API in JavaScript
This package simplifies the process of working with Urbit's APIs into fluent, typed functions organized by app. Pairs well with `@urbit/http-api`. Compare:
Without:
```ts
import UrbitInterface from '@urbit/http-api';
const api: UrbitInterface = useApi();
api.poke({
app: 'settings-store',
mark: 'settings-event',
json: {
'put-entry': {
'bucket-key': bucket,
'entry-key': key,
'value': value
}
}
});
```
With:
```ts
import UrbitInterface from '@urbit/http-api';
import { settings } from '@urbit/api';
const api: UrbitInterface = useApi();
api.poke(setings.putEntry(bucket, key, value));
```
You may import single functions
```ts
import { putEntry } from '@urbit/api';
```
or whole apps:
```ts
import { settings } from '@urbit/api';
```
This package also provides types and utilities for working with Urbit's internal data structures, such as Nouns, Das, Tas, and so forth.
This package was originally developed as part of Tlon's Landscape client and therefore the best reference material exists [there](https://github.com/urbit/urbit/tree/master/pkg/interface/src).

View File

@ -13,9 +13,11 @@ import {
ContactUpdateSetPublic,
} from "./types";
const storeAction = <T extends ContactUpdate>(data: T): Poke<T> => ({
export const CONTACT_UPDATE_VERSION: number = 0;
const storeAction = <T extends ContactUpdate>(data: T, version: number = CONTACT_UPDATE_VERSION): Poke<T> => ({
app: "contact-store",
mark: "contact-action",
mark: `contact-update-${version}`,
json: data,
});
@ -34,9 +36,9 @@ export const removeContact = (ship: Patp): Poke<ContactUpdateRemove> =>
remove: { ship },
});
export const share = (recipient: Patp): Poke<ContactShare> => ({
export const share = (recipient: Patp, version: number = CONTACT_UPDATE_VERSION): Poke<ContactShare> => ({
app: "contact-push-hook",
mark: "contact-action",
mark: `contact-update-${version}`,
json: { share: recipient },
});

View File

@ -1,9 +1,11 @@
import _ from 'lodash';
import { GroupPolicy, makeResource, resourceFromPath } from '../groups';
import { GroupPolicy, makeResource, Resource, resourceFromPath } from '../groups';
import { deSig, unixToDa } from '../lib';
import { Enc, Path, Patp, PatpNoSig, Poke, Thread } from '../lib/types';
import { Content, GraphChildrenPoke, GraphNode, GraphNodePoke, Post } from './types';
import { Content, Graph, GraphChildrenPoke, GraphNode, GraphNodePoke, Post } from './types';
export const GRAPH_UPDATE_VERSION: number = 1;
export const createBlankNodeWithChildPost = (
ship: PatpNoSig,
@ -40,12 +42,15 @@ export const createBlankNodeWithChildPost = (
};
};
export const markPending = (nodes: any): void => {
_.forEach(nodes, node => {
node.post.author = deSig(node.post.author);
node.post.pending = true;
markPending(node.children || {});
export const markPending = (nodes: any): any => {
Object.keys(nodes).forEach((key) => {
nodes[key].post.author = deSig(nodes[key].post.author);
nodes[key].post.pending = true;
if (nodes[key].children) {
nodes[key].children = markPending(nodes[key].children);
}
});
return nodes;
};
export const createPost = (
@ -80,9 +85,9 @@ function moduleToMark(mod: string): string | undefined {
return undefined;
}
const storeAction = <T>(data: T): Poke<T> => ({
const storeAction = <T>(data: T, version: number = GRAPH_UPDATE_VERSION): Poke<T> => ({
app: 'graph-store',
mark: 'graph-update',
mark: `graph-update-${version}`,
json: data
});
@ -97,9 +102,9 @@ const viewAction = <T>(threadName: string, action: T): Thread<T> => ({
export { viewAction as graphViewAction };
const hookAction = <T>(data: T): Poke<T> => ({
const hookAction = <T>(data: T, version: number = GRAPH_UPDATE_VERSION): Poke<T> => ({
app: 'graph-push-hook',
mark: 'graph-update',
mark: `graph-update-${version}`,
json: data
});
@ -223,22 +228,23 @@ export const addNodes = (
ship: Patp,
name: string,
nodes: Object
): Poke<any> => {
const action = {
): Thread<any> => ({
inputMark: `graph-update-${GRAPH_UPDATE_VERSION}`,
outputMark: 'graph-view-action',
threadName: 'graph-add-nodes',
body: {
'add-nodes': {
resource: { ship, name },
nodes
}
};
return hookAction(action);
};
}
});
export const addPost = (
ship: Patp,
name: string,
post: Post
) => {
): Thread<any> => {
let nodes: Record<string, GraphNode> = {};
nodes[post.index] = {
post,
@ -251,13 +257,40 @@ export const addNode = (
ship: Patp,
name: string,
node: GraphNode
): Poke<any> => {
): Thread<any> => {
let nodes: Record<string, GraphNode> = {};
nodes[node.post.index] = node;
return addNodes(ship, name, nodes);
}
export const createGroupFeed = (
group: Resource,
vip: any = ''
): Thread<any> => ({
inputMark: 'graph-view-action',
outputMark: 'resource',
threadName: 'graph-create-group-feed',
body: {
'create-group-feed': {
resource: group,
vip
}
}
});
export const disableGroupFeed = (
group: Resource
): Thread<any> => ({
inputMark: 'graph-view-action',
outputMark: 'json',
threadName: 'graph-disable-group-feed',
body: {
'disable-group-feed': {
resource: group
}
}
});
export const removeNodes = (
ship: Patp,

View File

@ -4,15 +4,17 @@ import { Enc, Path, Patp, PatpNoSig, Poke, Thread } from '../lib/types';
import { Group, GroupPolicy, GroupPolicyDiff, GroupUpdateAddMembers, GroupUpdateAddTag, GroupUpdateChangePolicy, GroupUpdateRemoveGroup, GroupUpdateRemoveMembers, GroupUpdateRemoveTag, Resource, RoleTags, Tag } from './types';
import { GroupUpdate } from './update';
export const proxyAction = <T>(data: T): Poke<T> => ({
export const GROUP_UPDATE_VERSION = 0;
export const proxyAction = <T>(data: T, version: number = GROUP_UPDATE_VERSION): Poke<T> => ({
app: 'group-push-hook',
mark: 'group-update',
mark: `group-update-${version}`,
json: data
});
const storeAction = <T extends GroupUpdate>(data: T): Poke<T> => ({
const storeAction = <T extends GroupUpdate>(data: T, version: number = GROUP_UPDATE_VERSION): Poke<T> => ({
app: 'group-store',
mark: 'group-update',
mark: `group-update-${version}`,
json: data
});
@ -146,6 +148,12 @@ export const invite = (
}
});
export const hide = (
resource: string
): Poke<any> => viewAction({
hide: resource
});
export const roleTags = ['janitor', 'moderator', 'admin'];
// TODO make this type better?

View File

@ -245,6 +245,6 @@ export const getNotificationCount = (
): number => {
const unread = unreads.graph?.[path] || {};
return Object.keys(unread)
.map(index => unread[index]?.notifications || 0)
.map(index => unread[index]?.notifications as number || 0)
.reduce(f.add, 0);
}

View File

@ -7,7 +7,7 @@ export type GraphNotifDescription = "link" | "comment" | "note" | "mention" | "m
export interface UnreadStats {
unreads: Set<string> | number;
notifications: NotifRef[];
notifications: NotifRef[] | number;
last: number;
}

View File

@ -1,9 +1,18 @@
export * from './contacts';
export * as contacts from './contacts';
export * from './graph';
export * as graph from './graph';
export * from './groups';
export * as groups from './groups';
export * from './hark';
export * as hark from './hark';
export * from './invite';
export * as invite from './invite';
export * from './metadata';
export * as metadata from './metadata';
export * from './settings';
export * as settings from './settings';
export * from './s3';
export * as s3 from './s3';
export * from './lib';
export * from './lib/BigIntOrderedMap';

View File

@ -1,9 +1,11 @@
import { AppName, Path, Poke, uxToHex, PatpNoSig } from "../lib";
import { Association, Metadata, MetadataUpdate, MetadataUpdateAdd, MetadataUpdateRemove } from './types';
export const metadataAction = <T extends MetadataUpdate>(data: T): Poke<T> => ({
export const METADATA_UPDATE_VERSION = 1;
export const metadataAction = <T extends MetadataUpdate>(data: T, version: number = METADATA_UPDATE_VERSION): Poke<T> => ({
app: 'metadata-push-hook',
mark: 'metadata-update',
mark: `metadata-update-${version}`,
json: data
});
@ -30,8 +32,9 @@ export const add = (
color,
'date-created': dateCreated,
creator: `~${ship}`,
'module': moduleName,
config: { graph: moduleName },
picture: '',
hidden: false,
preview: false,
vip: ''
}

View File

@ -1,3 +1,4 @@
import { Resource } from "..";
import { AppName, Path, Patp } from "../lib";
export type MetadataUpdate =
@ -48,8 +49,6 @@ export type AppAssociations = {
[p in Path]: Association;
}
export type Association = MdResource & {
group: Path;
metadata: Metadata;
@ -67,10 +66,20 @@ export interface Metadata {
'date-created': string;
description: string;
title: string;
module: string;
config: MetadataConfig;
picture: string;
hidden: boolean;
preview: boolean;
vip: PermVariation;
}
type MetadataConfig = GroupConfig | GraphConfig;
interface GroupConfig {
group: null | {} | Resource;
}
interface GraphConfig {
graph: string;
}
export type PermVariation = '' | 'reader-comments' | 'member-metadata' | 'host-feed' | 'admin-feed';

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "@urbit/api",
"version": "1.0.0",
"version": "1.1.0",
"description": "",
"repository": {
"type": "git",
@ -11,6 +11,7 @@
"types": "dist/index.d",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"watch": "onchange './**/*.ts' -e './dist/**' -- npm run build",
"build": "npm run clean && tsc -p tsconfig.json",
"clean": "rm -rf dist/*"
},
@ -23,5 +24,8 @@
"big-integer": "^1.6.48",
"immer": "^9.0.1",
"lodash": "^4.17.20"
},
"devDependencies": {
"onchange": "^7.1.0"
}
}

View File

@ -0,0 +1,2 @@
export * from './lib';
export * from './types';

View File

@ -20,7 +20,7 @@ export interface PutEntry {
"put-entry": {
"bucket-key": Key;
"entry-key": Key;
"value": Value;
"value"?: Value;
};
}

View File

@ -3,9 +3,9 @@
"exclude": ["node_modules", "dist", "@types"],
"compilerOptions": {
"outDir": "./dist",
"module": "ESNext",
"module": "ES2020",
"noImplicitAny": true,
"target": "ESNext",
"target": "ES2020",
"pretty": true,
"moduleResolution": "node",
"esModuleInterop": true,

Binary file not shown.

View File

@ -1 +0,0 @@
example/*.js

521
pkg/npm/http-api/Urbit.ts Normal file
View File

@ -0,0 +1,521 @@
import { isBrowser, isNode } from 'browser-or-node';
import { Action, Scry, Thread } from '@urbit/api';
import { fetchEventSource, EventSourceMessage, EventStreamContentType } from '@microsoft/fetch-event-source';
import { AuthenticationInterface, SubscriptionInterface, CustomEventHandler, PokeInterface, SubscriptionRequestInterface, headers, UrbitInterface, SSEOptions, PokeHandlers, Message } from './types';
import { uncamelize, hexString } from './utils';
/**
* A class for interacting with an urbit ship, given its URL and code
*/
export class Urbit implements UrbitInterface {
/**
* UID will be used for the channel: The current unix time plus a random hex string
*/
uid: string = `${Math.floor(Date.now() / 1000)}-${hexString(6)}`;
/**
* Last Event ID is an auto-updated index of which events have been sent over this channel
*/
lastEventId: number = 0;
lastAcknowledgedEventId: number = 0;
/**
* SSE Client is null for now; we don't want to start polling until it the channel exists
*/
sseClientInitialized: boolean = false;
/**
* Cookie gets set when we log in.
*/
cookie?: string | undefined;
/**
* A registry of requestId to successFunc/failureFunc
*
* These functions are registered during a +poke and are executed
* in the onServerEvent()/onServerError() callbacks. Only one of
* the functions will be called, and the outstanding poke will be
* removed after calling the success or failure function.
*/
outstandingPokes: Map<number, PokeHandlers> = new Map();
/**
* A registry of requestId to subscription functions.
*
* These functions are registered during a +subscribe and are
* executed in the onServerEvent()/onServerError() callbacks. The
* event function will be called whenever a new piece of data on this
* subscription is available, which may be 0, 1, or many times. The
* disconnect function may be called exactly once.
*/
outstandingSubscriptions: Map<number, SubscriptionRequestInterface> = new Map();
/**
* Ship can be set, in which case we can do some magic stuff like send chats
*/
ship?: string | null;
/**
* If verbose, logs output eagerly.
*/
verbose?: boolean;
onError?: (error: any) => void = null;
/** This is basic interpolation to get the channel URL of an instantiated Urbit connection. */
get channelUrl(): string {
return `${this.url}/~/channel/${this.uid}`;
}
get fetchOptions(): any {
const headers: headers = {
'Content-Type': 'application/json',
};
if (!isBrowser) {
headers.Cookie = this.cookie;
}
return {
credentials: 'include',
accept: '*',
headers
};
}
/**
* Constructs a new Urbit connection.
*
* @param url The URL (with protocol and port) of the ship to be accessed
* @param code The access code for the ship at that address
*/
constructor(
public url: string,
public code: string
) {
if (isBrowser) {
window.addEventListener('beforeunload', this.delete);
}
return this;
}
/**
* All-in-one hook-me-up.
*
* Given a ship, url, and code, this returns an airlock connection
* that is ready to go. It `|hi`s itself to create the channel,
* then opens the channel via EventSource.
*
* @param AuthenticationInterface
*/
static async authenticate({ ship, url, code, verbose = false }: AuthenticationInterface) {
const airlock = new Urbit(`http://${url}`, code);
airlock.verbose = verbose;
airlock.ship = ship;
await airlock.connect();
await airlock.poke({ app: 'hood', mark: 'helm-hi', json: 'opening airlock' });
await airlock.eventSource();
return airlock;
}
/**
* Connects to the Urbit ship. Nothing can be done until this is called.
* That's why we roll it into this.authenticate
*/
async connect(): Promise<void> {
if (this.verbose) {
console.log(`password=${this.code} `, isBrowser ? "Connecting in browser context at " + `${this.url}/~/login` : "Connecting from node context");
}
return fetch(`${this.url}/~/login`, {
method: 'post',
body: `password=${this.code}`,
credentials: 'include',
}).then(response => {
if (this.verbose) {
console.log('Received authentication response', response);
}
const cookie = response.headers.get('set-cookie');
if (!this.ship) {
this.ship = new RegExp(/urbauth-~([\w-]+)/).exec(cookie)[1];
}
if (!isBrowser) {
this.cookie = cookie;
}
});
}
/**
* Initializes the SSE pipe for the appropriate channel.
*/
eventSource(): void {
if (!this.sseClientInitialized) {
const sseOptions: SSEOptions = {
headers: {}
};
if (isBrowser) {
sseOptions.withCredentials = true;
} else if (isNode) {
sseOptions.headers.Cookie = this.cookie;
}
if (this.lastEventId === 0) {
// Can't receive events until the channel is open
this.poke({ app: 'hood', mark: 'helm-hi', json: 'Opening API channel' });
}
fetchEventSource(this.channelUrl, {
...this.fetchOptions,
openWhenHidden: true,
onopen: async (response) => {
if (this.verbose) {
console.log('Opened eventsource', response);
}
if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
return; // everything's good
} else if (response.status >= 400 && response.status < 500 && response.status !== 429) {
if (this.onError) {
this.onError(response.statusText);
} else {
throw new Error();
}
} else {
if (this.onError) {
this.onError(response.statusText);
} else {
throw new Error();
}
}
},
onmessage: (event: EventSourceMessage) => {
if (this.verbose) {
console.log('Received SSE: ', event);
}
if (!event.id) return;
this.ack(Number(event.id));
if (event.data && JSON.parse(event.data)) {
const data: any = JSON.parse(event.data);
if (data.response === 'diff') {
this.clearQueue();
}
if (data.response === 'poke' && this.outstandingPokes.has(data.id)) {
const funcs = this.outstandingPokes.get(data.id);
if (data.hasOwnProperty('ok')) {
funcs.onSuccess();
} else if (data.hasOwnProperty('err')) {
funcs.onError(data.err);
} else {
console.error('Invalid poke response', data);
}
this.outstandingPokes.delete(data.id);
} else if (data.response === 'subscribe' ||
(data.response === 'poke' && this.outstandingSubscriptions.has(data.id))) {
const funcs = this.outstandingSubscriptions.get(data.id);
if (data.hasOwnProperty('err')) {
funcs.err(data.err);
this.outstandingSubscriptions.delete(data.id);
}
} else if (data.response === 'diff' && this.outstandingSubscriptions.has(data.id)) {
const funcs = this.outstandingSubscriptions.get(data.id);
funcs.event(data.json);
} else if (data.response === 'quit' && this.outstandingSubscriptions.has(data.id)) {
const funcs = this.outstandingSubscriptions.get(data.id);
funcs.quit(data);
this.outstandingSubscriptions.delete(data.id);
} else {
console.log('Unrecognized response', data);
}
}
},
onerror: (error) => {
if (this.onError) {
this.onError(error);
} else {
throw error;
}
}
});
this.sseClientInitialized = true;
}
return;
}
/**
* Autoincrements the next event ID for the appropriate channel.
*/
getEventId(): number {
this.lastEventId = Number(this.lastEventId) + 1;
return this.lastEventId;
}
/**
* Acknowledges an event.
*
* @param eventId The event to acknowledge.
*/
async ack(eventId: number): Promise<number | void> {
const message: Message = {
action: 'ack',
'event-id': eventId
};
await this.sendJSONtoChannel(message);
return eventId;
}
/**
* This is a wrapper method that can be used to send any action with data.
*
* Every message sent has some common parameters, like method, headers, and data
* structure, so this method exists to prevent duplication.
*
* @param action The action to send
* @param data The data to send with the action
*
* @returns void | number If successful, returns the number of the message that was sent
*/
// async sendMessage(action: Action, data?: object): Promise<number | void> {
// const id = this.getEventId();
// if (this.verbose) {
// console.log(`Sending message ${id}:`, action, data,);
// }
// const message: Message = { id, action, ...data };
// await this.sendJSONtoChannel(message);
// return id;
// }
outstandingJSON: Message[] = [];
debounceTimer: any = null;
debounceInterval = 500;
calm = true;
sendJSONtoChannel(json: Message): Promise<boolean | void> {
this.outstandingJSON.push(json);
return this.processQueue();
}
processQueue(): Promise<boolean | void> {
return new Promise(async (resolve, reject) => {
const process = async () => {
if (this.calm) {
if (this.outstandingJSON.length === 0) resolve(true);
this.calm = false; // We are now occupied
const json = this.outstandingJSON;
const body = JSON.stringify(json);
this.outstandingJSON = [];
if (body === '[]') {
this.calm = true;
return resolve(false);
}
try {
await fetch(this.channelUrl, {
...this.fetchOptions,
method: 'PUT',
body
});
} catch (error) {
json.forEach(failed => this.outstandingJSON.push(failed));
if (this.onError) {
this.onError(error);
} else {
throw error;
}
}
this.calm = true;
if (!this.sseClientInitialized) {
this.eventSource(); // We can open the channel for subscriptions once we've sent data over it
}
resolve(true);
} else {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(process, this.debounceInterval);
resolve(false);
}
}
this.debounceTimer = setTimeout(process, this.debounceInterval);
});
}
// resetDebounceTimer() {
// if (this.debounceTimer) {
// clearTimeout(this.debounceTimer);
// this.debounceTimer = null;
// }
// this.calm = false;
// this.debounceTimer = setTimeout(() => {
// this.calm = true;
// }, this.debounceInterval);
// }
clearQueue() {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
/**
* Pokes a ship with data.
*
* @param app The app to poke
* @param mark The mark of the data being sent
* @param json The data to send
*/
poke<T>(params: PokeInterface<T>): Promise<number> {
const {
app,
mark,
json,
ship,
onSuccess,
onError
} = {
onSuccess: () => { },
onError: () => { },
ship: this.ship,
...params
};
return new Promise((resolve, reject) => {
const message: Message = {
id: this.getEventId(),
action: 'poke',
ship,
app,
mark,
json
};
this.outstandingPokes.set(message.id, {
onSuccess: () => {
onSuccess();
resolve(message.id);
},
onError: (event) => {
onError(event);
reject(event.err);
}
});
this.sendJSONtoChannel(message).then(() => {
resolve(message.id);
});
});
}
/**
* Subscribes to a path on an app on a ship.
*
* @param app The app to subsribe to
* @param path The path to which to subscribe
* @param handlers Handlers to deal with various events of the subscription
*/
async subscribe(params: SubscriptionRequestInterface): Promise<number> {
const {
app,
path,
ship,
err,
event,
quit
} = {
err: () => { },
event: () => { },
quit: () => { },
ship: this.ship,
...params
};
const message: Message = {
id: this.getEventId(),
action: 'subscribe',
ship,
app,
path
};
this.outstandingSubscriptions.set(message.id, {
app, path, err, event, quit
});
await this.sendJSONtoChannel(message);
return message.id;
}
/**
* Unsubscribes to a given subscription.
*
* @param subscription
*/
async unsubscribe(subscription: number) {
return this.sendJSONtoChannel({
id: this.getEventId(),
action: 'unsubscribe',
subscription
}).then(() => {
this.outstandingSubscriptions.delete(subscription);
});
}
/**
* Deletes the connection to a channel.
*/
delete() {
if (isBrowser) {
navigator.sendBeacon(this.channelUrl, JSON.stringify([{
action: 'delete'
}]));
} else {
// TODO
// this.sendMessage('delete');
}
}
/**
*
* @param app The app into which to scry
* @param path The path at which to scry
*/
async scry(params: Scry): Promise<void | any> {
const { app, path } = params;
const response = await fetch(`${this.url}/~/scry/${app}${path}.json`, this.fetchOptions);
return await response.json();
}
/**
*
* @param inputMark The mark of the data being sent
* @param outputMark The mark of the data being returned
* @param threadName The thread to run
* @param body The data to send to the thread
*/
async thread<T>(params: Thread<T>): Promise<T> {
const { inputMark, outputMark, threadName, body } = params;
const res = await fetch(`${this.url}/spider/${inputMark}/${threadName}/${outputMark}.json`, {
...this.fetchOptions,
method: 'POST',
body: JSON.stringify(body)
});
return res.json();
}
/**
* Utility function to connect to a ship that has its *.arvo.network domain configured.
*
* @param name Name of the ship e.g. zod
* @param code Code to log in
*/
static async onArvoNetwork(ship: string, code: string): Promise<Urbit> {
const url = `https://${ship}.arvo.network`;
return await Urbit.authenticate({ ship, url, code });
}
}
export default Urbit;

View File

@ -0,0 +1,3 @@
// import Urbit from '../../dist/browser';
// window.Urbit = Urbit;

View File

@ -0,0 +1,14 @@
// import Urbit from '../../dist/index';
// async function blastOff() {
// const airlock = await Urbit.authenticate({
// ship: 'zod',
// url: 'localhost:8080',
// code: 'lidlut-tabwed-pillex-ridrup',
// verbose: true
// });
// airlock.subscribe('chat-view', '/primary');
// }
// blastOff();

View File

@ -0,0 +1,3 @@
export * from './types';
import Urbit from './Urbit';
export { Urbit as default };

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "@urbit/http-api",
"version": "1.1.0",
"version": "1.2.0",
"license": "MIT",
"description": "Library to interact with an Urbit ship over HTTP",
"repository": {
@ -16,6 +16,7 @@
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"watch": "onchange './*.ts' -- npm run build",
"build": "npm run clean && tsc -p tsconfig.json",
"clean": "rm -rf dist/*"
},
@ -40,6 +41,7 @@
"@typescript-eslint/parser": "^4.7.0",
"babel-loader": "^8.2.1",
"clean-webpack-plugin": "^3.0.0",
"onchange": "^7.1.0",
"tslib": "^2.0.3",
"typescript": "^3.9.7",
"util": "^0.12.3",

View File

@ -1,5 +1,5 @@
{
"include": ["src/*.ts"],
"include": ["*.ts"],
"exclude": ["node_modules", "dist", "@types"],
"compilerOptions": {
"outDir": "./dist",
@ -15,5 +15,9 @@
"strict": false,
"noErrorTruncation": true,
"allowJs": true,
"baseUrl": ".",
"paths": {
"*" : ["./node_modules/@types/*", "*"]
}
}
}

72
pkg/npm/http-api/types.ts Normal file
View File

@ -0,0 +1,72 @@
import { Action, Poke, Scry, Thread } from '@urbit/api';
export interface PokeHandlers {
onSuccess?: () => void;
onError?: (e: any) => void;
}
export type PokeInterface<T> = PokeHandlers & Poke<T>;
export interface AuthenticationInterface {
ship: string;
url: string;
code: string;
verbose?: boolean;
}
export interface SubscriptionInterface {
err?(error: any): void;
event?(data: any): void;
quit?(data: any): void;
}
export type SubscriptionRequestInterface = SubscriptionInterface & {
app: string;
path: string;
}
export interface headers {
'Content-Type': string;
Cookie?: string;
}
export interface UrbitInterface {
uid: string;
lastEventId: number;
lastAcknowledgedEventId: number;
sseClientInitialized: boolean;
cookie?: string | undefined;
outstandingPokes: Map<number, PokeHandlers>;
outstandingSubscriptions: Map<number, SubscriptionRequestInterface>;
verbose?: boolean;
ship?: string | null;
onError?: (error: any) => void;
connect(): void;
connect(): Promise<void>;
eventSource(): void;
getEventId(): number;
ack(eventId: number): Promise<void | number>;
// sendMessage(action: Action, data?: object): Promise<void | number>;
poke<T>(params: PokeInterface<T>): Promise<number>;
subscribe(params: SubscriptionRequestInterface): Promise<number>;
unsubscribe(subscription: number): Promise<boolean | void>;
delete(): void;
scry(params: Scry): Promise<void | any>;
thread<T>(params: Thread<T>): Promise<T>;
}
export interface CustomEventHandler {
(data: any, response: string): void;
}
export interface SSEOptions {
headers?: {
Cookie?: string
};
withCredentials?: boolean;
}
export interface Message extends Record<string, any> {
action: Action;
id?: number;
}

82
pkg/npm/http-api/utils.ts Normal file
View File

@ -0,0 +1,82 @@
import * as http from 'http';
interface HttpResponse {
req: http.ClientRequest;
res: http.IncomingMessage;
data: string;
}
export function request(
url: string,
options: http.ClientRequestArgs,
body?: string
): Promise<HttpResponse> {
return new Promise<HttpResponse>((resolve, reject) => {
const req = http.request(url, options, res => {
let data = "";
res.on("data", chunk => {
data += chunk;
});
res.on("end", () => {
resolve({ req, res, data });
});
res.on("error", e => {
reject(e);
});
});
if (body) {
req.write(body);
}
req.end();
});
}
export function camelize(str: string) {
return str
.replace(/\s(.)/g, function($1: string) { return $1.toUpperCase(); })
.replace(/\s/g, '')
.replace(/^(.)/, function($1: string) { return $1.toLowerCase(); });
}
export function uncamelize(str: string, separator = '-') {
// Replace all capital letters by separator followed by lowercase one
var str = str.replace(/[A-Z]/g, function (letter: string) {
return separator + letter.toLowerCase();
});
return str.replace(new RegExp('^' + separator), '');
}
/**
* Returns a hex string of given length.
*
* Poached from StackOverflow.
*
* @param len Length of hex string to return.
*/
export function hexString(len: number): string {
const maxlen = 8;
const min = Math.pow(16, Math.min(len, maxlen) - 1);
const max = Math.pow(16, Math.min(len, maxlen)) - 1;
const n = Math.floor(Math.random() * (max - min + 1)) + min;
let r = n.toString(16);
while (r.length < len) {
r = r + hexString(len - maxlen);
}
return r;
}
/**
* Generates a random UID.
*
* Copied from https://github.com/urbit/urbit/blob/137e4428f617c13f28ed31e520eff98d251ed3e9/pkg/interface/src/lib/util.js#L3
*/
export function uid(): string {
let str = '0v';
str += Math.ceil(Math.random() * 8) + '.';
for (let i = 0; i < 5; i++) {
let _str = Math.ceil(Math.random() * 10000000).toString(32);
_str = ('00000' + _str).substr(-5, 5);
str += _str + '.';
}
return str.slice(0, -1);
}