Merge branch 'release/next-userspace' into m/next-gen-term-real

This commit is contained in:
fang 2021-06-25 00:26:02 +02:00
commit 0a1fdc016e
No known key found for this signature in database
GPG Key ID: EB035760C1BBA972
53 changed files with 2265 additions and 1347 deletions

View File

@ -157,7 +157,7 @@
++ to-range ++ to-range
|= [item=byts f=@ k=byts] |= [item=byts f=@ k=byts]
^- @ ^- @
(rsh [0 64] (mul f (swp 3 dat:(siphash k item)))) (rsh [0 64] (mul f (rev 3 (siphash k item))))
:: +set-construct: return sorted hashes of scriptpubkeys :: +set-construct: return sorted hashes of scriptpubkeys
:: ::
++ set-construct ++ set-construct

View File

@ -36,8 +36,34 @@ export const globalTypes = {
export const decorators = [ export const decorators = [
(Story, context) => { (Story, context) => {
window.ship = 'sampel-palnet';
const theme = context.globals.theme === 'light' ? light : dark; const theme = context.globals.theme === 'light' ? light : dark;
useContactState.setState({
contacts: {
'~ridlur-figbud': {
status: 'please like and subscribe',
'last-updated': 1616609090555,
avatar: null,
cover: null,
bio: '',
nickname: 'Gav',
color: '0x26.3e0f',
groups: [],
},
'~sampel-palnet': {
status: 'A test status',
'last-updated': 1616609090555,
avatar: null,
cover: null,
bio: '',
nickname: 'You',
color: '0x26.3e0f',
groups: [],
}
},
});
useMetadataState.setState({ useMetadataState.setState({
associations: { associations: {
groups: { groups: {
@ -66,6 +92,25 @@ export const decorators = [
}, },
}, },
graph: { graph: {
'/ship/~bitbet-bolbel/links': {
metadata: {
preview: false,
vip: '',
title: 'Link Collection',
description: '',
creator: '~darrux-landes',
picture: '',
hidden: false,
config: {
graph: 'link',
},
'date-created': '~2020.4.6..21.53.30..dc68',
color: '0x0',
},
'app-name': 'graph',
resource: '/ship/~bitbet-bolbel/links',
group: '/ship/~bitbet-bolbel/urbit-community',
},
'/ship/~darrux-landes/development': { '/ship/~darrux-landes/development': {
metadata: { metadata: {
preview: false, preview: false,

View File

@ -2022,6 +2022,25 @@
} }
} }
}, },
"@figspec/components": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/@figspec/components/-/components-0.1.8.tgz",
"integrity": "sha512-jSk3aREVOvQRncC/2HyieeOPxkjQmKyWn6prfXC+ijOHwIeR6jzhv5C/ryk4ZW2msXYAtPtIBL+/iF2pQVJ/Sg==",
"dev": true,
"requires": {
"copy-to-clipboard": "^3.0.0",
"lit-element": "^2.4.0"
}
},
"@figspec/react": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@figspec/react/-/react-0.1.6.tgz",
"integrity": "sha512-oi0JL8uIXgJ+PWRl4LDxJ7WWa80E3jdYmi6wsHAFDq1vT0rKuyhqimEJzCezIrHHz4fXKpNRO98TO7ccma6hjw==",
"dev": true,
"requires": {
"@figspec/components": "^0.1.1"
}
},
"@hapi/hoek": { "@hapi/hoek": {
"version": "9.2.0", "version": "9.2.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.0.tgz", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.0.tgz",
@ -24185,6 +24204,21 @@
} }
} }
}, },
"lit-element": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-2.5.1.tgz",
"integrity": "sha512-ogu7PiJTA33bEK0xGu1dmaX5vhcRjBXCFexPja0e7P7jqLhTpNKYRPmE+GmiCaRVAbiQKGkUgkh/i6+bh++dPQ==",
"dev": true,
"requires": {
"lit-html": "^1.1.1"
}
},
"lit-html": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz",
"integrity": "sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA==",
"dev": true
},
"loader-runner": { "loader-runner": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
@ -29727,6 +29761,15 @@
"integrity": "sha512-7t+/wpKLanLzSnQPX8WAcuLCCeuSHoWdQuh9SB3xD0kNOM38DNf+0Oa+wmvxmYueRzkmh6IcdKFtvTa+ecgPDw==", "integrity": "sha512-7t+/wpKLanLzSnQPX8WAcuLCCeuSHoWdQuh9SB3xD0kNOM38DNf+0Oa+wmvxmYueRzkmh6IcdKFtvTa+ecgPDw==",
"dev": true "dev": true
}, },
"storybook-addon-designs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/storybook-addon-designs/-/storybook-addon-designs-6.0.0.tgz",
"integrity": "sha512-nwwUusxOmUt82ajTjfBDQfOU2zEO3WrHxG9J7rZmSLnoC42OC8P/FJtAuhL5JregCQildVbjIfeFfz7pMGJOjQ==",
"dev": true,
"requires": {
"@figspec/react": "^0.1.6"
}
},
"stream-browserify": { "stream-browserify": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",

View File

@ -105,6 +105,7 @@
"react-hot-loader": "^4.13.0", "react-hot-loader": "^4.13.0",
"sass": "^1.32.5", "sass": "^1.32.5",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"storybook-addon-designs": "^6.0.0",
"ts-mdast": "^1.0.0", "ts-mdast": "^1.0.0",
"typescript": "^4.2.4", "typescript": "^4.2.4",
"webpack": "^4.46.0", "webpack": "^4.46.0",

View File

@ -0,0 +1,16 @@
import { newApi } from './fakeApi';
describe('API shim', () => {
it('should allow deep accesses', () => {
const api = newApi();
expect(api.foo.bar.baz.toString()).toBe('[fakeApi]');
});
it('should return promise on call', () => {
const api = newApi();
const method = api.foo.bar.baz;
const res = method();
expect('then' in res).toBe(true);
});
});

View File

@ -0,0 +1,22 @@
export function newApi() {
const target = () => {};
const handler = {
apply: function (target, that, args) {
return Promise.resolve();
},
get: function (target, prop, receiver) {
const original = target[prop];
if (prop === 'toString') {
return () => '[fakeApi]';
} else if (typeof original === 'function') {
return target[prop].bind(target);
} else if (original) {
return target[prop];
}
return newApi();
}
};
return new Proxy(target, handler) as any;
}

View File

@ -0,0 +1,45 @@
import { Content, GraphNode, unixToDa } from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import bigInt, { BigInteger } from 'big-integer';
export const makeComment = (
author: string,
time: number,
parentIndex: string,
contents: Content[]
): [BigInteger, GraphNode] => {
const da = unixToDa(time);
const index = `${parentIndex}/${da.toString()}`;
const children = new BigIntOrderedMap<GraphNode>().gas([
[
bigInt.one,
{
post: {
index: `${index}/1`,
author,
'time-sent': time,
signatures: [],
contents: contents,
hash: null
},
children: new BigIntOrderedMap()
}
]
]);
return [
da,
{
post: {
index,
author,
'time-sent': time,
signatures: [],
contents: [],
hash: null
},
children
}
];
};

View File

@ -0,0 +1,29 @@
import { useEffect, useMemo, useRef } from 'react';
import _ from 'lodash';
export function useResize<T extends HTMLElement>(
callback: (entry: ResizeObserverEntry, observer: ResizeObserver) => void
) {
const ref = useRef<T>();
useEffect(() => {
function observer(
entries: ResizeObserverEntry[],
observer: ResizeObserver
) {
for (const entry of _.flatten(entries)) {
callback(entry, observer);
}
}
const resizeObs = new ResizeObserver(observer);
resizeObs.observe(ref.current, { box: 'border-box' });
return () => {
resizeObs.unobserve(ref.current);
};
}, [callback]);
const bind = useMemo(() => ({ ref }), [ref]);
return bind;
}

View File

@ -0,0 +1,34 @@
import { useField } from 'formik';
import { MutableRefObject, useCallback, useMemo } from 'react';
import useStorage from './useStorage';
export function useUrlField(
id: string,
ref: MutableRefObject<HTMLInputElement>
) {
const [field, meta, helpers] = useField(id);
const { setValue, setError } = helpers;
const storage = useStorage();
const { uploadDefault, canUpload } = storage;
const onImageUpload = useCallback(async () => {
const file = ref.current?.files?.item(0);
if (!file || !canUpload) {
return;
}
try {
const url = await uploadDefault(file);
setValue(url);
} catch (e) {
setError(e.message);
}
}, [ref.current, uploadDefault, canUpload, setValue]);
const extStorage = useMemo(() => ({ ...storage, onImageUpload }), [
storage,
onImageUpload
]);
return [field, meta, helpers, extStorage] as const;
}

View File

@ -0,0 +1,118 @@
import React from 'react';
import { Meta } from '@storybook/react';
import { withDesign } from 'storybook-addon-designs';
import { Col, Row } from '@tlon/indigo-react';
import { LinkBlockItem } from '~/views/apps/links/components/LinkBlockItem';
import { createPost, GraphNode } from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
export default {
title: 'Collections/BlockItem',
component: LinkBlockItem,
decorators: [withDesign]
} as Meta;
const createLink = (text: string, url: string) => ({
post: createPost('sampel-palnet', [{ text }, { url }]),
children: new BigIntOrderedMap<GraphNode>()
});
export const Image = () => (
<Row flexWrap="wrap" m="2" width="700px" backgroundColor="white">
<LinkBlockItem
summary
m="2"
node={createLink(
'Gas',
'https://media.urbit.org/site/posts/essays/value-of-address-space-pt1.jpg'
)}
/>
<LinkBlockItem
summary
m="2"
node={createLink(
'Ocean',
'https://media.urbit.org/site/posts/essays/ocean.jpeg'
)}
/>
<LinkBlockItem
m="2"
size="512px"
node={createLink(
'Big Ocean',
'https://media.urbit.org/site/posts/essays/ocean.jpeg'
)}
/>
</Row>
);
Image.parameters = {
design: {
type: 'figma',
url:
'https://www.figma.com/file/ovD1mlsYDa0agyYTdvCmGr/Landscape?node-id=8228%3A11'
}
};
export const Fallback = () => (
<Col gapY="2" p="2" width="500px" backgroundColor="white">
<LinkBlockItem
node={createLink('', 'https://www.are.na/edouard-urcades/edouard')}
/>
<LinkBlockItem node={createLink('', 'https://thejaymo.net')} />
</Col>
);
Fallback.parameters = {
design: {
type: 'figma',
url:
'https://www.figma.com/file/ovD1mlsYDa0agyYTdvCmGr/Landscape?node-id=8228%3A57'
}
};
export const Audio = () => (
<Col gapY="2" p="2" width="500px" backgroundColor="white">
<LinkBlockItem
node={createLink(
'Artist · Track',
'https://rovnys-public.s3.amazonaws.com/urbit-from-the-outside-in-1.m4a'
)}
/>
</Col>
);
Audio.parameters = {
design: {
type: 'figma',
url:
'https://www.figma.com/file/ovD1mlsYDa0agyYTdvCmGr/Landscape?node-id=8229%3A0'
}
};
export const Youtube = () => (
<Col gapY="2" p="2" width="500px" backgroundColor="white">
<LinkBlockItem
node={createLink(
'Artist · Track',
'https://www.youtube.com/watch?v=M04AKTCDavc&t=1s'
)}
/>
<LinkBlockItem
summary
node={createLink(
'Artist · Track',
'https://www.youtube.com/watch?v=M04AKTCDavc&t=1s'
)}
/>
</Col>
);
Youtube.parameters = {
design: {
type: 'figma',
url:
'https://www.figma.com/file/ovD1mlsYDa0agyYTdvCmGr/Landscape?node-id=8229%3A0'
}
};

View File

@ -0,0 +1,78 @@
import React from 'react';
import { Meta } from '@storybook/react';
import { withDesign } from 'storybook-addon-designs';
import { Box } from '@tlon/indigo-react';
import { LinkDetail } from '~/views/apps/links/components/LinkDetail';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import { GraphNode } from '@urbit/api';
import useMetadataState from '~/logic/state/metadata';
import { makeComment } from '~/logic/lib/fixtures';
import moment from 'moment';
export default {
title: 'Collections/LinkDetail',
component: LinkDetail,
decorators: [withDesign]
} as Meta;
const nodeIndex = '/170141184504850861030994857749504231211';
const node = {
post: {
index: '/170141184504850861030994857749504231211',
author: 'fabled-faster',
'time-sent': 1609969377513,
signatures: [],
contents: [
{ text: 'IMG_20200827_150753' },
{
url:
'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2021.1.06..21.42.48-structure-0001.png'
}
],
hash: null
},
children: new BigIntOrderedMap<GraphNode>().gas([
makeComment(
'ridlur-figbud',
moment().hour(12).minute(34).valueOf(),
nodeIndex,
[{ text: 'Beautiful' }]
),
makeComment(
'roslet-tanner',
moment().hour(12).minute(34).valueOf(),
nodeIndex,
[{ text: 'where did you find this?' }]
),
makeComment(
'fabled-faster',
moment().hour(12).minute(34).valueOf(),
nodeIndex,
[{ text: 'I dont\'t remember lol' }]
)
])
};
export const Image = () => {
const association = useMetadataState(
s => s.associations.graph['/ship/~bitbet-bolbel/links']
);
return (
<Box width="1166px" p="1" backgroundColor="white">
<LinkDetail
baseUrl="/"
node={node}
association={association}
/>
</Box>
);
};
Image.parameters = {
design: {
type: 'figma',
url:
'https://www.figma.com/file/ovD1mlsYDa0agyYTdvCmGr/Landscape?node-id=8303%3A591'
}
};

View File

@ -0,0 +1 @@

View File

@ -12,7 +12,7 @@ export default {
} as Meta; } as Meta;
const Template: Story<RemoteContentProps> = args => ( const Template: Story<RemoteContentProps> = args => (
<Box backgroundColor="white" p="2" width="500px"> <Box backgroundColor="white" p="2" width="800px">
<RemoteContent {...args} /> <RemoteContent {...args} />
</Box> </Box>
); );
@ -38,3 +38,15 @@ Twitter.args = {
// massive test flake // massive test flake
unfold: false unfold: false
}; };
export const Image = Template.bind({});
Image.args = {
url: 'https://pbs.twimg.com/media/E343N9_UUAIm0Iw.jpg'
};
export const Fallback = Template.bind({});
Fallback.args = {
url: 'https://www.are.na/edouard-urcades/edouard'
};

View File

@ -5,6 +5,7 @@ import {
} from '@urbit/api'; } from '@urbit/api';
import bigInt, { BigInteger } from 'big-integer'; import bigInt, { BigInteger } from 'big-integer';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { GraphScroller } from '~/views/components/GraphScroller';
import VirtualScroller from '~/views/components/VirtualScroller'; import VirtualScroller from '~/views/components/VirtualScroller';
import ChatMessage from './ChatMessage'; import ChatMessage from './ChatMessage';
import UnreadNotice from './UnreadNotice'; import UnreadNotice from './UnreadNotice';
@ -45,7 +46,7 @@ class ChatWindow extends Component<
ChatWindowProps, ChatWindowProps,
ChatWindowState ChatWindowState
> { > {
private virtualList: VirtualScroller<GraphNode> | null; private virtualList: VirtualScroller<bigInt.BigInteger, GraphNode> | null;
private prevSize = 0; private prevSize = 0;
private unreadSet = false; private unreadSet = false;
@ -257,7 +258,7 @@ class ChatWindow extends Component<
dismissUnread={this.props.dismissUnread} dismissUnread={this.props.dismissUnread}
onClick={this.scrollToUnread} onClick={this.scrollToUnread}
/>)} />)}
<VirtualScroller<GraphNode> <GraphScroller
ref={(list) => { ref={(list) => {
this.virtualList = list; this.virtualList = list;
}} }}

