Merge pull request #5102 from urbit/lf/int-fixes

interface: assorted fixes
This commit is contained in:
matildepark 2021-07-13 10:12:04 -04:00 committed by GitHub
commit 42b992edbd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 463 additions and 45 deletions

View File

@ -0,0 +1,11 @@
:: group-view|join: Join a group
::
/- view=group-view
:- %say
|= $: [now=@da eny=@uvJ =beak]
[[him=ship name=term ~] ~]
==
::
:- %group-view-action
^- action:view
[%join [him name] him]

View File

@ -405,26 +405,17 @@ export const addNodes = (json, state) => {
const removePosts = (json, state: GraphState): GraphState => {
const _remove = (graph, index) => {
const child = graph.get(index[0]);
if(!child) {
return graph;
}
if (index.length === 1) {
if (child) {
return graph.set(index[0], {
post: child.post.hash || '',
children: child.children
});
}
return graph.set(index[0], {
post: child.post.hash || '',
children: child.children
});
} else {
if (child) {
_remove(child.children, index.slice(1));
return graph.set(index[0], child);
} else {
const child = graph.get(index[0]);
if (child) {
return graph.set(index[0], produce((draft: any) => {
draft.children = _remove(draft.children, index.slice(1));
}));
}
return graph;
}
const node = { ...child, children: _remove(child.children, index.slice(1)) };
return graph.set(index[0], node);
}
};

View File

@ -1,4 +1,4 @@
import { cite, Content, Post } from '@urbit/api';
import { cite, Content, Post, removeDmMessage } from '@urbit/api';
import React, { useCallback, useEffect } from 'react';
import _ from 'lodash';
import bigInt from 'big-integer';
@ -11,6 +11,7 @@ import useHarkState, { useHarkDm } from '~/logic/state/hark';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
import { ChatPane } from './components/ChatPane';
import shallow from 'zustand/shallow';
import airlock from '~/logic/api';
interface DmResourceProps {
ship: string;
@ -121,6 +122,9 @@ export function DmResource(props: DmResourceProps) {
[ship, addDmMessage]
);
const onDelete = useCallback((msg: Post) => {
airlock.poke(removeDmMessage(`~${window.ship}`, msg.index));
}, []);
return (
<Col width="100%" height="100%" overflow="hidden">
<Row
@ -169,6 +173,7 @@ export function DmResource(props: DmResourceProps) {
onReply={quoteReply}
fetchMessages={fetchMessages}
dismissUnread={dismissUnread}
onDelete={onDelete}
getPermalink={() => undefined}
isAdmin={false}
onSubmit={onSubmit}

View File

@ -232,6 +232,7 @@ const ChatEditor = React.forwardRef<CodeMirrorShim, ChatEditorProps>(({ inCodeMo
fontFamily={inCodeMode ? 'Source Code Pro' : 'Inter'}
fontSize={1}
lineHeight="tall"
value={message}
rows={1}
style={{ width: '100%', background: 'transparent', color: 'currentColor' }}
placeholder={inCodeMode ? 'Code...' : 'Message...'}

View File

@ -1,4 +1,4 @@
import { BaseImage, Box, Row, Text } from '@tlon/indigo-react';
import { BaseImage, Row, Text, Button } from '@tlon/indigo-react';
import { allowGroup, allowShips, Contact, share } from '@urbit/api';
import React, { ReactElement } from 'react';
import { Sigil } from '~/logic/lib/sigil';
@ -61,14 +61,15 @@ const ShareProfile = (props: ShareProfileProps): ReactElement | null => {
borderBottom={1}
borderColor="lightGray"
flexShrink={0}
px="3"
>
<Row pl={3} alignItems="center">
<Row alignItems="center">
{image}
<Text verticalAlign="middle" pl={2}>Share private profile?</Text>
</Row>
<Box pr={2} onClick={onClick}>
<Text color="blue" bold cursor="pointer">Share</Text>
</Box>
<Button primary onClick={onClick}>
Share
</Button>
</Row>
) : null;
};

View File

@ -55,8 +55,10 @@ export function LinkBlocks(props: LinkBlocksProps) {
}, [association.resource]);
const orm = useMemo(() => {
const nodes = [null, ...Array.from(props.graph)];
const graph = Array.from(props.graph).filter(
([idx, node]) => typeof node?.post !== 'string'
);
const nodes = [null, ...graph];
const chunks = _.chunk(nodes, colCount);
return new BigIntOrderedMap<[bigInt.BigInteger, GraphNode][]>().gas(
chunks.reverse().map((chunk, i) => {

View File

@ -39,7 +39,6 @@ export function PostForm(props: PostFormProps) {
validationSchema={formSchema}
initialValues={initial}
onSubmit={onSubmit}
validateOnBlur
>
<Form style={{ display: 'contents' }}>
<Row flexShrink={0} flexDirection={['column-reverse', 'row']} mb={4} gapX={4} justifyContent='space-between'>

View File

@ -44,7 +44,11 @@ function getAdjacentId(
): BigInteger | null {
const children = Array.from(graph);
const i = children.findIndex(([index]) => index.eq(child));
const target = children[backwards ? i + 1 : i - 1];
let idx = backwards ? i + 1 : i - 1;
let target = children[idx];
while(typeof target?.[1]?.post === 'string') {
target = children[backwards ? idx++ : idx--];
}
return target?.[0] || null;
}

View File

@ -1,7 +1,6 @@
import { Col } from '@tlon/indigo-react';
import { Graph, Group } from '@urbit/api';
import React from 'react';
import useContactState from '~/logic/state/contact';
import { NotePreview } from './NotePreview';
interface NotebookPostsProps {
@ -19,7 +18,7 @@ export function NotebookPosts(props: NotebookPostsProps) {
<Col>
{Array.from(props.graph || []).map(
([date, node]) =>
node && (
node && typeof node?.post !== 'string' && (
<NotePreview
key={date.toString()}
host={props.host}

View File

@ -336,6 +336,26 @@ export const removePosts = (
}
});
/**
* Remove a DM message from our inbox
*
* @remarks
* This does not remove the message from the recipients inbox
*/
export const removeDmMessage = (
our: Patp,
index: string
): Poke<any> => ({
app: 'graph-store',
mark: `graph-update-${GRAPH_UPDATE_VERSION}`,
json: {
'remove-posts': {
resource: { ship: our, name: 'dm-inbox' },
indices: [index]
}
}
});
/**
* Send a DM to a ship
*

View File

@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
};

View File

@ -0,0 +1,194 @@
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/configuration
*/
module.exports = {
// All imported modules in your tests should be mocked automatically
automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/7w/hvrpvq7978bbb9kwkbhsn6rr0000gn/T/jest_dx",
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
// coverageProvider: "babel",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
maxWorkers: 1,
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
setupFiles: ['./setupEnv.js'],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: "jsdom",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};

Binary file not shown.

View File

@ -15,7 +15,7 @@
"src"
],
"scripts": {
"test": "echo \"No test specified\" && exit 0",
"test": "jest",
"build": "tsc -p tsconfig.json",
"prepare": "npm run build",
"watch": "tsc -p tsconfig.json --watch",
@ -29,25 +29,34 @@
},
"author": "",
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/core": "^7.14.6",
"@babel/plugin-proposal-class-properties": "^7.12.1",
"@babel/plugin-proposal-object-rest-spread": "^7.12.1",
"@babel/plugin-proposal-optional-chaining": "^7.12.1",
"@babel/preset-env": "^7.14.7",
"@babel/preset-typescript": "^7.12.1",
"@types/browser-or-node": "^1.2.0",
"@types/eventsource": "^1.1.5",
"@types/jest": "^26.0.24",
"@types/react": "^16.9.56",
"@typescript-eslint/eslint-plugin": "^4.7.0",
"@typescript-eslint/parser": "^4.7.0",
"babel-jest": "^27.0.6",
"babel-loader": "^8.2.1",
"clean-webpack-plugin": "^3.0.0",
"cross-fetch": "^3.1.4",
"event-target-polyfill": "0.0.3",
"fast-text-encoding": "^1.0.3",
"jest": "^27.0.6",
"onchange": "^7.1.0",
"tslib": "^2.0.3",
"typescript": "^3.9.7",
"util": "^0.12.3",
"web-streams-polyfill": "^3.0.3",
"webpack": "^5.4.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.0"
"webpack-dev-server": "^3.11.0",
"yet-another-abortcontroller-polyfill": "0.0.4"
},
"dependencies": {
"@babel/runtime": "^7.12.5",
@ -55,7 +64,6 @@
"browser-or-node": "^1.3.0",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"node-fetch": "^2.6.1",
"stream-browserify": "^3.0.0",
"stream-http": "^3.1.1"
}

View File

@ -0,0 +1,8 @@
require('event-target-polyfill');
require('yet-another-abortcontroller-polyfill');
require('cross-fetch/polyfill');
require('fast-text-encoding');
require('web-streams-polyfill');
global.ReadableStream = require('web-streams-polyfill').ReadableStream;

View File

@ -165,6 +165,11 @@ export class Urbit {
* Initializes the SSE pipe for the appropriate channel.
*/
eventSource(): Promise<void> {
if(this.lastEventId === 0) {
// Can't receive events until the channel is open
this.skipDebounce = true;
return this.poke({ app: 'hood', mark: 'helm-hi', json: 'Opening API channel' }).then(() => {});
}
return new Promise((resolve, reject) => {
if (!this.sseClientInitialized) {
const sseOptions: SSEOptions = {
@ -175,10 +180,6 @@ export class Urbit {
} 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,
@ -236,6 +237,7 @@ export class Urbit {
funcs.quit(data);
this.outstandingSubscriptions.delete(data.id);
} else {
console.log([...this.outstandingSubscriptions.keys()]);
console.log('Unrecognized response', data);
}
}
@ -329,7 +331,8 @@ export class Urbit {
private outstandingJSON: Message[] = [];
private debounceTimer: NodeJS.Timeout = null;
private debounceInterval = 500;
private debounceInterval = 10;
private skipDebounce = false;
private calm = true;
private sendJSONtoChannel(json: Message): Promise<boolean | void> {
@ -351,11 +354,14 @@ export class Urbit {
return resolve(false);
}
try {
await fetch(this.channelUrl, {
const response = await fetch(this.channelUrl, {
...this.fetchOptions,
method: 'PUT',
body
});
if(!response.ok) {
throw new Error('failed to PUT');
}
} catch (error) {
console.log(error);
json.forEach(failed => this.outstandingJSON.push(failed));
@ -367,7 +373,7 @@ export class Urbit {
}
this.calm = true;
if (!this.sseClientInitialized) {
this.eventSource(); // We can open the channel for subscriptions once we've sent data over it
this.eventSource().then(resolve); // We can open the channel for subscriptions once we've sent data over it
}
resolve(true);
} else {
@ -376,6 +382,10 @@ export class Urbit {
resolve(false);
}
}
if(this.skipDebounce) {
process();
this.skipDebounce = false;
}
this.debounceTimer = setTimeout(process, this.debounceInterval);

View File

@ -1,8 +1,167 @@
import Urbit from '../src';
import { Readable } from 'streams';
describe('blah', () => {
it('works', () => {
const connection = new Urbit('~sampel-palnet', '+code');
expect(connection).toEqual(2);
function fakeSSE(messages = [], timeout = 0) {
const ourMessages = [...messages];
const enc = new TextEncoder();
return new ReadableStream({
start(controller) {
const interval = setInterval(() => {
let message = ':\n';
if (ourMessages.length > 0) {
message = ourMessages.shift();
}
controller.enqueue(enc.encode(message));
}, 50);
if (timeout > 0) {
setTimeout(() => {
controller.close();
interval;
}, timeout);
}
},
});
}
const ship = '~sampel-palnet';
let eventId = 0;
function event(data: any) {
return `id:${eventId++}\ndata:${JSON.stringify(data)}\n\n`;
}
function fact(id: number, data: any) {
return event({
response: 'diff',
id,
json: data,
});
}
function ack(id: number, err = false) {
const res = err ? { err: 'Error' } : { ok: true };
return event({ id, response: 'poke', ...res });
}
const fakeFetch = (body) => () =>
Promise.resolve({
ok: true,
body: body(),
});
const wait = (ms: number) => new Promise((res) => setTimeout(res, ms));
process.on('unhandledRejection', () => {
console.error(error);
});
describe('Initialisation', () => {
let airlock: Urbit;
let fetchSpy;
beforeEach(() => {
airlock = new Urbit('', '+code');
airlock.debounceInterval = 10;
});
afterEach(() => {
fetchSpy.mockReset();
});
it('should poke & connect upon a 200', async () => {
airlock.onOpen = jest.fn();
fetchSpy = jest.spyOn(window, 'fetch');
fetchSpy
.mockImplementationOnce(() =>
Promise.resolve({ ok: true, body: fakeSSE() })
)
.mockImplementationOnce(() =>
Promise.resolve({ ok: true, body: fakeSSE() })
);
await airlock.eventSource();
expect(airlock.onOpen).toHaveBeenCalled();
}, 500);
it('should handle failures', async () => {
fetchSpy = jest.spyOn(window, 'fetch');
fetchSpy
.mockImplementation(() =>
Promise.resolve({ ok: false, body: fakeSSE() })
)
airlock.onError = jest.fn();
try {
await airlock.eventSource();
wait(100);
} catch (e) {
expect(airlock.onError).toHaveBeenCalled();
}
}, 200);
});
describe('subscription', () => {
let airlock: Urbit;
let fetchSpy: jest.SpyInstance;
beforeEach(() => {
eventId = 1;
});
afterEach(() => {
fetchSpy.mockReset();
});
it('should subscribe', async () => {
fetchSpy = jest.spyOn(window, 'fetch');
airlock = new Urbit('', '+code');
airlock.onOpen = jest.fn();
const params = {
app: 'app',
path: '/path',
err: jest.fn(),
event: jest.fn(),
quit: jest.fn(),
};
const firstEv = 'one';
const secondEv = 'two';
const events = (id) => [fact(id, firstEv), fact(id, secondEv)];
fetchSpy.mockImplementation(fakeFetch(() => fakeSSE(events(1))));
await airlock.subscribe(params);
await wait(600);
expect(airlock.onOpen).toBeCalled();
expect(params.event).toHaveBeenNthCalledWith(1, firstEv);
expect(params.event).toHaveBeenNthCalledWith(2, secondEv);
}, 800);
it('should poke', async () => {
fetchSpy = jest.spyOn(window, 'fetch');
airlock = new Urbit('', '+code');
airlock.onOpen = jest.fn();
fetchSpy.mockImplementation(fakeFetch(() => fakeSSE([ack(1)])));
const params = {
app: 'app',
mark: 'mark',
json: { poke: 1 },
onSuccess: jest.fn(),
onError: jest.fn(),
};
await airlock.poke(params);
await wait(300);
expect(params.onSuccess).toHaveBeenCalled();
}, 800);
it('should nack poke', async () => {
fetchSpy = jest.spyOn(window, 'fetch');
airlock = new Urbit('', '+code');
airlock.onOpen = jest.fn();
fetchSpy.mockImplementation(fakeFetch(() => fakeSSE([ack(1, true)])));
const params = {
app: 'app',
mark: 'mark',
json: { poke: 1 },
onSuccess: jest.fn(),
onError: jest.fn(),
};
try {
await airlock.poke(params);
await wait(300);
} catch (e) {
expect(params.onError).toHaveBeenCalled();
}
});
});