View File

@ -3,21 +3,21 @@ import { Group } from '@urbit/api';
import { Association } from '@urbit/api/metadata'; import { Association } from '@urbit/api/metadata';
import bigInt from 'big-integer'; import bigInt from 'big-integer';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Link, Route, Switch } from 'react-router-dom'; import { Link, Route, Switch, useLocation } from 'react-router-dom';
import { useQuery } from '~/logic/lib/useQuery';
import { Titlebar } from '~/views/components/Titlebar';
import useGraphState from '~/logic/state/graph'; import useGraphState from '~/logic/state/graph';
import useMetadataState from '~/logic/state/metadata'; import useMetadataState from '~/logic/state/metadata';
import { Comments } from '~/views/components/Comments';
import useGroupState from '../../../logic/state/group'; import useGroupState from '../../../logic/state/group';
import { LinkItem } from './components/LinkItem'; import { LinkBlocks } from './components/LinkBlocks';
import { LinkDetail } from './components/LinkDetail';
import './css/custom.css'; import './css/custom.css';
import LinkWindow from './LinkWindow'; import LinkWindow from './LinkWindow';
const emptyMeasure = () => {}; interface LinkResourceProps {
type LinkResourceProps = {
association: Association; association: Association;
baseUrl: string; baseUrl: string;
}; }
export function LinkResource(props: LinkResourceProps) { export function LinkResource(props: LinkResourceProps) {
const { const {
@ -41,6 +41,9 @@ export function LinkResource(props: LinkResourceProps) {
const graphs = useGraphState(state => state.graphs); const graphs = useGraphState(state => state.graphs);
const graph = graphs[resourcePath] || null; const graph = graphs[resourcePath] || null;
const graphTimesentMap = useGraphState(state => state.graphTimesentMap); const graphTimesentMap = useGraphState(state => state.graphTimesentMap);
const { query } = useQuery();
const isList = query.has('list');
const { pathname, search } = useLocation();
const getGraph = useGraphState(s => s.getGraph); const getGraph = useGraphState(s => s.getGraph);
useEffect(() => { useEffect(() => {
@ -48,19 +51,31 @@ export function LinkResource(props: LinkResourceProps) {
}, [association]); }, [association]);
const resourceUrl = `${baseUrl}/resource/link${rid}`; const resourceUrl = `${baseUrl}/resource/link${rid}`;
if (!graph) { if (!graph || !resource) {
return <Center width='100%' height='100%'><LoadingSpinner /></Center>; return <Center width='100%' height='100%'><LoadingSpinner /></Center>;
} }
const { title, description } = resource.metadata;
const titlebar = (back?: string) => (
<Titlebar back={back && `${back}${search}`} title={title} description={description} workspace={baseUrl} baseUrl={resourceUrl} >
<Link to={{ pathname, search: isList ? '' : '?list=true' }}>
<Text bold pr='3' color='blue'>
Switch to {!isList ? 'list' : 'grid' }
</Text>
</Link>
</Titlebar>
);
return ( return (
<Col alignItems="center" height="100%" width="100%" overflowY="hidden">
<Switch> <Switch>
<Route <Route
exact exact
path={relativePath('')} path={relativePath('')}
render={(props) => { render={(props) => {
return ( return (
// @ts-ignore state helper weirdness <Col minWidth="0" overflow="hidden">
{titlebar()}
{ isList ? /* @ts-ignore withState typings */ (
<LinkWindow <LinkWindow
key={rid} key={rid}
association={resource} association={resource}
@ -72,6 +87,10 @@ export function LinkResource(props: LinkResourceProps) {
pendingSize={Object.keys(graphTimesentMap[resourcePath] || {}).length} pendingSize={Object.keys(graphTimesentMap[resourcePath] || {}).length}
mb={3} mb={3}
/> />
) : (
<LinkBlocks graph={graph} association={resource} />
)}
</Col>
); );
}} }}
/> />
@ -79,7 +98,6 @@ export function LinkResource(props: LinkResourceProps) {
path={relativePath('/index/:index')} path={relativePath('/index/:index')}
render={(props) => { render={(props) => {
const index = bigInt(props.match.params.index); const index = bigInt(props.match.params.index);
const editCommentId = props.match.params.commentId || null;
if (!index) { if (!index) {
return <div>Malformed URL</div>; return <div>Malformed URL</div>;
@ -99,38 +117,19 @@ export function LinkResource(props: LinkResourceProps) {
); );
} }
return ( return (
<Col alignItems="center" overflowY="auto" width="100%"> <Col overflow="hidden">
<Col width="100%" p={3} maxWidth="768px"> {titlebar(relativePath(''))}
<Link to={resourceUrl}><Text px={3} bold>{'<- Back'}</Text></Link> <LinkDetail
<LinkItem
key={node.post.index}
resource={resourcePath}
node={node} node={node}
baseUrl={resourceUrl}
association={association} association={association}
group={group as Group} baseUrl={pathname}
path={resource?.group} flexGrow={1}
mt={3} maxHeight="calc(100% - 48px)"
measure={emptyMeasure}
/> />
<Comments
ship={ship}
name={name}
comments={node}
resource={resourcePath}
association={association}
editCommentId={editCommentId}
history={props.history}
baseUrl={`${resourceUrl}/index/${props.match.params.index}`}
group={group as Group}
px={3}
/>
</Col>
</Col> </Col>
); );
}} }}
/> />
</Switch> </Switch>
</Col>
); );
} }

View File

@ -1,11 +1,11 @@
import { Box, Col, Text } from '@tlon/indigo-react'; import { Box, Col, Text } from '@tlon/indigo-react';
import { Association, Graph, Group } from '@urbit/api'; import { Association, Graph, Group } from '@urbit/api';
import bigInt from 'big-integer'; import bigInt, { BigInteger } from 'big-integer';
import React, { import React, {
Component, ReactNode Component, ReactNode
} from 'react'; } from 'react';
import { isWriter } from '~/logic/lib/group'; import { isWriter } from '~/logic/lib/group';
import VirtualScroller from '~/views/components/VirtualScroller'; import { GraphScroller } from '~/views/components/GraphScroller';
import { LinkItem } from './components/LinkItem'; import { LinkItem } from './components/LinkItem';
import LinkSubmit from './components/LinkSubmit'; import LinkSubmit from './components/LinkSubmit';
@ -58,7 +58,6 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
...props, ...props,
node node
}; };
{ /* @ts-ignore calling @liam-fitzgerald on Uint8Array props */ }
if (this.canWrite() && index.eq(first ?? bigInt.zero)) { if (this.canWrite() && index.eq(first ?? bigInt.zero)) {
return ( return (
<React.Fragment key={index.toString()}> <React.Fragment key={index.toString()}>
@ -123,9 +122,9 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
} }
return ( return (
<Col width="100%" height="100%" position="relative"> <Col width="100%" height="calc(100% - 48px)" position="relative">
{/* @ts-ignore calling @liam-fitzgerald on virtualscroller */} {/* @ts-ignore calling @liam-fitzgerald on virtualscroller */}
<VirtualScroller <GraphScroller
origin="top" origin="top"
offset={0} offset={0}
style={style} style={style}

View File

@ -0,0 +1,127 @@
import React, { useCallback, useState } from 'react';
import { Box, LoadingSpinner, Action, Row } from '@tlon/indigo-react';
import useStorage from '~/logic/lib/useStorage';
import { StatelessUrlInput } from '~/views/components/StatelessUrlInput';
import { Association, resourceFromPath, createPost } from '@urbit/api';
import { parsePermalink, permalinkToReference } from '~/logic/lib/permalinks';
import useGraphState, { GraphState } from '~/logic/state/graph';
interface LinkBlockInputProps {
size: string;
url?: string;
association: Association;
}
const selGraph = (s: GraphState) => s.addPost;
export function LinkBlockInput(props: LinkBlockInputProps) {
const { size, association } = props;
const [url, setUrl] = useState(props.url || '');
const [valid, setValid] = useState(false);
const [focussed, setFocussed] = useState(false);
const addPost = useGraphState(selGraph);
const { uploading, canUpload, promptUpload } = useStorage();
const onFocus = useCallback(() => {
setFocussed(true);
}, []);
const onBlur = useCallback(() => {
setFocussed(false);
}, []);
const URLparser = new RegExp(
/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/
);
const handleChange = useCallback((val: string) => {
setUrl(val);
setValid(URLparser.test(val));
}, []);
const doPost = () => {
const text = '';
const { ship, name } = resourceFromPath(association.resource);
if (!(valid || url)) {
return;
}
const contents = url.startsWith('web+urbitgraph:/')
? [{ text }, permalinkToReference(parsePermalink(url)!)]
: [{ text }, { url }];
const post = createPost(window.ship, contents);
addPost(ship, name, post).then(() => {
setUrl('');
setValid(false);
});
};
const onKeyPress = useCallback(
(e) => {
if (e.key === 'Enter') {
e.preventDefault();
doPost();
}
},
[doPost]
);
return (
<Box
height={size}
width={size}
border="1"
borderColor="lightGray"
borderRadius="2"
alignItems="center"
display="flex"
justifyContent="center"
flexDirection="column"
p="2"
position="relative"
>
{uploading ? (
<Box
display="flex"
width="100%"
height="100%"
position="absolute"
left={0}
right={0}
bg="white"
zIndex={9}
alignItems="center"
justifyContent="center"
>
<LoadingSpinner />
</Box>
) : (
<StatelessUrlInput
value={url}
onChange={handleChange}
canUpload={canUpload}
onFocus={onFocus}
focussed={focussed}
onBlur={onBlur}
promptUpload={promptUpload}
onKeyPress={onKeyPress}
leftOffset="24px"
/>
)}
<Row
position="absolute"
right="0"
bottom="0"
p="2"
justifyContent="row-end"
>
<Action onClick={doPost} disabled={!valid} backgroundColor="white">
Post
</Action>
</Row>
</Box>
);
}

View File

@ -0,0 +1,125 @@
import React from 'react';
import {
Icon,
Center,
Row,
Text,
Col,
Box,
CenterProps
} from '@tlon/indigo-react';
import { hasProvider } from 'oembed-parser';
import { AUDIO_REGEX, IMAGE_REGEX } from '~/views/components/RemoteContent';
import { AudioPlayer } from '~/views/components/AudioPlayer';
import { useHistory } from 'react-router';
import { useHovering } from '~/logic/lib/util';
import Author from '~/views/components/Author';
import {
GraphNode,
ReferenceContent,
TextContent,
UrlContent
} from '@urbit/api';
import {
RemoteContentEmbedFallback,
RemoteContentImageEmbed,
RemoteContentOembed,
RemoteContentPermalinkEmbed
} from '~/views/components/RemoteContent/embed';
import { PermalinkEmbed } from '../../permalinks/embed';
import { referenceToPermalink } from '~/logic/lib/permalinks';
export interface LinkBlockItemProps {
node: GraphNode;
size?: CenterProps['height'];
border?: CenterProps['border'];
summary?: boolean;
}
export function LinkBlockItem(props: LinkBlockItemProps & CenterProps) {
const { node, summary, size, m, border = 1, ...rest } = props;
const { post, children } = node;
const { contents, index, author } = post;
const [{ text: title }, ...content] = contents as [
TextContent,
UrlContent | ReferenceContent
];
let url = '';
if ('url' in content?.[0]) {
url = content[0].url;
}
const isReference = 'reference' in content[0];
const isImage = IMAGE_REGEX.test(url);
const isAudio = AUDIO_REGEX.test(url);
const isOembed = hasProvider(url);
const history = useHistory();
const { hovering, bind } = useHovering();
const onClick = () => {
const { pathname, search } = history.location;
history.push(`${pathname}/index${index}${search}`);
};
return (
<Center
onClick={onClick}
border={border}
borderColor="lightGray"
position="relative"
borderRadius="1"
height={size}
width={size}
m={m}
maxHeight="100%"
{...rest}
{...bind}
>
{isReference ? (
summary ? (
<RemoteContentPermalinkEmbed
reference={content[0] as ReferenceContent}
/>
) : (
<PermalinkEmbed
link={referenceToPermalink(content[0] as ReferenceContent).link}
transcluded={0}
/>
)
) : isImage ? (
<RemoteContentImageEmbed url={url} />
) : isAudio ? (
<AudioPlayer title={title} url={url} />
) : isOembed ? (
<RemoteContentOembed tall={!summary} renderUrl={false} url={url} thumbnail={summary} />
) : (
<RemoteContentEmbedFallback url={url} />
)}
<Box
backgroundColor="white"
display={summary && hovering ? 'block' : 'none'}
width="100%"
height="64px"
position="absolute"
left="0"
bottom="0"
>
<Col width="100%" height="100%" p="2" justifyContent="space-between">
<Row justifyContent="space-between" width="100%">
<Text textOverflow="ellipsis" whiteSpace="nowrap" overflow="hidden">
{title}
</Text>
<Row gapX="1" alignItems="center">
<Icon icon="Chat" color="black" />
<Text>{children.size}</Text>
</Row>
</Row>
<Row width="100%">
<Author ship={author} date={post['time-sent']} showImage></Author>
</Row>
</Col>
</Box>
</Center>
);
}

View File

@ -0,0 +1,118 @@
import { Col, Row, Text } from '@tlon/indigo-react';
import { Association, Graph, GraphNode } from '@urbit/api';
import React, { useCallback, useState, useMemo } from 'react';
import _ from 'lodash';
import { useResize } from '~/logic/lib/useResize';
import { LinkBlockItem } from './LinkBlockItem';
import { LinkBlockInput } from './LinkBlockInput';
import useLocalState from '~/logic/state/local';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import bigInt from 'big-integer';
import { BlockScroller } from '~/views/components/BlockScroller';
export interface LinkBlocksProps {
graph: Graph;
association: Association;
}
const style = {
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
};
export function LinkBlocks(props: LinkBlocksProps) {
const { association } = props;
const [linkSize, setLinkSize] = useState(250);
const linkSizePx = `${linkSize}px`;
const isMobile = useLocalState(s => s.mobile);
const colCount = useMemo(() => (isMobile ? 2 : 5), [isMobile]);
const bind = useResize<HTMLDivElement>(
useCallback(
(entry) => {
const { width } = entry.target.getBoundingClientRect();
setLinkSize((width - 8) / colCount - 8);
},
[colCount]
)
);
const orm = useMemo(() => {
const nodes = [null, ...Array.from(props.graph)];
const chunks = _.chunk(nodes, colCount);
return new BigIntOrderedMap<[bigInt.BigInteger, GraphNode][]>().gas(
chunks.reverse().map((chunk, i) => {
return [bigInt(i), chunk];
})
);
}, [props.graph]);
const renderItem = useCallback(
React.forwardRef<any, any>(({ index }, ref) => {
const chunk = orm.get(index);
return (
<Row
ref={ref}
flexShrink={0}
my="2"
px="2"
gapX="2"
width="100%"
height={linkSizePx}
>
{chunk.map((block) => {
if (!block) {
return (
<LinkBlockInput
size={linkSizePx}
association={association}
/>
);
}
const [i, node] = block;
return typeof node.post === 'string' ? (
<Col
key={i.toString()}
alignItems="center"
justifyContent="center"
height={linkSizePx}
width={linkSizePx}
>
<Text>This link has been deleted</Text>
</Col>
) : (
<LinkBlockItem
key={i.toString()}
size={linkSizePx}
node={node}
summary
/>
);
})}
</Row>
);
}),
[orm, linkSizePx]
);
return (
<Col overflowX="hidden" overflowY="auto" height="calc(100% - 48px)" {...bind}>
<BlockScroller
origin="top"
offset={0}
style={style}
data={orm}
averageHeight={100}
size={orm.size}
pendingSize={0}
renderer={renderItem}
loadRows={() => Promise.resolve(true)}
/>
</Col>
);
}

View File

@ -0,0 +1,65 @@
import { Col, Row, RowProps } from '@tlon/indigo-react';
import { Association, GraphNode, TextContent, UrlContent } from '@urbit/api';
import React from 'react';
import { useGroup } from '~/logic/state/group';
import Author from '~/views/components/Author';
import Comments from '~/views/components/Comments';
import { TruncatedText } from '~/views/components/TruncatedText';
import { LinkBlockItem } from './LinkBlockItem';
export interface LinkDetailProps extends RowProps {
node: GraphNode;
association: Association;
baseUrl: string;
}
export function LinkDetail(props: LinkDetailProps) {
const { node, association, baseUrl, ...rest } = props;
const group = useGroup(association.group);
const { post } = node;
const [{ text: title }] = post.contents as [TextContent, UrlContent];
return (
/* @ts-ignore indio props?? */
<Row flexDirection={['column', 'column', 'row']} {...rest}>
<LinkBlockItem flexGrow={1} border={0} node={node} />
<Col
flexShrink={0}
width={['100%', '100%', '350px']}
flexGrow={0}
gapY="4"
borderLeft="1"
borderColor="lightGray"
py="4"
>
<Col px="4" gapY="2">
{title.length > 0 ? (
<TruncatedText fontWeight="medium" lineHeight="tall">
{title}
</TruncatedText>
) : null}
<Author
sigilPadding={4}
size={24}
ship={post.author}
showImage
date={post['time-sent']}
/>
</Col>
<Col
height="100%"
overflowY="auto"
borderTop="1"
borderTopColor="lightGray"
p="4"
>
<Comments
association={association}
comments={node}
baseUrl={baseUrl}
group={group}
/>
</Col>
</Col>
</Row>
);
}

View File

@ -17,7 +17,6 @@ interface LinkItemProps {
association: Association; association: Association;
resource: string; resource: string;
group: Group; group: Group;
path: string;
baseUrl: string; baseUrl: string;
mt?: number; mt?: number;
measure?: any; measure?: any;
@ -35,7 +34,10 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
return <Redirect to="/~404" />; return <Redirect to="/~404" />;
} }
const remoteRef = useRef<typeof RemoteContent | null>(null); const remoteRef = useRef<HTMLDivElement>(null);
const setRef = useCallback((el: HTMLDivElement | null ) => {
remoteRef.current = el;
}, []);
const index = node.post.index.split('/')[1]; const index = node.post.index.split('/')[1];
const markRead = useCallback(() => { const markRead = useCallback(() => {
@ -100,7 +102,7 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
const unreads = useHarkState(state => state.unreads?.[appPath]); const unreads = useHarkState(state => state.unreads?.[appPath]);
const commColor = (unreads?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray'; const commColor = (unreads?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray';
// @ts-ignore hark will have to choose between sets and numbers // @ts-ignore hark will have to choose between sets and numbers
const isUnread = (unreads?.['/']?.unreads ?? new Set()).has(node.post.index); const isUnread = unreads?.['/']?.unreads?.has?.(node.post.index);
return ( return (
<Box <Box
@ -125,7 +127,7 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
overflow="hidden" overflow="hidden"
onClick={markRead} onClick={markRead}
> >
<Text p={2}>{contents[0].text}</Text> {contents[0].text ? <Text p={2}>{contents[0].text}</Text> : null}
{ 'reference' in contents[1] ? ( { 'reference' in contents[1] ? (
<> <>
<Rule /> <Rule />
@ -134,34 +136,11 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
) : ( ) : (
<> <>
<RemoteContent <RemoteContent
ref={(r) => { embedRef={setRef}
// @ts-ignore RemoteContent weirdness
remoteRef.current = r;
}}
// @ts-ignore RemoteContent weirdness // @ts-ignore RemoteContent weirdness
renderUrl={false} renderUrl={false}
url={href} url={href}
text={contents[0].text} tall
unfold={true}
style={{ alignSelf: 'center' }}
oembedProps={{
p: 2,
className: 'links embed-container',
onClick: markRead
}}
imageProps={{
marginLeft: 'auto',
marginRight: 'auto',
display: 'block'
}}
textProps={{
overflow: 'hidden',
color: 'black',
display: 'block',
alignSelf: 'center',
style: { textOverflow: 'ellipsis', whiteSpace: 'pre', width: '100%' },
p: 2
}}
/> />
<Text color="gray" p={2} flexShrink={0}> <Text color="gray" p={2} flexShrink={0}>
<Anchor target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }} href={href}> <Anchor target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }} href={href}>

View File

@ -1,9 +1,10 @@
import { BaseInput, Box, Button, LoadingSpinner, Text } from '@tlon/indigo-react'; import { BaseInput, Box, Button, LoadingSpinner } from '@tlon/indigo-react';
import { hasProvider } from 'oembed-parser'; import { hasProvider } from 'oembed-parser';
import React, { useCallback, useState, DragEvent, useEffect } from 'react'; import React, { useCallback, useState, DragEvent, useEffect } from 'react';
import { parsePermalink, permalinkToReference } from '~/logic/lib/permalinks'; import { parsePermalink, permalinkToReference } from '~/logic/lib/permalinks';
import { useFileDrag } from '~/logic/lib/useDrag'; import { useFileDrag } from '~/logic/lib/useDrag';
import useStorage from '~/logic/lib/useStorage'; import useStorage from '~/logic/lib/useStorage';
import { StatelessUrlInput } from '~/views/components/StatelessUrlInput';
import SubmitDragger from '~/views/components/SubmitDragger'; import SubmitDragger from '~/views/components/SubmitDragger';
import useGraphState from '~/logic/state/graph'; import useGraphState from '~/logic/state/graph';
import { createPost } from '@urbit/api'; import { createPost } from '@urbit/api';
@ -20,7 +21,6 @@ const LinkSubmit = (props: LinkSubmitProps) => {
const addPost = useGraphState(s => s.addPost); const addPost = useGraphState(s => s.addPost);
const [submitFocused, setSubmitFocused] = useState(false); const [submitFocused, setSubmitFocused] = useState(false);
const [urlFocused, setUrlFocused] = useState(false);
const [linkValue, setLinkValue] = useState(''); const [linkValue, setLinkValue] = useState('');
const [linkTitle, setLinkTitle] = useState(''); const [linkTitle, setLinkTitle] = useState('');
const [disabled, setDisabled] = useState(false); const [disabled, setDisabled] = useState(false);
@ -35,7 +35,7 @@ const LinkSubmit = (props: LinkSubmitProps) => {
setDisabled(true); setDisabled(true);
const parentIndex = props.parentIndex || ''; const parentIndex = props.parentIndex || '';
const post = createPost(`~${window.ship}`, contents, parentIndex); const post = createPost(window.ship, contents, parentIndex);
addPost( addPost(
`~${props.ship}`, `~${props.ship}`,
@ -135,26 +135,6 @@ const LinkSubmit = (props: LinkSubmitProps) => {
} }
}; };
const placeholder = <Text
gray
position="absolute"
px={2}
pt={2}
style={{ pointerEvents: 'none' }}
>{canUpload
? <>
Drop or{' '}
<Text
cursor='pointer'
color='blue'
style={{ pointerEvents: 'all' }}
onClick={() => promptUpload().then(setLinkValue)}
>upload</Text>
{' '}a file, or paste a link here
</>
: 'Paste a link here'
}</Text>;
return ( return (
<> <>
{/* @ts-ignore archaic event type mismatch */} {/* @ts-ignore archaic event type mismatch */}
@ -182,25 +162,16 @@ const LinkSubmit = (props: LinkSubmitProps) => {
<LoadingSpinner /> <LoadingSpinner />
</Box>} </Box>}
{dragging && <SubmitDragger />} {dragging && <SubmitDragger />}
<Box position='relative'> <StatelessUrlInput
{!(linkValue || urlFocused || disabled) && placeholder}
<BaseInput
type="url"
pl={2}
width="100%"
py={2}
color="black"
backgroundColor="transparent"
onChange={e => setLinkValue(e.target.value)}
onBlur={() => [setUrlFocused(false), setSubmitFocused(false)]}
onFocus={() => [setUrlFocused(true), setSubmitFocused(true)]}
spellCheck="false"
// @ts-ignore archaic event type mismatch error
onPaste={onPaste}
onKeyPress={onKeyPress}
value={linkValue} value={linkValue}
promptUpload={promptUpload}
canUpload={canUpload}
onSubmit={doPost}
onChange={setLinkValue}
error={linkValid ? 'Invalid URL' : undefined}
onKeyPress={onKeyPress}
onPaste={onPaste}
/> />
</Box>
<BaseInput <BaseInput
type="text" type="text"
pl={2} pl={2}

View File

@ -4,7 +4,6 @@ import {
GroupNotifIndex, GroupNotifIndex,
GroupUpdate GroupUpdate
} from '@urbit/api'; } from '@urbit/api';
import bigInt from 'big-integer';
import _ from 'lodash'; import _ from 'lodash';
import React, { ReactElement } from 'react'; import React, { ReactElement } from 'react';
import { useAssocForGroup } from '~/logic/state/metadata'; import { useAssocForGroup } from '~/logic/state/metadata';
@ -35,7 +34,6 @@ interface GroupNotificationProps {
index: GroupNotifIndex; index: GroupNotifIndex;
contents: GroupNotificationContents; contents: GroupNotificationContents;
time: number; time: number;
timebox: bigInt.BigInteger;
} }
export function GroupNotification(props: GroupNotificationProps): ReactElement { export function GroupNotification(props: GroupNotificationProps): ReactElement {

View File

@ -132,7 +132,6 @@ export function Notification(props: NotificationProps) {
<GroupNotification <GroupNotification
index={index} index={index}
contents={c} contents={c}
timebox={props.time}
time={time} time={time}
/> />
</NotificationWrapper> </NotificationWrapper>

View File

@ -1,639 +0,0 @@
/* eslint-disable valid-jsdoc */
import { Box, Center, LoadingSpinner } from '@tlon/indigo-react';
import BigIntArrayOrderedMap, {
arrToString,
stringToArr
} from '@urbit/api/lib/BigIntArrayOrderedMap';
import bigInt, { BigInteger } from 'big-integer';
import _ from 'lodash';
import normalizeWheel from 'normalize-wheel';
import React, { Component, SyntheticEvent, useCallback } from 'react';
import styled from 'styled-components';
import { IS_IOS } from '~/logic/lib/platform';
import { VirtualContext } from '~/logic/lib/virtualContext';
import { clamp } from '~/logic/lib/util';
const ScrollbarLessBox = styled(Box)`
scrollbar-width: none !important;
::-webkit-scrollbar {
display: none;
}
`;
interface RendererProps {
index: BigInteger[];
scrollWindow: any;
ref: (el: HTMLElement | null) => void;
}
export { arrToString, stringToArr };
export function indexEqual(a: BigInteger[], b: BigInteger[]) {
const aLen = a.length;
const bLen = b.length;
if (aLen === bLen) {
let i = 0;
while (i < aLen && i < bLen) {
if (a[i].eq(b[i])) {
if (i === aLen - 1) {
return true;
}
i++;
} else {
return false;
}
}
}
return false;
}
interface VirtualScrollerProps<T> {
/**
* Start scroll from
*/
origin: 'top' | 'bottom';
/**
* Load more of the graph
*
* @returns boolean whether or not the graph is now fully loaded
*/
loadRows(newer: boolean): Promise<boolean>;
/**
* The data to iterate over
*/
data: BigIntArrayOrderedMap<T>;
/**
* The component to render the items
*
* @remarks
*
* This component must be referentially stable, so either use `useCallback` or
* a instance method. It must also forward the DOM ref from its root DOM node
*/
renderer: (props: RendererProps) => JSX.Element | null;
onStartReached?(): void;
onEndReached?(): void;
size: number;
pendingSize: number;
/**
* Average height of a single rendered item
*
* @remarks
* This is used primarily to calculate how many items should be onscreen. If
* size is variable, err on the lower side.
*/
averageHeight: number;
/**
* The offset to begin rendering at, on load.
*
* @remarks
* This is only looked up once, on component creation. Subsequent changes to
* this prop will have no effect
*/
offset: number;
style?: any;
/**
* Callback to execute when finished loading from start
*/
onBottomLoaded?: () => void;
}
interface VirtualScrollerState {
visibleItems: BigInteger[][];
scrollbar: number;
loaded: {
top: boolean;
bottom: boolean;
}
}
type LogLevel = 'scroll' | 'network' | 'bail' | 'reflow';
const logLevel = ['network', 'bail', 'scroll', 'reflow'] as LogLevel[];
const log = (level: LogLevel, message: string) => {
if(logLevel.includes(level)) {
console.log(`[${level}]: ${message}`);
}
};
const ZONE_SIZE = IS_IOS ? 20 : 80;
// nb: in this file, an index refers to a BigInteger[] and an offset refers to a
// number used to index a listified BigIntArrayOrderedMap
/**
* A virtualscroller for a `BigIntArrayOrderedMap`.
*
* VirtualScroller does not clean up or reset itself, so please use `key`
* to ensure a new instance is created for each BigIntArrayOrderedMap
*/
export default class ArrayVirtualScroller<T> extends Component<VirtualScrollerProps<T>, VirtualScrollerState> {
/**
* A reference to our scroll container
*/
window: HTMLDivElement | null = null;
/**
* A map of child refs, used to calculate scroll position
*/
private childRefs = new Map<string, HTMLElement>();
/**
* A set of child refs which have been unmounted
*/
private orphans = new Set<string>();
/**
* If saving, the bottommost visible element that we pin our scroll to
*/
private savedIndex: BigInteger[] | null = null;
/**
* If saving, the distance between the top of `this.savedEl` and the bottom
* of the screen
*/
private savedDistance = 0;
/**
* If saving, the number of requested saves. If several images are loading
* at once, we save the scroll pos the first time we see it and restore
* once the number of requested saves is zero
*/
private saveDepth = 0;
scrollLocked = true;
private pageSize = 50;
private pageDelta = 15;
private scrollRef: HTMLElement | null = null;
private cleanupRefInterval: NodeJS.Timeout | null = null;
constructor(props: VirtualScrollerProps<T>) {
super(props);
this.state = {
visibleItems: [],
scrollbar: 0,
loaded: {
top: false,
bottom: false
}
};
this.updateVisible = this.updateVisible.bind(this);
this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
this.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 200) : this.onScroll.bind(this);
this.scrollKeyMap = this.scrollKeyMap.bind(this);
this.setWindow = this.setWindow.bind(this);
this.restore = this.restore.bind(this);
this.startOffset = this.startOffset.bind(this);
}
componentDidMount() {
this.updateVisible(0);
this.loadTop();
this.loadBottom();
this.cleanupRefInterval = setInterval(this.cleanupRefs, 5000);
}
cleanupRefs = () => {
if(this.saveDepth > 0) {
return;
}
[...this.orphans].forEach((o) => {
this.childRefs.delete(o);
});
this.orphans.clear();
};
// manipulate scrollbar manually, to dodge change detection
updateScroll = IS_IOS ? () => {} : _.throttle(() => {
if(!this.window || !this.scrollRef) {
return;
}
const { scrollTop, scrollHeight } = this.window;
const unloaded = (this.startOffset() / this.pageSize);
const totalpages = this.props.size / this.pageSize;
const loaded = (scrollTop / scrollHeight);
const result = ((unloaded + loaded) / totalpages) * this.window.offsetHeight;
this.scrollRef.style[this.props.origin] = `${result}px`;
}, 50);
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState) {
const { size, pendingSize } = this.props;
if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) {
if((this.window?.scrollTop ?? 0) < ZONE_SIZE) {
this.scrollLocked = true;
this.updateVisible(0);
this.resetScroll();
}
}
}
componentWillUnmount() {
window.removeEventListener('keydown', this.invertedKeyHandler);
if(this.cleanupRefInterval) {
clearInterval(this.cleanupRefInterval);
}
this.cleanupRefs();
this.childRefs.clear();
}
startOffset() {
const { data } = this.props;
const startIndex = this.state.visibleItems?.[0];
if(!startIndex) {
return 0;
}
const dataList = Array.from(data);
const offset = dataList.findIndex(([i]) => indexEqual(i, startIndex));
if(offset === -1) {
// TODO: revisit when we remove nodes for any other reason than
// pending indices being removed
return 0;
}
return offset;
}
/**
* Updates the `startOffset` and adjusts visible items accordingly.
* Saves the scroll positions before repainting and restores it afterwards
*
* @param newOffset new startOffset
*/
updateVisible(newOffset: number) {
if (!this.window) {
return;
}
log('reflow', `from: ${this.startOffset()} to: ${newOffset}`);
const { data } = this.props;
const visibleItems = data.keys().slice(newOffset, newOffset + this.pageSize);
this.save();
this.setState({
visibleItems
});
requestAnimationFrame(() => {
this.restore();
});
}
scrollKeyMap(): Map<string, number> {
return new Map([
['ArrowUp', this.props.averageHeight],
['ArrowDown', this.props.averageHeight * -1],
['PageUp', this.window!.offsetHeight],
['PageDown', this.window!.offsetHeight * -1],
['Home', this.window!.scrollHeight],
['End', this.window!.scrollHeight * -1],
['Space', this.window!.offsetHeight * -1]
]);
}
invertedKeyHandler(event): void | false {
const map = this.scrollKeyMap();
if (map.has(event.code) && document.body.isSameNode(document.activeElement)) {
event.preventDefault();
event.stopImmediatePropagation();
let distance = map.get(event.code)!;
if (event.code === 'Space' && event.shiftKey) {
distance = distance * -1;
}
this.window!.scrollBy(0, distance);
return false;
}
}
setWindow(element) {
if (!element)
return;
this.save();
if (this.window) {
if (this.window.isSameNode(element)) {
return;
} else {
window.removeEventListener('keydown', this.invertedKeyHandler);
}
}
const { averageHeight } = this.props;
this.window = element;
this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 2));
this.pageDelta = Math.floor(this.pageSize / 4);
if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => {
event.preventDefault();
const normalized = normalizeWheel(event);
element.scrollBy(0, normalized.pixelY * -1);
return false;
}, { passive: false });
window.addEventListener('keydown', this.invertedKeyHandler, { passive: false });
}
this.restore();
}
resetScroll() {
if (!this.window) {
return;
}
this.window.scrollTop = 0;
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
}
loadTop = _.throttle(() => this.loadRows(false), 100);
loadBottom = _.throttle(() => this.loadRows(true), 100);
loadRows = async (newer: boolean) => {
const dir = newer ? 'bottom' : 'top';
if(this.state.loaded[dir]) {
return;
}
log('network', `loading more at ${dir}`);
const done = await this.props.loadRows(newer);
if(done) {
this.setState({
loaded: {
...this.state.loaded,
[dir]: done
}
});
if(newer && this.props.onBottomLoaded) {
this.props.onBottomLoaded();
}
}
};
onScroll(event: SyntheticEvent<HTMLElement>) {
this.updateScroll();
if(!this.window) {
// bail if we're going to adjust scroll anyway
return;
}
if(this.saveDepth > 0) {
log('bail', 'deep scroll queue');
return;
}
const { onStartReached, onEndReached } = this.props;
const windowHeight = this.window.offsetHeight;
const { scrollTop, scrollHeight } = this.window;
const startOffset = this.startOffset();
if (scrollTop < ZONE_SIZE) {
log('scroll', `Entered start zone ${scrollTop}`);
if (startOffset === 0) {
onStartReached && onStartReached();
this.scrollLocked = true;
}
const newOffset =
clamp(startOffset - this.pageDelta, 0, this.props.data.size - this.pageSize);
if(newOffset < 10) {
this.loadBottom();
}
if(newOffset !== startOffset) {
this.updateVisible(newOffset);
}
} else if (scrollTop + windowHeight >= scrollHeight - ZONE_SIZE) {
this.scrollLocked = false;
log('scroll', `Entered end zone ${scrollTop}`);
const newOffset =
clamp(startOffset + this.pageDelta, 0, this.props.data.size - this.pageSize);
if (onEndReached && startOffset === 0) {
onEndReached();
}
if((newOffset + (3 * this.pageSize) > this.props.data.size)) {
this.loadTop();
}
if(newOffset !== startOffset) {
this.updateVisible(newOffset);
}
} else {
this.scrollLocked = false;
}
}
restore() {
if(!this.window || !this.savedIndex) {
return;
}
if(this.saveDepth !== 1) {
log('bail', 'Deep restore');
return;
}
if(this.scrollLocked) {
this.resetScroll();
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth--;
});
return;
}
const ref = this.childRefs.get(arrToString(this.savedIndex));
if(!ref) {
return;
}
const newScrollTop = this.props.origin === 'top'
? this.savedDistance + ref.offsetTop
: this.window.scrollHeight - ref.offsetTop - this.savedDistance;
this.window.scrollTo(0, newScrollTop);
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth--;
});
}
scrollToIndex = (index: BigInteger[]) => {
let ref = this.childRefs.get(arrToString(index));
if(!ref) {
const offset = [...this.props.data].findIndex(([idx]) => indexEqual(idx, index));
if(offset === -1) {
return;
}
this.scrollLocked = false;
this.updateVisible(Math.max(offset - this.pageDelta, 0));
requestAnimationFrame(() => {
ref = this.childRefs.get(arrToString(index));
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
});
ref?.scrollIntoView({ block: 'center' });
});
} else {
ref?.scrollIntoView({ block: 'center' });
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
});
}
};
save() {
if(!this.window || this.savedIndex) {
return;
}
if(this.saveDepth !== 0) {
return;
}
log('scroll', 'saving...');
this.saveDepth++;
const { visibleItems } = this.state;
let bottomIndex = visibleItems[visibleItems.length - 1];
const { scrollTop, scrollHeight } = this.window;
const topSpacing = this.props.origin === 'top' ? scrollTop : scrollHeight - scrollTop;
const items = this.props.origin === 'top' ? visibleItems : [...visibleItems].reverse();
items.forEach((index) => {
const el = this.childRefs.get(arrToString(index));
if(!el) {
return;
}
const { offsetTop } = el;
if(offsetTop < topSpacing) {
bottomIndex = index;
}
});
if(!bottomIndex) {
// weird, shouldn't really happen
this.saveDepth--;
log('bail', 'no index found');
return;
}
this.savedIndex = bottomIndex;
const ref = this.childRefs.get(arrToString(bottomIndex))!;
if(!ref) {
this.saveDepth--;
log('bail', 'missing ref');
return;
}
const { offsetTop } = ref;
this.savedDistance = topSpacing - offsetTop;
}
// disabled until we work out race conditions with loading new nodes
shiftLayout = { save: () => {}, restore: () => {} };
setRef = (element: HTMLElement | null, index: BigInteger[]) => {
if(element) {
this.childRefs.set(arrToString(index), element);
this.orphans.delete(arrToString(index));
} else {
this.orphans.add(arrToString(index));
}
}
render() {
const {
visibleItems
} = this.state;
const {
origin = 'top',
renderer,
style
} = this.props;
const isTop = origin === 'top';
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const children = isTop ? visibleItems : [...visibleItems].reverse();
const atStart =
indexEqual(
(this.props.data.peekLargest()?.[0] ?? [bigInt.zero]),
(visibleItems?.[0] || [bigInt.zero])
);
const atEnd =
indexEqual(
(this.props.data.peekSmallest()?.[0] ?? [bigInt.zero]),
(visibleItems?.[visibleItems.length - 1] || [bigInt.zero])
);
return (
<>
{!IS_IOS && (<Box borderRadius={3} top ={isTop ? '0' : undefined}
bottom={!isTop ? '0' : undefined} ref={(el) => {
this.scrollRef = el;
}}
right={0} height="50px"
position="absolute" width="4px"
backgroundColor="lightGray"
/>)}
<ScrollbarLessBox overflowY='scroll' ref={this.setWindow} onScroll={this.onScroll} style={{ ...style, ...{ transform }, 'WebkitOverflowScrolling': 'auto' }}>
<Box style={{ transform, width: 'calc(100% - 4px)' }}>
{(isTop ? !atStart : !atEnd) && (
<Center height={5}>
<LoadingSpinner />
</Center>
)}
<VirtualContext.Provider value={this.shiftLayout}>
{children.map(index => (
<VirtualChild
key={arrToString(index)}
setRef={this.setRef}
index={index}
scrollWindow={this.window}
renderer={renderer}
/>
))}
</VirtualContext.Provider>
{(!isTop ? !atStart : !atEnd) &&
(<Center height={5}>
<LoadingSpinner />
</Center>)}
</Box>
</ScrollbarLessBox>
</>
);
}
}
interface VirtualChildProps {
index: BigInteger[];
scrollWindow: any;
setRef: (el: HTMLElement | null, index: BigInteger[]) => void;
renderer: (p: RendererProps) => JSX.Element | null;
}
function VirtualChild(props: VirtualChildProps) {
const { setRef, renderer: Renderer, ...rest } = props;
const ref = useCallback((el: HTMLElement | null) => {
setRef(el, props.index);
// VirtualChild should always be keyed on the index, so the index should be
// valid for the entire lifecycle of the component, hence no dependencies
}, []);
return <Renderer ref={ref} {...rest} />;
}

View File

@ -0,0 +1,81 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Action, Text, Icon, Row } from '@tlon/indigo-react';
function formatTime(num: number) {
const minutes = Math.floor(num / 60);
const seconds = Math.floor(num % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
export function AudioPlayer(props: { url: string; title?: string }) {
const { url, title = '' } = props;
const ref = useRef<HTMLAudioElement>();
const [playing, setPlaying] = useState(false);
const playPause = useCallback(
(e) => {
e.stopPropagation();
if (playing) {
ref.current.pause();
} else {
ref.current.play();
}
setPlaying(p => !p);
},
[ref, playing]
);
const [duration, setDuration] = useState(0);
const [progress, setProgress] = useState(0);
useEffect(() => {
if (playing) {
// eslint-disable-next-line no-inner-declarations
function updateProgress() {
setProgress(ref.current.currentTime);
}
const tim = setInterval(updateProgress, 250);
return () => {
tim && clearTimeout(tim);
};
}
}, [ref.current, playing]);
useEffect(() => {
ref.current.addEventListener('loadedmetadata', () => {
setDuration(ref.current.duration);
});
}, [ref.current]);
return (
<Row
backgroundColor="white"
alignItems="center"
justifyContent="center"
height="100%"
width="100%"
position="relative"
>
<Row p="2" position="absolute" left="0" top="0">
<Text lineHeight="tall">{title}</Text>
</Row>
<audio ref={ref} src={url} preload="metadata" />
<Action backgroundColor="white" height="unset" onClick={playPause}>
<Icon
height="32px"
width="32px"
color="black"
icon={playing ? 'LargeBullet' : 'TriangleEast'}
/>
</Action>
<Row p="2" position="absolute" right="0" bottom="0">
<Text lineHeight="tall">
{formatTime(progress)} / {formatTime(duration)}
</Text>
</Row>
</Row>
);
}

View File

@ -95,6 +95,9 @@ function Author(props: AuthorProps & PropFunc<typeof Box>): ReactElement {
fontWeight={showNickname ? '500' : '400'} fontWeight={showNickname ? '500' : '400'}
mr={showNickname ? 0 : '2px'} mr={showNickname ? 0 : '2px'}
mt={showNickname ? 0 : '0px'} mt={showNickname ? 0 : '0px'}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
title={showNickname ? cite(ship) : contact?.nickname} title={showNickname ? cite(ship) : contact?.nickname}
onClick={doCopy} onClick={doCopy}
> >

View File

@ -0,0 +1,27 @@
import { GraphNode } from '@urbit/api';
import bigInt, { BigInteger } from 'big-integer';
import React from 'react';
import VirtualScroller, { VirtualScrollerProps } from './VirtualScroller';
type BlockScrollerProps = Omit<
VirtualScrollerProps<BigInteger, [BigInteger, GraphNode][]>,
'keyEq' | 'keyToString' | 'keyBunt'
>;
const keyEq = (a: BigInteger, b: BigInteger) => a.eq(b);
const keyToString = (a: BigInteger) => a.toString();
export const BlockScroller = React.forwardRef<
VirtualScroller<BigInteger, [BigInteger, GraphNode][]>,
BlockScrollerProps
>((props, ref) => {
return (
<VirtualScroller<BigInteger, [BigInteger, GraphNode][]>
ref={ref}
{...props}
keyEq={keyEq}
keyToString={keyToString}
keyBunt={bigInt.zero}
/>
);
});

View File

@ -1,8 +1,19 @@
import { ManagedTextAreaField as TextArea } from '@tlon/indigo-react'; import {
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; Action,
import React from 'react'; BaseTextArea as TextArea,
Box,
Col,
Row
} from '@tlon/indigo-react';
import {
Formik,
FormikHelpers,
useField,
useFormikContext
} from 'formik';
import React, { useEffect } from 'react';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { AsyncButton } from './AsyncButton'; import { ShipImage } from './ShipImage';
interface FormSchema { interface FormSchema {
comment: string; comment: string;
@ -18,26 +29,62 @@ interface CommentInputProps {
actions: FormikHelpers<FormSchema> actions: FormikHelpers<FormSchema>
) => Promise<void>; ) => Promise<void>;
initial?: string; initial?: string;
loadingText?: string;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
} }
const SubmitTextArea = (props) => { const SubmitTextArea = (props) => {
const { submitForm } = useFormikContext<FormSchema>(); const { submitForm } = useFormikContext<FormSchema>();
const [field] = useField(props.id);
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if ((e.getModifierState('Control') || e.metaKey) && e.key === 'Enter') { if ((e.getModifierState('Control') || e.metaKey) && e.key === 'Enter') {
submitForm(); submitForm();
e.preventDefault();
} }
}; };
return <TextArea onKeyDown={onKeyDown} {...props} />; return (
<TextArea
lineHeight="tall"
backgroundColor="white"
color="black"
fontFamily="sans"
fontWeight="500"
fontSize="1"
flexGrow={1}
style={{ resize: 'vertical' }}
{...field}
onKeyDown={onKeyDown}
{...props}
/>
);
}; };
function FormikHelper(props: { initialValues: any }) {
const { initialValues } = props;
const { resetForm } = useFormikContext();
useEffect(() => {
resetForm(initialValues);
}, [initialValues]);
return null;
}
export default function CommentInput(props: CommentInputProps) { export default function CommentInput(props: CommentInputProps) {
const initialValues: FormSchema = { comment: props.initial || '' }; const initialValues: FormSchema = { comment: props.initial || '' };
const label = props.label || 'Add Comment'; const label = props.label || 'Comment';
const loading = props.loadingText || 'Commenting...';
return ( return (
<Row
marginLeft="-8px"
width="105%"
border="1"
borderColor="lightGray"
borderRadius="2"
flexShrink={0}
>
<Box p="2">
<ShipImage ship={`~${window.ship}`} />
</Box>
<Formik <Formik
validationSchema={formSchema} validationSchema={formSchema}
onSubmit={props.onSubmit} onSubmit={props.onSubmit}
@ -45,15 +92,27 @@ export default function CommentInput(props: CommentInputProps) {
validateOnBlur={false} validateOnBlur={false}
validateOnChange={false} validateOnChange={false}
> >
<Form> {({ submitForm }) => (
<Col pb="1" pr="2" pt="2" flexGrow={1}>
<FormikHelper initialValues={initialValues} />
<SubmitTextArea <SubmitTextArea
width="100%"
id="comment" id="comment"
placeholder={props.placeholder || ''} placeholder={props.placeholder || ''}
/> />
<AsyncButton mt={2} loadingText={loading} border type="submit"> <Action
type="submit"
my="1"
width="fit-content"
alignSelf="flex-end"
backgroundColor="white"
onClick={submitForm}
>
{label} {label}
</AsyncButton> </Action>
</Form> </Col>
)}
</Formik> </Formik>
</Row>
); );
} }

View File

@ -1,16 +1,18 @@
import { Action, Box, Row, Text } from '@tlon/indigo-react'; import { Action, Box, Col, Icon, Row, Text } from '@tlon/indigo-react';
import { Group, removePosts } from '@urbit/api'; import { Group, removePosts } from '@urbit/api';
import { GraphNode } from '@urbit/api/graph'; import { GraphNode } from '@urbit/api/graph';
import bigInt from 'big-integer'; import bigInt from 'big-integer';
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { roleForShip } from '~/logic/lib/group'; import { roleForShip } from '~/logic/lib/group';
import { getPermalinkForGraph } from '~/logic/lib/permalinks'; import { getPermalinkForGraph } from '~/logic/lib/permalinks';
import { getLatestCommentRevision } from '~/logic/lib/publish'; import { getLatestCommentRevision } from '~/logic/lib/publish';
import { useCopy } from '~/logic/lib/useCopy'; import { useCopy } from '~/logic/lib/useCopy';
import { useHovering } from '~/logic/lib/util';
import useMetadataState from '~/logic/state/metadata'; import useMetadataState from '~/logic/state/metadata';
import Author from '~/views/components/Author'; import Author from '~/views/components/Author';
import { ActionLink } from '~/views/components/Link';
import { GraphContent } from '../landscape/components/Graph/GraphContent'; import { GraphContent } from '../landscape/components/Graph/GraphContent';
import { Dropdown } from './Dropdown';
import airlock from '~/logic/api'; import airlock from '~/logic/api';
interface CommentItemProps { interface CommentItemProps {
@ -28,7 +30,10 @@ export function CommentItem(props: CommentItemProps) {
let { highlighted } = props; let { highlighted } = props;
const { ship, name, comment, group } = props; const { ship, name, comment, group } = props;
const association = useMetadataState( const association = useMetadataState(
useCallback(s => s.associations.graph[`/ship/${ship}/${name}`], [ship,name]) useCallback(s => s.associations.graph[`/ship/${ship}/${name}`], [
ship,
name
])
); );
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [, post] = getLatestCommentRevision(comment); const [, post] = getLatestCommentRevision(comment);
@ -63,30 +68,12 @@ return false;
highlighted = true; highlighted = true;
} }
} }
const { hovering, bind } = useHovering();
const commentIndexArray = (comment.post?.index || '/').split('/'); const commentIndexArray = (comment.post?.index || '/').split('/');
const commentIndex = commentIndexArray[commentIndexArray.length - 1]; const commentIndex = commentIndexArray[commentIndexArray.length - 1];
const adminLinks: JSX.Element[] = [];
const ourRole = roleForShip(group, window.ship); const ourRole = roleForShip(group, window.ship);
if (window.ship == post?.author && !disabled) {
adminLinks.push(
<Link to={{ pathname: props.baseUrl, search: `?edit=${commentIndex}` }}>
<Action bg="white">
Update
</Action>
</Link>
);
}
if ((window.ship == post?.author || ourRole == 'admin') && !disabled) {
adminLinks.push(
<Action bg="white" onClick={onDelete} destructive>
Delete
</Action>
);
}
useEffect(() => { useEffect(() => {
if (ref.current && props.highlighted) { if (ref.current && props.highlighted) {
ref.current.scrollIntoView({ block: 'center' }); ref.current.scrollIntoView({ block: 'center' });
@ -111,26 +98,68 @@ return false;
} }
return ( return (
<Box ref={ref} mb={4} opacity={post?.pending ? '60%' : '100%'}> <Box {...bind} ref={ref} mb={4} opacity={post?.pending ? '60%' : '100%'}>
<Row px={1} my={3}> <Row justifyContent="space-between" alignItems="center" my={1} pr="1">
<Author <Author
size={24}
sigilPadding={4}
showImage showImage
ship={post?.author} ship={post?.author}
date={post?.['time-sent']} date={post?.['time-sent']}
unread={props.unread} unread={props.unread}
group={group} group={group}
isRelativeTime isRelativeTime
></Author>
<Box opacity={hovering ? '100%' : '0%'}>
<Dropdown
alignX="right"
alignY="top"
options={
<Col
p="2"
border="1"
borderRadius="1"
borderColor="lightGray"
backgroundColor="white"
gapY="2"
> >
<Row px={2} gapX={2} height="18px"> <Action bg="white" onClick={doCopy}>
<Action bg="white" onClick={doCopy}>{copyDisplay}</Action> {copyDisplay}
{adminLinks} </Action>
</Row> {(window.ship == post?.author && !disabled) ? (
</Author> <ActionLink
color="blue"
to={{
pathname: props.baseUrl,
search: `?edit=${commentIndex}`
}}
>
Update
</ActionLink>
) : null}
{(window.ship == post?.author || ourRole == 'admin') &&
!disabled ? (
<Action
height="unset"
bg="white"
onClick={onDelete}
destructive
>
Delete
</Action>
) : null}
</Col>
}
>
<Icon icon="Ellipsis" />
</Dropdown>
</Box>
</Row> </Row>
<GraphContent <GraphContent
borderRadius={1} borderRadius={1}
p={1} p={1}
mb={1} mb={1}
ml="28px"
backgroundColor={highlighted ? 'washedBlue' : 'white'} backgroundColor={highlighted ? 'washedBlue' : 'white'}
transcluded={0} transcluded={0}
contents={post.contents} contents={post.contents}

View File

@ -1,5 +1,14 @@
import { Col } from '@tlon/indigo-react'; import { Col } from '@tlon/indigo-react';
import { createPost, createBlankNodeWithChildPost, Association, GraphNode, Group, markCountAsRead, addPost } from '@urbit/api'; import {
createPost,
createBlankNodeWithChildPost,
Association,
GraphNode,
Group,
markCountAsRead,
addPost,
resourceFromPath
} from '@urbit/api';
import bigInt from 'big-integer'; import bigInt from 'big-integer';
import { FormikHelpers } from 'formik'; import { FormikHelpers } from 'formik';
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
@ -15,12 +24,11 @@ import CommentInput from './CommentInput';
import { CommentItem } from './CommentItem'; import { CommentItem } from './CommentItem';
import airlock from '~/logic/api'; import airlock from '~/logic/api';
import useGraphState from '~/logic/state/graph'; import useGraphState from '~/logic/state/graph';
import { useHistory } from 'react-router';
interface CommentsProps { interface CommentsProps {
comments: GraphNode; comments: GraphNode;
association: Association; association: Association;
name: string;
ship: string;
baseUrl: string; baseUrl: string;
group: Group; group: Group;
} }
@ -29,14 +37,14 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
const { const {
association, association,
comments, comments,
ship,
name,
history,
baseUrl, baseUrl,
group, group,
...rest ...rest
} = props; } = props;
const addNode = useGraphState(s => s.addNode); const addNode = useGraphState(s => s.addNode);
const history = useHistory();
const { ship, name } = resourceFromPath(association.resource);
const { query } = useQuery(); const { query } = useQuery();
const selectedComment = useMemo(() => { const selectedComment = useMemo(() => {
@ -80,7 +88,7 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
const content = tokenizeMessage(comment); const content = tokenizeMessage(comment);
const post = createPost( const post = createPost(
`~${window.ship}`, window.ship,
content, content,
commentNode.post.index, commentNode.post.index,
parseInt((idx + 1).toString(), 10).toString() parseInt((idx + 1).toString(), 10).toString()
@ -130,15 +138,6 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
return ( return (
<Col {...rest} minWidth={0}> <Col {...rest} minWidth={0}>
{( !editCommentId && canComment ? <CommentInput onSubmit={onSubmit} /> : null )}
{( editCommentId ? (
<CommentInput
onSubmit={onEdit}
label='Edit Comment'
loadingText='Editing...'
initial={commentContent}
/>
) : null )}
{children.reverse() {children.reverse()
.map(([idx, comment], i) => { .map(([idx, comment], i) => {
const highlighted = selectedComment?.eq(idx) ?? false; const highlighted = selectedComment?.eq(idx) ?? false;
@ -156,6 +155,14 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
/> />
); );
})} })}
{( editCommentId ? (
<CommentInput
onSubmit={onEdit}
label='Edit Comment'
initial={commentContent}
/>
) : null )}
{( !editCommentId && canComment ? <CommentInput placeholder="Comment" onSubmit={onSubmit} /> : null )}
</Col> </Col>
); );
} }

View File

@ -0,0 +1,27 @@
import { GraphNode } from '@urbit/api';
import bigInt, { BigInteger } from 'big-integer';
import React from 'react';
import VirtualScroller, { VirtualScrollerProps } from './VirtualScroller';
type GraphScrollerProps = Omit<
VirtualScrollerProps<BigInteger, GraphNode>,
'keyEq' | 'keyToString' | 'keyBunt'
>;
const keyEq = (a: BigInteger, b: BigInteger) => a.eq(b);
const keyToString = (a: BigInteger) => a.toString();
export const GraphScroller = React.forwardRef<
VirtualScroller<BigInteger, GraphNode>,
GraphScrollerProps
>((props, ref) => {
return (
<VirtualScroller
ref={ref}
{...props}
keyEq={keyEq}
keyToString={keyToString}
keyBunt={bigInt.zero}
/>
);
});

View File

@ -88,7 +88,7 @@ const errorRetry = (meta, focus, uploading, clickUploadButton) => {
if (!focus && meta.error !== undefined) { if (!focus && meta.error !== undefined) {
return ( return (
<Text <Text
position='absolute' position="absolute"
left={2} left={2}
display='flex' display='flex'
height='100%' height='100%'
@ -98,7 +98,8 @@ const errorRetry = (meta, focus, uploading, clickUploadButton) => {
style={{ pointerEvents: 'none' }} style={{ pointerEvents: 'none' }}
onSelect={e => e.preventDefault} onSelect={e => e.preventDefault}
> >
{meta.error}{', '}please{' '} {meta.error()}
{', '}please{' '}
<Text <Text
fontWeight='500' fontWeight='500'
cursor='pointer' cursor='pointer'
@ -115,25 +116,24 @@ const errorRetry = (meta, focus, uploading, clickUploadButton) => {
return null; return null;
}; };
const clearButton = (field, uploading, clearEvt) => { export const clearButton = (field, uploading, clearEvt) => {
if (field.value && !uploading) { if (field.value && !uploading) {
return ( return (
<Box <Box
position='absolute' position="absolute"
right={0} right={0}
top={0}
px={1} px={1}
height='100%' height="100%"
cursor='pointer' cursor="pointer"
onClick={clearEvt} onClick={clearEvt}
backgroundColor='white' backgroundColor="white"
display='flex' display="flex"
alignItems='center' alignItems="center"
borderRadius='0 4px 4px 0' borderRadius="0 4px 4px 0"
border='1px solid' border="1px solid"
borderColor='lightGray' borderColor="lightGray"
> >
<Icon icon='X' /> <Icon icon="X" />
</Box> </Box>
); );
} }
@ -178,7 +178,7 @@ export function ImageInput(props: ImageInputProps): ReactElement {
return ( return (
<Box display="flex" flexDirection="column" {...props}> <Box display="flex" flexDirection="column" {...props}>
<Label htmlFor={id}>{label}</Label> {label ? <Label htmlFor={id}>{label}</Label> : null}
{caption ? ( {caption ? (
<Label mt={2} gray> <Label mt={2} gray>
{caption} {caption}
@ -201,10 +201,7 @@ export function ImageInput(props: ImageInputProps): ReactElement {
</Box> </Box>
{canUpload && ( {canUpload && (
<> <>
<Button <Button display="none" onClick={clickUploadButton} />
display='none'
onClick={clickUploadButton}
/>
<BaseInput <BaseInput
style={{ display: 'none' }} style={{ display: 'none' }}
type="file" type="file"

View File

@ -0,0 +1,13 @@
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { ActionProps, asAction } from '@tlon/indigo-react';
import { PropFunc } from '~/types';
interface AsLinkProps {
to: PropFunc<typeof RouterLink>['to'];
replace?: boolean;
}
export type ActionLinkProps = AsLinkProps & ActionProps;
export const ActionLink: React.FC<ActionLinkProps> = asAction(RouterLink);

View File

@ -1,373 +0,0 @@
import { BaseAnchor, BaseImage, Box, Icon, Row, Text } from '@tlon/indigo-react';
import { hasProvider } from 'oembed-parser';
import React, { Component, Fragment } from 'react';
import EmbedContainer from 'react-oembed-container';
import styled from 'styled-components';
import { VirtualContextProps, withVirtual } from '~/logic/lib/virtualContext';
import withState from '~/logic/lib/withState';
import useSettingsState from '~/logic/state/settings';
import { RemoteContentPolicy } from '~/types/local-update';
export type RemoteContentProps = VirtualContextProps & {
url: string;
text?: string;
unfold?: boolean;
renderUrl?: boolean;
remoteContentPolicy?: RemoteContentPolicy;
imageProps?: any;
audioProps?: any;
videoProps?: any;
oembedProps?: any;
textProps?: any;
style?: any;
transcluded?: any;
className?: string;
tall?: boolean;
}
interface RemoteContentState {
unfold: boolean;
embed: any | undefined;
noCors: boolean;
showArrow: boolean;
}
const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i);
const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i);
const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i);
const TruncatedText = styled(Text)`
white-space: pre;
text-overflow: ellipsis;
overflow: hidden;
min-width: 0;
`;
class RemoteContent extends Component<RemoteContentProps, RemoteContentState> {
private fetchController: AbortController | undefined;
containerRef: HTMLDivElement | null = null;
private saving = false;
private isOembed = false;
constructor(props) {
super(props);
this.state = {
unfold: props.unfold || false,
embed: undefined,
noCors: false,
showArrow: false
};
this.unfoldEmbed = this.unfoldEmbed.bind(this);
this.loadOembed = this.loadOembed.bind(this);
this.wrapInLink = this.wrapInLink.bind(this);
this.onError = this.onError.bind(this);
this.toggleArrow = this.toggleArrow.bind(this);
this.isOembed = hasProvider(props.url);
}
save = () => {
if(this.saving) {
return;
}
this.saving = true;
this.props.save();
};
restore = () => {
this.saving = false;
this.props.restore();
}
componentWillUnmount() {
if(this.saving) {
this.restore();
}
if (this.fetchController) {
this.fetchController.abort();
}
}
unfoldEmbed(event: Event) {
event.stopPropagation();
let unfoldState = this.state.unfold;
unfoldState = !unfoldState;
this.save();
this.setState({ unfold: unfoldState });
requestAnimationFrame(() => {
this.restore();
});
}
componentDidUpdate(prevProps, prevState) {
if(prevState.embed !== this.state.embed) {
// console.log('remotecontent: restoring');
// prevProps.shiftLayout.restore();
}
const { url } = this.props;
if(url !== prevProps.url && (IMAGE_REGEX.test(url) || AUDIO_REGEX.test(url) || VIDEO_REGEX.test(url))) {
this.save();
}
}
componentDidMount() {
}
onLoad = () => {
window.requestAnimationFrame(() => {
const { restore } = this;
restore();
});
}
loadOembed() {
this.fetchController = new AbortController();
fetch(`https://noembed.com/embed?url=${this.props.url}`, {
signal: this.fetchController.signal
})
.then(response => response.clone().json())
.then((result) => {
this.setState({ embed: result });
}).catch((error) => {
if (error.name === 'AbortError')
return;
this.setState({ embed: 'error' });
});
}
wrapInLink(contents, textOnly = false, unfold = false, unfoldEmbed = null, embedContainer = null, flushPadding = false, noOp = false) {
const { style, tall = false } = this.props;
const maxWidth = tall ? '100%' : 'min(500px, 100%)';
return (
<Box borderRadius={1} backgroundColor="washedGray" maxWidth={maxWidth}>
<Row
alignItems="center"
gapX={1}
>
{ textOnly && (<Icon ml={2} display="block" icon="ArrowExternal" />)}
{ !textOnly && unfoldEmbed && (
<Icon
ml={2}
display='block'
onClick={unfoldEmbed}
icon={unfold ? 'TriangleSouth' : 'TriangleEast'}
/>
)}
<BaseAnchor
display="flex"
p={flushPadding ? 0 : 2}
onClick={(e) => {
noOp ? e.preventDefault() : e.stopPropagation();
}}
href={this.props.url}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
minWidth={0}
width={textOnly ? 'calc(100% - 24px)' : 'fit-content'}
maxWidth={maxWidth}
style={{ color: 'inherit', textDecoration: 'none', ...style }}
target="_blank"
rel="noopener noreferrer"
cursor={noOp ? 'default' : 'pointer'}
>
{contents}
</BaseAnchor>
</Row>
{embedContainer}
</Box>
);
}
onError(e: Event) {
this.restore();
this.setState({ noCors: true });
}
toggleArrow() {
this.setState({ showArrow: !this.state.showArrow });
}
render() {
const {
remoteContentPolicy,
url,
text,
transcluded,
renderUrl = true,
imageProps = {},
audioProps = {},
videoProps = {},
oembedProps = {},
textProps = {},
style = {},
...props
} = this.props;
const { onLoad } = this;
const { noCors } = this.state;
const isImage = IMAGE_REGEX.test(url);
const isAudio = AUDIO_REGEX.test(url);
const isVideo = VIDEO_REGEX.test(url);
const isTranscluded = () => {
return transcluded;
};
if (isImage && remoteContentPolicy.imageShown) {
return this.wrapInLink(
<Box
position='relative'
onMouseEnter={this.toggleArrow}
onMouseLeave={this.toggleArrow}
>
<BaseAnchor
position='absolute'
top={2}
right={2}
display={this.state.showArrow ? 'block' : 'none'}
target='_blank'
rel='noopener noreferrer'
onClick={(e) => {
e.stopPropagation();
}}
href={url}
>
<Box
backgroundColor='white'
padding={2}
borderRadius='50%'
display='flex'
>
<Icon icon='ArrowNorthEast' />
</Box>
</BaseAnchor>
<BaseImage
{...(noCors ? {} : { crossOrigin: 'anonymous' })}
referrerPolicy='no-referrer'
flexShrink={0}
src={url}
style={style}
onLoad={onLoad}
onError={this.onError}
height='100%'
width='100%'
objectFit='contain'
borderRadius={2}
{...imageProps}
{...props}
/>
</Box>,
false,
false,
null,
null,
true,
isTranscluded()
);
} else if (isAudio && remoteContentPolicy.audioShown) {
return (
<>
{renderUrl
? this.wrapInLink(
<TruncatedText {...textProps}>{url}</TruncatedText>,
false,
this.state.unfold,
this.unfoldEmbed,
<audio
onClick={(e) => {
e.stopPropagation();
}}
controls
className={this.state.unfold ? 'db' : 'dn'}
src={url}
style={style}
onLoad={onLoad}
objectFit="contain"
height="100%"
width="100%"
{...audioProps}
{...props}
/>)
: null}
</>
);
} else if (isVideo && remoteContentPolicy.videoShown) {
return (
<>
{renderUrl
? this.wrapInLink(
<TruncatedText {...textProps}>{url}</TruncatedText>,
false,
this.state.unfold,
this.unfoldEmbed,
<video
onClick={(e) => {
e.stopPropagation();
}}
controls
className={this.state.unfold ? 'db' : 'dn pa2'}
src={url}
style={style}
onLoad={onLoad}
objectFit="contain"
height="100%"
width="100%"
{...videoProps}
{...props}
/>)
: null}
</>
);
} else if (this.isOembed && remoteContentPolicy.oembedShown) {
if (!this.state.embed || this.state.embed?.html === '') {
this.loadOembed();
}
const renderEmbed = !(this.state.embed !== 'error' && this.state.embed?.html);
const embed = <Box
mb={2}
width='100%'
flexShrink={0}
display={this.state.unfold ? 'block' : 'none'}
className='embed-container'
style={style}
onLoad={this.onLoad}
{...oembedProps}
{...props}
>
<TruncatedText
display={(renderUrl && this.state.embed?.title && this.state.embed.title !== url) ? 'inline-block' : 'none'}
fontWeight='bold' width='100%'
>
{this.state.embed?.title}
</TruncatedText>
{this.state.embed && this.state.embed.html && this.state.unfold
? <EmbedContainer markup={this.state.embed.html}>
<div className="embed-container" ref={(el) => {
this.onLoad();
this.containerRef = el;
}}
dangerouslySetInnerHTML={{ __html: this.state.embed.html }}
></div>
</EmbedContainer>
: null}
</Box>;
return (
<Fragment>
{renderUrl
? this.wrapInLink(
<TruncatedText {...textProps}>{url}</TruncatedText>,
renderEmbed,
this.state.unfold,
this.unfoldEmbed,
embed
) : embed}
</Fragment>
);
} else {
return renderUrl
? this.wrapInLink(<TruncatedText {...textProps}>{text || url}</TruncatedText>, true)
: null;
}
}
}
export default withState(withVirtual(RemoteContent), [[useSettingsState, ['remoteContentPolicy']]]) as React.ComponentType<Omit<RemoteContentProps, 'save' | 'restore' | 'remoteContentPolicy'> & { ref?: any }>;

View File

@ -0,0 +1,339 @@
import React, {
MouseEvent,
useState,
useEffect,
useCallback
} from 'react';
import styled from 'styled-components';
import UnstyledEmbedContainer from 'react-oembed-container';
import {
Box,
BaseAnchor,
BaseImage,
AllSystemProps,
allSystemStyle,
Icon,
Row,
Col
} from '@tlon/indigo-react';
import { TruncatedText } from '~/views/components/TruncatedText';
import { getModuleIcon, useHovering } from '~/logic/lib/util';
import { IconRef, PropFunc } from '~/types';
import { system } from 'styled-system';
import { Association, GraphConfig, ReferenceContent } from '@urbit/api';
import { Link } from 'react-router-dom';
import { referenceToPermalink } from '~/logic/lib/permalinks';
import useMetadataState from '~/logic/state/metadata';
import { RemoteContentWrapper } from './wrapper';
interface RemoteContentEmbedProps {
url: string;
noCors?: boolean;
}
function onStopProp<T extends HTMLElement>(e: MouseEvent<T>) {
e.stopPropagation();
}
type ImageProps = PropFunc<typeof BaseImage> & {
objectFit?: string;
};
const Image = styled.img(system({ objectFit: true }), ...allSystemStyle);
export function RemoteContentImageEmbed(
props: ImageProps & RemoteContentEmbedProps
) {
const { url, noCors = false, ...rest } = props;
const { hovering, bind } = useHovering();
return (
<Box height="100%" width="100%" position="relative" {...bind} {...rest}>
<BaseAnchor
position="absolute"
top={2}
right={2}
display={hovering ? 'block' : 'none'}
target="_blank"
rel="noopener noreferrer"
onClick={onStopProp}
href={url}
>
<Box
backgroundColor="white"
padding={2}
borderRadius="50%"
display="flex"
>
<Icon icon="ArrowNorthEast" />
</Box>
</BaseAnchor>
<Image
{...(noCors ? {} : { crossOrigin: 'anonymous' })}
referrerPolicy="no-referrer"
flexShrink={0}
src={url}
height="100%"
width="100%"
objectFit="contain"
borderRadius={2}
{...props}
/>
</Box>
);
}
type BaseAudioProps = AllSystemProps & {
objectFit?: string;
};
const BaseAudio = styled.audio<React.PropsWithChildren<BaseAudioProps>>(
system({ objectFit: true }),
...allSystemStyle
);
export function RemoteContentAudioEmbed(props: RemoteContentEmbedProps) {
const { url, noCors, ...rest } = props;
return (
<BaseAudio
onClick={onStopProp}
controls
src={url}
objectFit="contain"
height="24px"
width="100%"
minWidth={['90vw', '384px']}
{...(noCors ? {} : { crossOrigin: 'anonymous' })}
{...rest}
/>
);
}
type BaseVideoProps = AllSystemProps & {
objectFit?: string;
};
const BaseVideo = styled.video<React.PropsWithChildren<BaseVideoProps>>(
system({ objectFit: true }),
...allSystemStyle
);
export function RemoteContentVideoEmbed(
props: RemoteContentEmbedProps & PropFunc<typeof BaseVideo>
) {
const { url, noCors, ...rest } = props;
return (
<BaseVideo
onClick={onStopProp}
controls
src={url}
objectFit="contain"
height="100%"
width="100%"
{...(noCors ? {} : { crossOrigin: 'anonymous' })}
{...rest}
/>
);
}
const EmbedContainer = styled(UnstyledEmbedContainer)`
width: 100%;
height: 100%;
`;
const EmbedBox = styled.div<{ aspect?: number }>`
${p => p.aspect ? `
height: 0;
overflow: hidden;
padding-bottom: calc(100% / ${p.aspect});
position: relative;
` : `
height: auto;
width: 100%;
`}
& iframe {
height: 100%;
width: 100%;
${p => p.aspect && 'position: absolute;'}
}
`;
export function RemoteContentPermalinkEmbed(props: {
reference: ReferenceContent;
}) {
const { reference } = props;
const permalink = referenceToPermalink(reference);
if (permalink.type === 'graph') {
return <RemoteContentPermalinkEmbedGraph {...permalink} />;
} else if (permalink.type === 'group') {
return <RemoteContentPermalinkEmbedGroup {...permalink} />;
}
return null;
}
function RemoteContentPermalinkEmbedGroup(props: {
group: string;
link: string;
}) {
const { group, link } = props;
const association = useMetadataState(s => s.associations.groups[group]);
const title = association?.metadata?.title ?? group.slice(6);
return (
<RemoteContentPermalinkEmbedBase icon="Groups" title={title} link={link} />
);
}
function RemoteContentPermalinkEmbedGraph(props: {
graph: string;
link: string;
}) {
const { graph, link } = props;
const association = useMetadataState(
useCallback(s => s.associations.graph[graph] as Association | null, [
graph
])
);
const icon = association
? getModuleIcon((association.metadata.config as GraphConfig).graph as any)
: 'Groups';
const title = association?.metadata?.title ?? graph.slice(6);
return (
<RemoteContentPermalinkEmbedBase icon={icon} link={link} title={title} />
);
}
function RemoteContentPermalinkEmbedBase(props: {
icon: IconRef;
link: string;
title: string;
}) {
const { icon, link, title } = props;
return (
<Row maxWidth="100%" overflow="hidden" gapX="2" alignItems="center" p="2">
<Icon color="gray" icon={icon} />
{/* @ts-ignore TS doesn't forward types here */}
<Link to={link} component={TruncatedText} maxWidth="100%" gray>
{title}
</Link>
</Row>
);
}
type RemoteContentOembedProps = {
renderUrl?: boolean;
thumbnail?: boolean;
tall?: boolean;
} & RemoteContentEmbedProps &
PropFunc<typeof Box>;
export const RemoteContentOembed = React.forwardRef<
HTMLDivElement,
RemoteContentOembedProps
>((props, ref) => {
const { url, tall = false, renderUrl = false, thumbnail = false, ...rest } = props;
const [embed, setEmbed] = useState<any>();
const [aspect, setAspect] = useState<number | undefined>();
useEffect(() => {
const getEmbed = async () => {
try {
const search = new URLSearchParams({
url
});
if(!tall) {
search.append('maxwidth', '500');
}
const oembed = await (
await fetch(`https://noembed.com/embed?${search.toString()}`)
).json();
if('height' in oembed && typeof oembed.height === 'number' && 'width' in oembed && typeof oembed.width === 'number') {
const newAspect = (oembed.width / oembed.height);
setAspect(newAspect);
} else {
setAspect(undefined);
}
setEmbed(oembed);
} catch (e) {
console.error(e);
console.log(`${url} failed`);
}
};
getEmbed();
}, [url]);
const detail = (
<Col
mb={embed?.html && !thumbnail ? 0 : 2}
width="100%"
flexShrink={0}
height="100%"
justifyContent="center"
alignItems="center"
{...rest}
>
{thumbnail && embed?.['thumbnail_url'] ? (
<BaseImage
height="100%"
src={embed?.['thumbnail_url']}
style={{ objectFit: 'contain' }}
/>
) : !thumbnail && embed?.html ? (
<EmbedContainer markup={embed.html}>
<EmbedBox
ref={ref}
aspect={aspect}
dangerouslySetInnerHTML={{ __html: embed.html }}
></EmbedBox>
</EmbedContainer>
) : renderUrl ? (
<RemoteContentEmbedFallback url={url} />
) : null}
</Col>
);
if (!renderUrl) {
return detail;
}
return (
<RemoteContentWrapper url={url} detail={detail}>
<TruncatedText>{embed?.title ?? url}</TruncatedText>
</RemoteContentWrapper>
);
});
export function RemoteContentEmbedFallback(props: RemoteContentEmbedProps) {
const { url } = props;
return (
<Row maxWidth="100%" overflow="hidden" gapX="2" alignItems="center" p="2">
<Icon color="gray" icon="ArrowExternal" />
<TruncatedText maxWidth="100%" gray>
<BaseAnchor
href={url}
target="_blank"
rel="noopener noreferrer"
maxWidth="100%"
overflow="hidden"
whiteSpace="pre"
textOverflow="ellipsis"
color="gray"
>
{url}
</BaseAnchor>
</TruncatedText>
</Row>
);
}

View File

@ -0,0 +1,102 @@
import { hasProvider } from 'oembed-parser';
import React from 'react';
import useSettingsState from '~/logic/state/settings';
import {
RemoteContentAudioEmbed,
RemoteContentImageEmbed,
RemoteContentOembed,
RemoteContentVideoEmbed
} from './embed';
import { TruncatedText } from '~/views/components/TruncatedText';
import { RemoteContentWrapper } from './wrapper';
export interface RemoteContentProps {
/**
* Url to render
*/
url: string;
/**
* Should render the URL as part of the display of the RemoteContent.
*
* If false, then only the embedded content, if any will be rendered
*/
renderUrl?: boolean;
/**
* A ref to the div that contains an iframe
*/
embedRef?: (el: HTMLDivElement | null) => void;
/**
* Is inside transclusion
*/
transcluded?: any;
/**
* Render in a tall formatting context, e.g. images will take the full width
* of their containers etc.
*/
tall?: boolean;
}
export const IMAGE_REGEX = new RegExp(
/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i
);
export const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg|m4a)$/i);
export const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i);
const emptyRef = () => {};
export function RemoteContent(props: RemoteContentProps) {
const {
url,
embedRef = emptyRef,
transcluded,
tall = false,
renderUrl = true
} = props;
const remoteContentPolicy = useSettingsState(s => s.remoteContentPolicy);
const isImage = IMAGE_REGEX.test(url);
const isAudio = AUDIO_REGEX.test(url);
const isVideo = VIDEO_REGEX.test(url);
const isOembed = hasProvider(url);
const wrapperProps = {
url,
tall,
embedOnly: !renderUrl || tall
};
if (isImage && remoteContentPolicy.imageShown) {
return (
<RemoteContentWrapper {...wrapperProps} noOp={transcluded} replaced>
<RemoteContentImageEmbed url={url} />
</RemoteContentWrapper>
);
} else if (isAudio && remoteContentPolicy.audioShown) {
return (
<RemoteContentWrapper {...wrapperProps}>
<RemoteContentAudioEmbed url={url} />
</RemoteContentWrapper>
);
} else if (isVideo && remoteContentPolicy.videoShown) {
return (
<RemoteContentWrapper
{...wrapperProps}
detail={<RemoteContentVideoEmbed url={url} />}
>
<TruncatedText>{url}</TruncatedText>
</RemoteContentWrapper>
);
} else if (isOembed && remoteContentPolicy.oembedShown) {
return (
<RemoteContentOembed ref={embedRef} url={url} renderUrl={renderUrl} />
);
} else if (renderUrl) {
return (
<RemoteContentWrapper {...wrapperProps}>
<TruncatedText>{url}</TruncatedText>
</RemoteContentWrapper>
);
} else {
return null;
}
}
export default React.memo(RemoteContent);

View File

@ -0,0 +1,79 @@
import React, { MouseEvent, useCallback, useState } from 'react';
import { Row, Box, Icon, BaseAnchor } from '@tlon/indigo-react';
interface RemoteContentWrapperProps {
children: JSX.Element;
url: string;
detail?: JSX.Element;
flushPadding?: boolean;
noOp?: boolean;
tall?: boolean;
embedOnly?: boolean;
replaced?: boolean;
}
export function RemoteContentWrapper(props: RemoteContentWrapperProps) {
const {
url,
children,
detail = null,
replaced = false,
noOp = false,
tall = false,
embedOnly = false
} = props;
const [unfold, setUnfold] = useState(false);
const toggleUnfold = useCallback(() => {
setUnfold(s => !s);
}, []);
const onClick = useCallback(
(e: MouseEvent) => {
noOp ? e.preventDefault() : e.stopPropagation();
},
[noOp]
);
const maxWidth = tall ? '100%' : 'min(500px, 100%)';
if(embedOnly) {
return detail || children;
}
return (
<Box width={detail ? '100%' : 'fit-content'} borderRadius={1} backgroundColor="washedGray" maxWidth={maxWidth}>
<Row width="100%" alignItems="center" gapX={1}>
{(!detail && !replaced) ? (
<Icon ml={2} display="block" icon="ArrowExternal" />
) : !replaced ? (
<Icon
ml={2}
display="block"
onClick={toggleUnfold}
icon={unfold ? 'TriangleSouth' : 'TriangleEast'}
/>
) : null }
<BaseAnchor
display="flex"
p={replaced ? 0 : 2}
onClick={onClick}
href={url}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
minWidth={0}
width={detail ? 'calc(100% - 24px)' : replaced ? '100%' : 'fit-content'}
maxWidth={maxWidth}
style={{ color: 'inherit', textDecoration: 'none' }}
target="_blank"
rel="noopener noreferrer"
cursor={noOp ? 'default' : 'pointer'}
>
{children}
</BaseAnchor>
</Row>
{unfold ? detail : null}
</Box>
);
}

View File

@ -0,0 +1,49 @@
import { uxToHex } from '@urbit/api';
import React from 'react';
import { Box, BaseImage } from '@tlon/indigo-react';
import { useContact } from '~/logic/state/contact';
import useSettingsState from '~/logic/state/settings';
import { Sigil } from '~/logic/lib/sigil';
export function ShipImage(props: {
ship: string;
size?: number;
sigilClass?: string;
}) {
const { ship, size = 24, sigilClass = '' } = props;
const contact = useContact(ship);
const { hideAvatars } = useSettingsState(s => s.calm);
const color = contact?.color ? uxToHex(contact.color) : '000000';
return contact?.avatar && !hideAvatars ? (
<BaseImage
flexShrink={0}
src={contact.avatar}
height={size}
width={size}
style={{ objectFit: 'cover' }}
borderRadius={1}
display="inline-block"
/>
) : (
<Box
width={24}
height={24}
display="flex"
justifyContent="center"
alignItems="center"
backgroundColor={`#${color}`}
borderRadius={1}
className={sigilClass}
>
<Sigil
ship={ship}
size={16}
color={`#${color}`}
classes={sigilClass}
icon
/>
</Box>
);
}

View File

@ -0,0 +1,87 @@
import React, { ChangeEvent, useCallback, useMemo } from 'react';
import { Text, Box, BaseInput } from '@tlon/indigo-react';
import { PropFunc } from '~/types';
type StatelessUrlInputProps = PropFunc<typeof BaseInput> & {
value?: string;
error?: string;
focussed?: boolean;
disabled?: boolean;
onChange?: (value: string) => void;
promptUpload: () => Promise<string>;
canUpload: boolean;
placeholderOffset?: number | string | number[] | string[];
leftOffset?: number | string | number[] | string[];
};
export function StatelessUrlInput(props: StatelessUrlInputProps) {
const {
value,
focussed,
disabled,
onChange = () => {},
promptUpload,
canUpload,
placeholderOffset = 0,
leftOffset = 0,
...rest
} = props;
const placeholder = useMemo(
() => (
<Text
gray
position="absolute"
top={placeholderOffset}
left={leftOffset}
px={2}
pt={2}
style={{ pointerEvents: 'none' }}
>
{canUpload ? (
<>
Drop or{' '}
<Text
cursor="pointer"
color="blue"
style={{ pointerEvents: 'all' }}
onClick={() => promptUpload().then(onChange)}
>
upload
</Text>{' '}
a file, or paste a link here
</>
) : (
'Paste a link here'
)}
</Text>
),
[canUpload, promptUpload, onChange]
);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
},
[onChange]
);
return (
<Box position="relative">
{!(value || focussed || disabled) && placeholder}
<BaseInput
type="url"
p={2}
width="100%"
fontSize={1}
color="black"
backgroundColor="transparent"
onChange={handleChange}
spellCheck="false"
value={value}
{...rest}
/>
</Box>
);
}

View File

@ -0,0 +1,48 @@
import { BigInteger } from 'big-integer';
import React from 'react';
import VirtualScroller, { VirtualScrollerProps } from './VirtualScroller';
import { arrToString } from '@urbit/api/lib/BigIntArrayOrderedMap';
import { FlatGraphNode } from '@urbit/api';
type ThreadScrollerProps = Omit<
VirtualScrollerProps<BigInteger[], FlatGraphNode>,
'keyEq' | 'keyToString' | 'keyBunt'
>;
export function keyEq(a: BigInteger[], b: BigInteger[]) {
const aLen = a.length;
const bLen = b.length;
if (aLen === bLen) {
let i = 0;
while (i < aLen && i < bLen) {
if (a[i].eq(b[i])) {
if (i === aLen - 1) {
return true;
}
i++;
} else {
return false;
}
}
}
return false;
}
const keyBunt = [];
export const ThreadScroller = React.forwardRef<
VirtualScroller<BigInteger[], FlatGraphNode>,
ThreadScrollerProps
>((props: ThreadScrollerProps, ref) => {
return (
<VirtualScroller<BigInteger[], FlatGraphNode>
ref={ref}
{...props}
keyEq={keyEq}
keyToString={arrToString}
keyBunt={keyBunt}
/>
);
});

View File

@ -0,0 +1,117 @@
import React, { ReactNode, useCallback, useState } from 'react';
import { Icon, Box, Text } from '@tlon/indigo-react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import RichText from '~/views/components/RichText';
import urbitOb from 'urbit-ob';
import { useResize } from '~/logic/lib/useResize';
interface TitlebarProps {
children?: ReactNode;
description?: string;
title: string;
monoDescription?: boolean;
workspace: string;
baseUrl: string;
back?: string;
}
const TruncatedText = styled(RichText)`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
export function Titlebar(props: TitlebarProps) {
const { workspace, monoDescription = false, baseUrl, children, back } = props;
const [actionsWidth, setActionsWidth] = useState(0);
const bind = useResize<HTMLDivElement>(
useCallback((entry) => {
setActionsWidth(entry.target.getBoundingClientRect().width);
}, [])
);
const menuControl = (
<Link to={`${baseUrl}/settings`}>
<Icon icon="Menu" color="gray" pr={2} />
</Link>
);
const title = (
<Text
mono={urbitOb.isValidPatp(props.title)}
fontSize={2}
fontWeight="600"
textOverflow="ellipsis"
overflow="hidden"
whiteSpace="nowrap"
minWidth={0}
maxWidth={props?.description ? ['100%', '50%'] : 'none'}
mr="2"
ml="1"
flexShrink={1}
>
{props.title}
</Text>
);
const description = (
<TruncatedText
display={['none', 'inline']}
mono={monoDescription}
color="gray"
mb={0}
minWidth={0}
maxWidth="50%"
flexShrink={1}
disableRemoteContent
>
{props.description}
</TruncatedText>
);
const backLink = (
<Box
borderRight={1}
borderRightColor="gray"
pr={3}
fontSize={1}
mr="12px"
my={1}
flexShrink={0}
display={back ? 'block' : ['block', 'none']}
>
<Link to={back || workspace}>
<Text>{'<- Back'}</Text>
</Link>
</Box>
);
return (
<Box
flexShrink={0}
height="48px"
py={2}
px={2}
borderBottom={1}
borderBottomColor="lightGray"
display="flex"
justifyContent="space-between"
alignItems="center"
>
<Box
display="flex"
alignItems="baseline"
width={`calc(100% - ${actionsWidth}px - 16px)`}
flexShrink={0}
>
{backLink}
{title}
{description}
</Box>
<Box ml={3} display="flex" alignItems="center" flexShrink={0} {...bind}>
{children}
{menuControl}
</Box>
</Box>
);
}

View File

@ -0,0 +1,10 @@
import { Text } from '@tlon/indigo-react';
import styled from 'styled-components';
export const TruncatedText = styled(Text)`
white-space: pre;
text-overflow: ellipsis;
overflow: hidden;
min-width: 0;
`;

View File

@ -1,6 +1,4 @@
import { Box, Center, LoadingSpinner } from '@tlon/indigo-react'; import { Box, Center, LoadingSpinner } from '@tlon/indigo-react';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import bigInt, { BigInteger } from 'big-integer';
import _ from 'lodash'; import _ from 'lodash';
import normalizeWheel from 'normalize-wheel'; import normalizeWheel from 'normalize-wheel';
import React, { Component, SyntheticEvent, useCallback } from 'react'; import React, { Component, SyntheticEvent, useCallback } from 'react';
@ -17,13 +15,20 @@ const ScrollbarLessBox = styled(Box)`
} }
`; `;
interface RendererProps { interface RendererProps<K> {
index: BigInteger; index: K;
scrollWindow: any; scrollWindow: any;
ref: (el: HTMLElement | null) => void; ref: (el: HTMLElement | null) => void;
} }
interface VirtualScrollerProps<T> { interface OrderedMap<K,V> extends Iterable<[K,V]> {
peekLargest: () => [K,V] | undefined;
peekSmallest: () => [K,V] | undefined;
size: number;
keys: () => K[];
}
export interface VirtualScrollerProps<K,V> {
/** /**
* Start scroll from * Start scroll from
*/ */
@ -37,7 +42,7 @@ interface VirtualScrollerProps<T> {
/** /**
* The data to iterate over * The data to iterate over
*/ */
data: BigIntOrderedMap<T>; data: OrderedMap<K,V>;
/* /*
* The component to render the items * The component to render the items
* *
@ -46,12 +51,11 @@ interface VirtualScrollerProps<T> {
* This component must be referentially stable, so either use `useCallback` or * This component must be referentially stable, so either use `useCallback` or
* a instance method. It must also forward the DOM ref from its root DOM node * a instance method. It must also forward the DOM ref from its root DOM node
*/ */
renderer: (props: RendererProps) => JSX.Element | null; renderer: (props: RendererProps<K>) => JSX.Element | null;
onStartReached?(): void; onStartReached?(): void;
onEndReached?(): void; onEndReached?(): void;
size: number; size: number;
pendingSize: number; pendingSize: number;
totalSize: number;
/* /*
* Average height of a single rendered item * Average height of a single rendered item
* *
@ -73,10 +77,22 @@ interface VirtualScrollerProps<T> {
* Callback to execute when finished loading from start * Callback to execute when finished loading from start
*/ */
onBottomLoaded?: () => void; onBottomLoaded?: () => void;
/*
* equality function for the key type
*/
keyEq: (a: K, b: K) => boolean;
/*
* string conversion for key type
*/
keyToString: (k: K) => string;
/*
* default value for key type
*/
keyBunt: K;
} }
interface VirtualScrollerState { interface VirtualScrollerState<K> {
visibleItems: BigInteger[]; visibleItems: K[];
scrollbar: number; scrollbar: number;
loaded: { loaded: {
top: boolean; top: boolean;
@ -85,7 +101,9 @@ interface VirtualScrollerState {
} }
type LogLevel = 'scroll' | 'network' | 'bail' | 'reflow'; type LogLevel = 'scroll' | 'network' | 'bail' | 'reflow';
const logLevel = ['network', 'bail', 'scroll', 'reflow'] as LogLevel[]; const logLevel = process.env.NODE_ENV === 'production'
? []
: ['network', 'bail', 'scroll', 'reflow'] as LogLevel[];
const log = (level: LogLevel, message: string) => { const log = (level: LogLevel, message: string) => {
if(logLevel.includes(level)) { if(logLevel.includes(level)) {
@ -104,7 +122,7 @@ const ZONE_SIZE = IS_IOS ? 20 : 80;
* VirtualScroller does not clean up or reset itself, so please use `key` * VirtualScroller does not clean up or reset itself, so please use `key`
* to ensure a new instance is created for each BigIntOrderedMap * to ensure a new instance is created for each BigIntOrderedMap
*/ */
export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T>, VirtualScrollerState> { export default class VirtualScroller<K,V> extends Component<VirtualScrollerProps<K,V>, VirtualScrollerState<K>> {
/* /*
* A reference to our scroll container * A reference to our scroll container
*/ */
@ -120,7 +138,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
/* /*
* If saving, the bottommost visible element that we pin our scroll to * If saving, the bottommost visible element that we pin our scroll to
*/ */
private savedIndex: BigInteger | null = null; private savedIndex: K | null = null;
/* /*
* If saving, the distance between the top of `this.savedEl` and the bottom * If saving, the distance between the top of `this.savedEl` and the bottom
* of the screen * of the screen
@ -144,7 +162,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
private cleanupRefInterval: NodeJS.Timeout | null = null; private cleanupRefInterval: NodeJS.Timeout | null = null;
constructor(props: VirtualScrollerProps<T>) { constructor(props: VirtualScrollerProps<K,V>) {
super(props); super(props);
this.state = { this.state = {
visibleItems: [], visibleItems: [],
@ -177,8 +195,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
return; return;
} }
[...this.orphans].forEach((o) => { [...this.orphans].forEach((o) => {
const index = bigInt(o); this.childRefs.delete(o);
this.childRefs.delete(index.toString());
}); });
this.orphans.clear(); this.orphans.clear();
}; };
@ -198,7 +215,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
this.scrollRef.style[this.props.origin] = `${result}px`; this.scrollRef.style[this.props.origin] = `${result}px`;
}, 50); }, 50);
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState) { componentDidUpdate(prevProps: VirtualScrollerProps<K,V>, _prevState: VirtualScrollerState<K>) {
const { size, pendingSize } = this.props; const { size, pendingSize } = this.props;
if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) { if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) {
@ -220,13 +237,13 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
} }
startOffset() { startOffset() {
const { data } = this.props; const { data, keyEq } = this.props;
const startIndex = this.state.visibleItems?.[0]; const startIndex = this.state.visibleItems?.[0];
if(!startIndex) { if(!startIndex) {
return 0; return 0;
} }
const dataList = Array.from(data); const dataList = Array.from(data);
const offset = dataList.findIndex(([i]) => i.eq(startIndex)); const offset = dataList.findIndex(([i]) => keyEq(i, startIndex));
if(offset === -1) { if(offset === -1) {
// TODO: revisit when we remove nodes for any other reason than // TODO: revisit when we remove nodes for any other reason than
// pending indices being removed // pending indices being removed
@ -401,6 +418,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
} }
restore() { restore() {
const { keyToString } = this.props;
if(!this.window || !this.savedIndex) { if(!this.window || !this.savedIndex) {
return; return;
} }
@ -418,7 +436,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
return; return;
} }
const ref = this.childRefs.get(this.savedIndex.toString()); const ref = this.childRefs.get(keyToString(this.savedIndex));
if(!ref) { if(!ref) {
return; return;
} }
@ -435,17 +453,18 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}); });
} }
scrollToIndex = (index: BigInteger) => { scrollToIndex = (index: K) => {
let ref = this.childRefs.get(index.toString()); const { keyToString, keyEq } = this.props;
let ref = this.childRefs.get(keyToString(index));
if(!ref) { if(!ref) {
const offset = [...this.props.data].findIndex(([idx]) => idx.eq(index)); const offset = [...this.props.data].findIndex(([idx]) => keyEq(idx, index));
if(offset === -1) { if(offset === -1) {
return; return;
} }
this.scrollLocked = false; this.scrollLocked = false;
this.updateVisible(Math.max(offset - this.pageDelta, 0)); this.updateVisible(Math.max(offset - this.pageDelta, 0));
requestAnimationFrame(() => { requestAnimationFrame(() => {
ref = this.childRefs.get(index.toString()); ref = this.childRefs.get(keyToString(index));
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.savedIndex = null; this.savedIndex = null;
this.savedDistance = 0; this.savedDistance = 0;
@ -468,6 +487,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
if(!this.window || this.savedIndex) { if(!this.window || this.savedIndex) {
return; return;
} }
log('reflow', `saving @ ${this.saveDepth}`);
if(this.saveDepth !== 0) { if(this.saveDepth !== 0) {
return; return;
} }
@ -476,13 +496,14 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
this.saveDepth++; this.saveDepth++;
const { visibleItems } = this.state; const { visibleItems } = this.state;
const { keyToString } = this.props;
let bottomIndex = visibleItems[visibleItems.length - 1]; let bottomIndex = visibleItems[visibleItems.length - 1];
const { scrollTop, scrollHeight } = this.window; const { scrollTop, scrollHeight } = this.window;
const topSpacing = this.props.origin === 'top' ? scrollTop : scrollHeight - scrollTop; const topSpacing = this.props.origin === 'top' ? scrollTop : scrollHeight - scrollTop;
const items = this.props.origin === 'top' ? visibleItems : [...visibleItems].reverse(); const items = this.props.origin === 'top' ? visibleItems : [...visibleItems].reverse();
items.forEach((index) => { items.forEach((index) => {
const el = this.childRefs.get(index.toString()); const el = this.childRefs.get(keyToString(index));
if(!el) { if(!el) {
return; return;
} }
@ -500,7 +521,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
} }
this.savedIndex = bottomIndex; this.savedIndex = bottomIndex;
const ref = this.childRefs.get(bottomIndex.toString())!; const ref = this.childRefs.get(keyToString(bottomIndex))!;
if(!ref) { if(!ref) {
this.saveDepth--; this.saveDepth--;
log('bail', 'missing ref'); log('bail', 'missing ref');
@ -513,12 +534,13 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
// disabled until we work out race conditions with loading new nodes // disabled until we work out race conditions with loading new nodes
shiftLayout = { save: () => {}, restore: () => {} }; shiftLayout = { save: () => {}, restore: () => {} };
setRef = (element: HTMLElement | null, index: BigInteger) => { setRef = (element: HTMLElement | null, index: K) => {
const { keyToString } = this.props;
if(element) { if(element) {
this.childRefs.set(index.toString(), element); this.childRefs.set(keyToString(index), element);
this.orphans.delete(index.toString()); this.orphans.delete(keyToString(index));
} else { } else {
this.orphans.add(index.toString()); this.orphans.add(keyToString(index));
} }
} }
@ -530,7 +552,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const { const {
origin = 'top', origin = 'top',
renderer, renderer,
style style,
keyEq,
keyBunt,
keyToString
} = this.props; } = this.props;
const isTop = origin === 'top'; const isTop = origin === 'top';
@ -538,8 +563,8 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)'; const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const children = isTop ? visibleItems : [...visibleItems].reverse(); const children = isTop ? visibleItems : [...visibleItems].reverse();
const atStart = (this.props.data.peekLargest()?.[0] ?? bigInt.zero).eq(visibleItems?.[0] || bigInt.zero); const atStart = keyEq(this.props.data.peekLargest()?.[0] ?? keyBunt, visibleItems?.[0] || keyBunt);
const atEnd = (this.props.data.peekSmallest()?.[0] ?? bigInt.zero).eq(visibleItems?.[visibleItems.length -1 ] || bigInt.zero); const atEnd = keyEq(this.props.data.peekSmallest()?.[0] ?? keyBunt, visibleItems?.[visibleItems.length -1 ] || keyBunt);
return ( return (
<> <>
@ -559,8 +584,8 @@ backgroundColor="lightGray"
</Center>)} </Center>)}
<VirtualContext.Provider value={this.shiftLayout}> <VirtualContext.Provider value={this.shiftLayout}>
{children.map(index => ( {children.map(index => (
<VirtualChild <VirtualChild<K>
key={index.toString()} key={keyToString(index)}
setRef={this.setRef} setRef={this.setRef}
index={index} index={index}
scrollWindow={this.window} scrollWindow={this.window}
@ -579,14 +604,14 @@ backgroundColor="lightGray"
} }
} }
interface VirtualChildProps { interface VirtualChildProps<K> {
index: BigInteger; index: K;
scrollWindow: any; scrollWindow: any;
setRef: (el: HTMLElement | null, index: BigInteger) => void; setRef: (el: HTMLElement | null, index: K) => void;
renderer: (p: RendererProps) => JSX.Element | null; renderer: (p: RendererProps<K>) => JSX.Element | null;
} }
function VirtualChild(props: VirtualChildProps) { function VirtualChild<K>(props: VirtualChildProps<K>) {
const { setRef, renderer: Renderer, ...rest } = props; const { setRef, renderer: Renderer, ...rest } = props;
const ref = useCallback((el: HTMLElement | null) => { const ref = useCallback((el: HTMLElement | null) => {

View File

@ -5,9 +5,9 @@ import bigInt from 'big-integer';
import React from 'react'; import React from 'react';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { resourceFromPath } from '~/logic/lib/group'; import { resourceFromPath } from '~/logic/lib/group';
import VirtualScroller from '~/views/components/VirtualScroller';
import PostItem from './PostItem/PostItem'; import PostItem from './PostItem/PostItem';
import PostInput from './PostInput'; import PostInput from './PostInput';
import { GraphScroller } from '~/views/components/GraphScroller';
import useGraphState, { GraphState } from '~/logic/state/graph'; import useGraphState, { GraphState } from '~/logic/state/graph';
import shallow from 'zustand/shallow'; import shallow from 'zustand/shallow';
@ -210,14 +210,13 @@ class PostFeed extends React.Component<PostFeedProps, any> {
return ( return (
<Col width="100%" height="100%" position="relative"> <Col width="100%" height="100%" position="relative">
<VirtualScroller <GraphScroller
key={history.location.pathname} key={history.location.pathname}
origin="top" origin="top"
offset={0} offset={0}
data={graph} data={graph}
averageHeight={106} averageHeight={80}
size={graph.size} size={graph.size}
totalSize={graph.size}
style={virtualScrollerStyle} style={virtualScrollerStyle}
pendingSize={pendingSize} pendingSize={pendingSize}
renderer={this.renderItem} renderer={this.renderItem}

View File

@ -4,10 +4,10 @@ import bigInt from 'big-integer';
import React from 'react'; import React from 'react';
import { RouteComponentProps, useHistory } from 'react-router'; import { RouteComponentProps, useHistory } from 'react-router';
import { resourceFromPath } from '~/logic/lib/group'; import { resourceFromPath } from '~/logic/lib/group';
import ArrayVirtualScroller, { import {
indexEqual,
arrToString arrToString
} from '~/views/components/ArrayVirtualScroller'; } from '@urbit/api/lib/BigIntArrayOrderedMap';
import { keyEq, ThreadScroller } from '~/views/components/ThreadScroller';
import PostItem from './PostItem/PostItem'; import PostItem from './PostItem/PostItem';
import PostInput from './PostInput'; import PostInput from './PostInput';
import useGraphState, { GraphState } from '~/logic/state/graph'; import useGraphState, { GraphState } from '~/logic/state/graph';
@ -66,9 +66,9 @@ class PostFlatFeed extends React.Component<PostFeedProps, {}> {
const first = flatGraph.peekLargest()?.[0]; const first = flatGraph.peekLargest()?.[0];
const last = flatGraph.peekSmallest()?.[0]; const last = flatGraph.peekSmallest()?.[0];
const isLast = last ? indexEqual(index, last) : false; const isLast = last ? keyEq(index, last) : false;
if (indexEqual(index, (first ?? [bigInt.zero]))) { if (keyEq(index, (first ?? [bigInt.zero]))) {
if (isThread) { if (isThread) {
return ( return (
<Col <Col
@ -195,12 +195,12 @@ class PostFlatFeed extends React.Component<PostFeedProps, {}> {
return ( return (
<Col width="100%" height="100%" position="relative"> <Col width="100%" height="100%" position="relative">
<ArrayVirtualScroller <ThreadScroller
key={history.location.pathname} key={history.location.pathname}
origin="top" origin="top"
offset={0} offset={0}
data={flatGraph} data={flatGraph}
averageHeight={122} averageHeight={80}
size={flatGraph.size} size={flatGraph.size}
style={virtualScrollerStyle} style={virtualScrollerStyle}
pendingSize={pendingSize} pendingSize={pendingSize}

View File

@ -6,7 +6,7 @@ import React, {
} from 'react'; } from 'react';
import { resourceFromPath } from '~/logic/lib/group'; import { resourceFromPath } from '~/logic/lib/group';
import { Loading } from '~/views/components/Loading'; import { Loading } from '~/views/components/Loading';
import { arrToString } from '~/views/components/ArrayVirtualScroller'; import { arrToString } from '@urbit/api/lib/BigIntArrayOrderedMap';
import useGraphState from '~/logic/state/graph'; import useGraphState from '~/logic/state/graph';
import PostFlatFeed from './PostFlatFeed'; import PostFlatFeed from './PostFlatFeed';
import PostInput from './PostInput'; import PostInput from './PostInput';

View File

@ -82,7 +82,7 @@ export function NewChannel(props: NewChannelProps): ReactElement {
} }
if (group) { if (group) {
await airlock.thread(createManagedGraph( await airlock.thread(createManagedGraph(
`~${window.ship}`, window.ship,
resId, resId,
name, name,
description, description,
@ -106,7 +106,7 @@ export function NewChannel(props: NewChannelProps): ReactElement {
} }
} else { } else {
await airlock.thread(createUnmanagedGraph( await airlock.thread(createUnmanagedGraph(
`~${window.ship}`, window.ship,
resId, resId,
name, name,
description, description,

View File

@ -42,6 +42,9 @@ export function Resource(props: ResourceProps): ReactElement {
<Helmet defer={false}> <Helmet defer={false}>
<title>{notificationsCount ? `(${String(notificationsCount)}) ` : ''}{ title }</title> <title>{notificationsCount ? `(${String(notificationsCount)}) ` : ''}{ title }</title>
</Helmet> </Helmet>
{ app === 'link' ? (
<LinkResource {...props} />
) : (
<ResourceSkeleton <ResourceSkeleton
{...skelProps} {...skelProps}
baseUrl={relativePath('')} baseUrl={relativePath('')}
@ -50,10 +53,9 @@ export function Resource(props: ResourceProps): ReactElement {
<ChatResource {...props} /> <ChatResource {...props} />
) : app === 'publish' ? ( ) : app === 'publish' ? (
<PublishResource {...props} /> <PublishResource {...props} />
) : ( ) : null }
<LinkResource {...props} />
)}
</ResourceSkeleton> </ResourceSkeleton>
)}
<Switch> <Switch>
<Route <Route
path={relativePath('/settings')} path={relativePath('/settings')}

View File

@ -6,6 +6,7 @@ import { Link } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import urbitOb from 'urbit-ob'; import urbitOb from 'urbit-ob';
import { isWriter } from '~/logic/lib/group'; import { isWriter } from '~/logic/lib/group';
import { useResize } from '~/logic/lib/useResize';
import { getItemTitle } from '~/logic/lib/util'; import { getItemTitle } from '~/logic/lib/util';
import useContactState from '~/logic/state/contact'; import useContactState from '~/logic/state/contact';
import useSettingsState, { selectCalmState } from '~/logic/state/settings'; import useSettingsState, { selectCalmState } from '~/logic/state/settings';
@ -199,9 +200,9 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
</Link> </Link>
); );
const actionsRef = useCallback((actionsRef) => { const bind = useResize<HTMLDivElement>(useCallback((entry) => {
setActionsWidth(actionsRef?.getBoundingClientRect().width); setActionsWidth(entry.borderBoxSize[0].inlineSize);
}, [rid]); }, []));
return ( return (
<Col width='100%' height='100%' overflow='hidden'> <Col width='100%' height='100%' overflow='hidden'>
@ -231,7 +232,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
display='flex' display='flex'
alignItems='center' alignItems='center'
flexShrink={0} flexShrink={0}
ref={actionsRef} {...bind}
> >
{extraControls} {extraControls}
{menuControl} {menuControl}

View File

@ -27,3 +27,7 @@ ol, ul {
margin-block-end: 0; margin-block-end: 0;
} }
span {
box-sizing: border-box;
}

View File

@ -472,7 +472,7 @@ export const getFirstborn = (
index: string index: string
): Scry => ({ ): Scry => ({
app: 'graph-store', app: 'graph-store',
path: `/firstborn/${ship}/${name}/${encodeIndex(index)}` path: `/firstborn/${ship}/${name}${encodeIndex(index)}`
}); });
/** /**
@ -489,7 +489,7 @@ export const getNode = (
index: string index: string
): Scry => ({ ): Scry => ({
app: 'graph-store', app: 'graph-store',
path: `/node/${ship}/${name}/${encodeIndex(index)}` path: `/node/${ship}/${name}${encodeIndex(index)}`
}); });
/** /**

View File

@ -94,7 +94,7 @@ export class Urbit {
credentials: 'include', credentials: 'include',
accept: '*', accept: '*',
headers, headers,
signal: this.abort signal: this.abort.signal
}; };
} }