mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-19 12:51:51 +03:00
Merge branch 'release/next-userspace' into m/next-gen-term-real
This commit is contained in:
commit
0a1fdc016e
@ -157,7 +157,7 @@
|
||||
++ to-range
|
||||
|= [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
|
||||
|
@ -36,8 +36,34 @@ export const globalTypes = {
|
||||
|
||||
export const decorators = [
|
||||
(Story, context) => {
|
||||
window.ship = 'sampel-palnet';
|
||||
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({
|
||||
associations: {
|
||||
groups: {
|
||||
@ -66,6 +92,25 @@ export const decorators = [
|
||||
},
|
||||
},
|
||||
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': {
|
||||
metadata: {
|
||||
preview: false,
|
||||
|
43
pkg/interface/package-lock.json
generated
43
pkg/interface/package-lock.json
generated
@ -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": {
|
||||
"version": "9.2.0",
|
||||
"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": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
|
||||
@ -29727,6 +29761,15 @@
|
||||
"integrity": "sha512-7t+/wpKLanLzSnQPX8WAcuLCCeuSHoWdQuh9SB3xD0kNOM38DNf+0Oa+wmvxmYueRzkmh6IcdKFtvTa+ecgPDw==",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
|
||||
|
@ -105,6 +105,7 @@
|
||||
"react-hot-loader": "^4.13.0",
|
||||
"sass": "^1.32.5",
|
||||
"sass-loader": "^8.0.2",
|
||||
"storybook-addon-designs": "^6.0.0",
|
||||
"ts-mdast": "^1.0.0",
|
||||
"typescript": "^4.2.4",
|
||||
"webpack": "^4.46.0",
|
||||
|
16
pkg/interface/src/logic/lib/fakeApi.test.js
Normal file
16
pkg/interface/src/logic/lib/fakeApi.test.js
Normal 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);
|
||||
});
|
||||
});
|
22
pkg/interface/src/logic/lib/fakeApi.ts
Normal file
22
pkg/interface/src/logic/lib/fakeApi.ts
Normal 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;
|
||||
}
|
45
pkg/interface/src/logic/lib/fixtures.ts
Normal file
45
pkg/interface/src/logic/lib/fixtures.ts
Normal 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
|
||||
}
|
||||
];
|
||||
};
|
29
pkg/interface/src/logic/lib/useResize.ts
Normal file
29
pkg/interface/src/logic/lib/useResize.ts
Normal 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;
|
||||
}
|
34
pkg/interface/src/logic/lib/useUrlField.tsx
Normal file
34
pkg/interface/src/logic/lib/useUrlField.tsx
Normal 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;
|
||||
}
|
118
pkg/interface/src/stories/LinkBlockItem.stories.tsx
Normal file
118
pkg/interface/src/stories/LinkBlockItem.stories.tsx
Normal 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'
|
||||
}
|
||||
};
|
78
pkg/interface/src/stories/LinkDetail.stories.tsx
Normal file
78
pkg/interface/src/stories/LinkDetail.stories.tsx
Normal 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'
|
||||
}
|
||||
};
|
1
pkg/interface/src/stories/LinkListItem.stories.tsx
Normal file
1
pkg/interface/src/stories/LinkListItem.stories.tsx
Normal file
@ -0,0 +1 @@
|
||||
|
@ -12,7 +12,7 @@ export default {
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<RemoteContentProps> = args => (
|
||||
<Box backgroundColor="white" p="2" width="500px">
|
||||
<Box backgroundColor="white" p="2" width="800px">
|
||||
<RemoteContent {...args} />
|
||||
</Box>
|
||||
);
|
||||
@ -38,3 +38,15 @@ Twitter.args = {
|
||||
// massive test flake
|
||||
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'
|
||||
};
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
} from '@urbit/api';
|
||||
import bigInt, { BigInteger } from 'big-integer';
|
||||
import React, { Component } from 'react';
|
||||
import { GraphScroller } from '~/views/components/GraphScroller';
|
||||
import VirtualScroller from '~/views/components/VirtualScroller';
|
||||
import ChatMessage from './ChatMessage';
|
||||
import UnreadNotice from './UnreadNotice';
|
||||
@ -45,7 +46,7 @@ class ChatWindow extends Component<
|
||||
ChatWindowProps,
|
||||
ChatWindowState
|
||||
> {
|
||||
private virtualList: VirtualScroller<GraphNode> | null;
|
||||
private virtualList: VirtualScroller<bigInt.BigInteger, GraphNode> | null;
|
||||
private prevSize = 0;
|
||||
private unreadSet = false;
|
||||
|
||||
@ -257,7 +258,7 @@ class ChatWindow extends Component<
|
||||
dismissUnread={this.props.dismissUnread}
|
||||
onClick={this.scrollToUnread}
|
||||
/>)}
|
||||
<VirtualScroller<GraphNode>
|
||||
<GraphScroller
|
||||
ref={(list) => {
|
||||
this.virtualList = list;
|
||||
}}
|
||||
|
@ -3,21 +3,21 @@ import { Group } from '@urbit/api';
|
||||
import { Association } from '@urbit/api/metadata';
|
||||
import bigInt from 'big-integer';
|
||||
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 useMetadataState from '~/logic/state/metadata';
|
||||
import { Comments } from '~/views/components/Comments';
|
||||
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 LinkWindow from './LinkWindow';
|
||||
|
||||
const emptyMeasure = () => {};
|
||||
|
||||
type LinkResourceProps = {
|
||||
interface LinkResourceProps {
|
||||
association: Association;
|
||||
baseUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function LinkResource(props: LinkResourceProps) {
|
||||
const {
|
||||
@ -41,6 +41,9 @@ export function LinkResource(props: LinkResourceProps) {
|
||||
const graphs = useGraphState(state => state.graphs);
|
||||
const graph = graphs[resourcePath] || null;
|
||||
const graphTimesentMap = useGraphState(state => state.graphTimesentMap);
|
||||
const { query } = useQuery();
|
||||
const isList = query.has('list');
|
||||
const { pathname, search } = useLocation();
|
||||
const getGraph = useGraphState(s => s.getGraph);
|
||||
|
||||
useEffect(() => {
|
||||
@ -48,89 +51,85 @@ export function LinkResource(props: LinkResourceProps) {
|
||||
}, [association]);
|
||||
|
||||
const resourceUrl = `${baseUrl}/resource/link${rid}`;
|
||||
if (!graph) {
|
||||
if (!graph || !resource) {
|
||||
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 (
|
||||
<Col alignItems="center" height="100%" width="100%" overflowY="hidden">
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path={relativePath('')}
|
||||
render={(props) => {
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path={relativePath('')}
|
||||
render={(props) => {
|
||||
return (
|
||||
<Col minWidth="0" overflow="hidden">
|
||||
{titlebar()}
|
||||
{ isList ? /* @ts-ignore withState typings */ (
|
||||
<LinkWindow
|
||||
key={rid}
|
||||
association={resource}
|
||||
resource={resourcePath}
|
||||
graph={graph}
|
||||
baseUrl={resourceUrl}
|
||||
group={group as Group}
|
||||
path={resource.group}
|
||||
pendingSize={Object.keys(graphTimesentMap[resourcePath] || {}).length}
|
||||
mb={3}
|
||||
/>
|
||||
) : (
|
||||
<LinkBlocks graph={graph} association={resource} />
|
||||
)}
|
||||
</Col>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path={relativePath('/index/:index')}
|
||||
render={(props) => {
|
||||
const index = bigInt(props.match.params.index);
|
||||
|
||||
if (!index) {
|
||||
return <div>Malformed URL</div>;
|
||||
}
|
||||
|
||||
const node = graph ? graph.get(index) : null;
|
||||
|
||||
if (!node) {
|
||||
return <Box>Not found</Box>;
|
||||
}
|
||||
|
||||
if (typeof node.post === 'string') {
|
||||
return (
|
||||
// @ts-ignore state helper weirdness
|
||||
<LinkWindow
|
||||
key={rid}
|
||||
association={resource}
|
||||
resource={resourcePath}
|
||||
graph={graph}
|
||||
baseUrl={resourceUrl}
|
||||
group={group as Group}
|
||||
path={resource.group}
|
||||
pendingSize={Object.keys(graphTimesentMap[resourcePath] || {}).length}
|
||||
mb={3}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path={relativePath('/index/:index')}
|
||||
render={(props) => {
|
||||
const index = bigInt(props.match.params.index);
|
||||
const editCommentId = props.match.params.commentId || null;
|
||||
|
||||
if (!index) {
|
||||
return <div>Malformed URL</div>;
|
||||
}
|
||||
|
||||
const node = graph ? graph.get(index) : null;
|
||||
|
||||
if (!node) {
|
||||
return <Box>Not found</Box>;
|
||||
}
|
||||
|
||||
if (typeof node.post === 'string') {
|
||||
return (
|
||||
<Col width="100%" textAlign="center" pt="2">
|
||||
<Text gray>This link has been deleted.</Text>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Col alignItems="center" overflowY="auto" width="100%">
|
||||
<Col width="100%" p={3} maxWidth="768px">
|
||||
<Link to={resourceUrl}><Text px={3} bold>{'<- Back'}</Text></Link>
|
||||
<LinkItem
|
||||
key={node.post.index}
|
||||
resource={resourcePath}
|
||||
node={node}
|
||||
baseUrl={resourceUrl}
|
||||
association={association}
|
||||
group={group as Group}
|
||||
path={resource?.group}
|
||||
mt={3}
|
||||
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 width="100%" textAlign="center" pt="2">
|
||||
<Text gray>This link has been deleted.</Text>
|
||||
</Col>
|
||||
</Col>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</Col>
|
||||
}
|
||||
return (
|
||||
<Col overflow="hidden">
|
||||
{titlebar(relativePath(''))}
|
||||
<LinkDetail
|
||||
node={node}
|
||||
association={association}
|
||||
baseUrl={pathname}
|
||||
flexGrow={1}
|
||||
maxHeight="calc(100% - 48px)"
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { Box, Col, Text } from '@tlon/indigo-react';
|
||||
import { Association, Graph, Group } from '@urbit/api';
|
||||
import bigInt from 'big-integer';
|
||||
import bigInt, { BigInteger } from 'big-integer';
|
||||
import React, {
|
||||
Component, ReactNode
|
||||
} from 'react';
|
||||
import { isWriter } from '~/logic/lib/group';
|
||||
import VirtualScroller from '~/views/components/VirtualScroller';
|
||||
import { GraphScroller } from '~/views/components/GraphScroller';
|
||||
import { LinkItem } from './components/LinkItem';
|
||||
import LinkSubmit from './components/LinkSubmit';
|
||||
|
||||
@ -58,7 +58,6 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
|
||||
...props,
|
||||
node
|
||||
};
|
||||
{ /* @ts-ignore calling @liam-fitzgerald on Uint8Array props */ }
|
||||
if (this.canWrite() && index.eq(first ?? bigInt.zero)) {
|
||||
return (
|
||||
<React.Fragment key={index.toString()}>
|
||||
@ -123,9 +122,9 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
|
||||
}
|
||||
|
||||
return (
|
||||
<Col width="100%" height="100%" position="relative">
|
||||
<Col width="100%" height="calc(100% - 48px)" position="relative">
|
||||
{/* @ts-ignore calling @liam-fitzgerald on virtualscroller */}
|
||||
<VirtualScroller
|
||||
<GraphScroller
|
||||
origin="top"
|
||||
offset={0}
|
||||
style={style}
|
||||
|
127
pkg/interface/src/views/apps/links/components/LinkBlockInput.tsx
Normal file
127
pkg/interface/src/views/apps/links/components/LinkBlockInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
125
pkg/interface/src/views/apps/links/components/LinkBlockItem.tsx
Normal file
125
pkg/interface/src/views/apps/links/components/LinkBlockItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
118
pkg/interface/src/views/apps/links/components/LinkBlocks.tsx
Normal file
118
pkg/interface/src/views/apps/links/components/LinkBlocks.tsx
Normal 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>
|
||||
);
|
||||
}
|
65
pkg/interface/src/views/apps/links/components/LinkDetail.tsx
Normal file
65
pkg/interface/src/views/apps/links/components/LinkDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -17,7 +17,6 @@ interface LinkItemProps {
|
||||
association: Association;
|
||||
resource: string;
|
||||
group: Group;
|
||||
path: string;
|
||||
baseUrl: string;
|
||||
mt?: number;
|
||||
measure?: any;
|
||||
@ -35,7 +34,10 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
|
||||
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 markRead = useCallback(() => {
|
||||
@ -100,7 +102,7 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
|
||||
const unreads = useHarkState(state => state.unreads?.[appPath]);
|
||||
const commColor = (unreads?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray';
|
||||
// @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 (
|
||||
<Box
|
||||
@ -125,7 +127,7 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
|
||||
overflow="hidden"
|
||||
onClick={markRead}
|
||||
>
|
||||
<Text p={2}>{contents[0].text}</Text>
|
||||
{contents[0].text ? <Text p={2}>{contents[0].text}</Text> : null}
|
||||
{ 'reference' in contents[1] ? (
|
||||
<>
|
||||
<Rule />
|
||||
@ -134,34 +136,11 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
|
||||
) : (
|
||||
<>
|
||||
<RemoteContent
|
||||
ref={(r) => {
|
||||
// @ts-ignore RemoteContent weirdness
|
||||
remoteRef.current = r;
|
||||
}}
|
||||
embedRef={setRef}
|
||||
// @ts-ignore RemoteContent weirdness
|
||||
renderUrl={false}
|
||||
url={href}
|
||||
text={contents[0].text}
|
||||
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
|
||||
}}
|
||||
tall
|
||||
/>
|
||||
<Text color="gray" p={2} flexShrink={0}>
|
||||
<Anchor target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }} href={href}>
|
||||
|
@ -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 React, { useCallback, useState, DragEvent, useEffect } from 'react';
|
||||
import { parsePermalink, permalinkToReference } from '~/logic/lib/permalinks';
|
||||
import { useFileDrag } from '~/logic/lib/useDrag';
|
||||
import useStorage from '~/logic/lib/useStorage';
|
||||
import { StatelessUrlInput } from '~/views/components/StatelessUrlInput';
|
||||
import SubmitDragger from '~/views/components/SubmitDragger';
|
||||
import useGraphState from '~/logic/state/graph';
|
||||
import { createPost } from '@urbit/api';
|
||||
@ -20,7 +21,6 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
||||
const addPost = useGraphState(s => s.addPost);
|
||||
|
||||
const [submitFocused, setSubmitFocused] = useState(false);
|
||||
const [urlFocused, setUrlFocused] = useState(false);
|
||||
const [linkValue, setLinkValue] = useState('');
|
||||
const [linkTitle, setLinkTitle] = useState('');
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
@ -35,7 +35,7 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
||||
|
||||
setDisabled(true);
|
||||
const parentIndex = props.parentIndex || '';
|
||||
const post = createPost(`~${window.ship}`, contents, parentIndex);
|
||||
const post = createPost(window.ship, contents, parentIndex);
|
||||
|
||||
addPost(
|
||||
`~${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 (
|
||||
<>
|
||||
{/* @ts-ignore archaic event type mismatch */}
|
||||
@ -181,26 +161,17 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
||||
>
|
||||
<LoadingSpinner />
|
||||
</Box>}
|
||||
{dragging && <SubmitDragger />}
|
||||
<Box position='relative'>
|
||||
{!(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}
|
||||
/>
|
||||
</Box>
|
||||
{dragging && <SubmitDragger />}
|
||||
<StatelessUrlInput
|
||||
value={linkValue}
|
||||
promptUpload={promptUpload}
|
||||
canUpload={canUpload}
|
||||
onSubmit={doPost}
|
||||
onChange={setLinkValue}
|
||||
error={linkValid ? 'Invalid URL' : undefined}
|
||||
onKeyPress={onKeyPress}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
<BaseInput
|
||||
type="text"
|
||||
pl={2}
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
GroupNotifIndex,
|
||||
GroupUpdate
|
||||
} from '@urbit/api';
|
||||
import bigInt from 'big-integer';
|
||||
import _ from 'lodash';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { useAssocForGroup } from '~/logic/state/metadata';
|
||||
@ -35,7 +34,6 @@ interface GroupNotificationProps {
|
||||
index: GroupNotifIndex;
|
||||
contents: GroupNotificationContents;
|
||||
time: number;
|
||||
timebox: bigInt.BigInteger;
|
||||
}
|
||||
|
||||
export function GroupNotification(props: GroupNotificationProps): ReactElement {
|
||||
|
@ -132,7 +132,6 @@ export function Notification(props: NotificationProps) {
|
||||
<GroupNotification
|
||||
index={index}
|
||||
contents={c}
|
||||
timebox={props.time}
|
||||
time={time}
|
||||
/>
|
||||
</NotificationWrapper>
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
81
pkg/interface/src/views/components/AudioPlayer.tsx
Normal file
81
pkg/interface/src/views/components/AudioPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -95,6 +95,9 @@ function Author(props: AuthorProps & PropFunc<typeof Box>): ReactElement {
|
||||
fontWeight={showNickname ? '500' : '400'}
|
||||
mr={showNickname ? 0 : '2px'}
|
||||
mt={showNickname ? 0 : '0px'}
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
title={showNickname ? cite(ship) : contact?.nickname}
|
||||
onClick={doCopy}
|
||||
>
|
||||
|
27
pkg/interface/src/views/components/BlockScroller.tsx
Normal file
27
pkg/interface/src/views/components/BlockScroller.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
@ -1,8 +1,19 @@
|
||||
import { ManagedTextAreaField as TextArea } from '@tlon/indigo-react';
|
||||
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||
import React from 'react';
|
||||
import {
|
||||
Action,
|
||||
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 { AsyncButton } from './AsyncButton';
|
||||
import { ShipImage } from './ShipImage';
|
||||
|
||||
interface FormSchema {
|
||||
comment: string;
|
||||
@ -18,42 +29,90 @@ interface CommentInputProps {
|
||||
actions: FormikHelpers<FormSchema>
|
||||
) => Promise<void>;
|
||||
initial?: string;
|
||||
loadingText?: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
const SubmitTextArea = (props) => {
|
||||
const { submitForm } = useFormikContext<FormSchema>();
|
||||
const [field] = useField(props.id);
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.getModifierState('Control') || e.metaKey) && e.key === 'Enter') {
|
||||
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) {
|
||||
const initialValues: FormSchema = { comment: props.initial || '' };
|
||||
const label = props.label || 'Add Comment';
|
||||
const loading = props.loadingText || 'Commenting...';
|
||||
const label = props.label || 'Comment';
|
||||
|
||||
return (
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
onSubmit={props.onSubmit}
|
||||
initialValues={initialValues}
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
<Row
|
||||
marginLeft="-8px"
|
||||
width="105%"
|
||||
border="1"
|
||||
borderColor="lightGray"
|
||||
borderRadius="2"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Form>
|
||||
<SubmitTextArea
|
||||
id="comment"
|
||||
placeholder={props.placeholder || ''}
|
||||
/>
|
||||
<AsyncButton mt={2} loadingText={loading} border type="submit">
|
||||
{label}
|
||||
</AsyncButton>
|
||||
</Form>
|
||||
</Formik>
|
||||
<Box p="2">
|
||||
<ShipImage ship={`~${window.ship}`} />
|
||||
</Box>
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
onSubmit={props.onSubmit}
|
||||
initialValues={initialValues}
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
>
|
||||
{({ submitForm }) => (
|
||||
<Col pb="1" pr="2" pt="2" flexGrow={1}>
|
||||
<FormikHelper initialValues={initialValues} />
|
||||
<SubmitTextArea
|
||||
width="100%"
|
||||
id="comment"
|
||||
placeholder={props.placeholder || ''}
|
||||
/>
|
||||
<Action
|
||||
type="submit"
|
||||
my="1"
|
||||
width="fit-content"
|
||||
alignSelf="flex-end"
|
||||
backgroundColor="white"
|
||||
onClick={submitForm}
|
||||
>
|
||||
{label}
|
||||
</Action>
|
||||
</Col>
|
||||
)}
|
||||
</Formik>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
@ -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 { GraphNode } from '@urbit/api/graph';
|
||||
import bigInt from 'big-integer';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { roleForShip } from '~/logic/lib/group';
|
||||
import { getPermalinkForGraph } from '~/logic/lib/permalinks';
|
||||
import { getLatestCommentRevision } from '~/logic/lib/publish';
|
||||
import { useCopy } from '~/logic/lib/useCopy';
|
||||
import { useHovering } from '~/logic/lib/util';
|
||||
import useMetadataState from '~/logic/state/metadata';
|
||||
import Author from '~/views/components/Author';
|
||||
import { ActionLink } from '~/views/components/Link';
|
||||
import { GraphContent } from '../landscape/components/Graph/GraphContent';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import airlock from '~/logic/api';
|
||||
|
||||
interface CommentItemProps {
|
||||
@ -28,7 +30,10 @@ export function CommentItem(props: CommentItemProps) {
|
||||
let { highlighted } = props;
|
||||
const { ship, name, comment, group } = props;
|
||||
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 [, post] = getLatestCommentRevision(comment);
|
||||
@ -63,32 +68,14 @@ return false;
|
||||
highlighted = true;
|
||||
}
|
||||
}
|
||||
const { hovering, bind } = useHovering();
|
||||
|
||||
const commentIndexArray = (comment.post?.index || '/').split('/');
|
||||
const commentIndex = commentIndexArray[commentIndexArray.length - 1];
|
||||
|
||||
const adminLinks: JSX.Element[] = [];
|
||||
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(() => {
|
||||
if(ref.current && props.highlighted) {
|
||||
if (ref.current && props.highlighted) {
|
||||
ref.current.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
}, [ref, props.highlighted]);
|
||||
@ -111,26 +98,68 @@ return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={ref} mb={4} opacity={post?.pending ? '60%' : '100%'}>
|
||||
<Row px={1} my={3}>
|
||||
<Box {...bind} ref={ref} mb={4} opacity={post?.pending ? '60%' : '100%'}>
|
||||
<Row justifyContent="space-between" alignItems="center" my={1} pr="1">
|
||||
<Author
|
||||
size={24}
|
||||
sigilPadding={4}
|
||||
showImage
|
||||
ship={post?.author}
|
||||
date={post?.['time-sent']}
|
||||
unread={props.unread}
|
||||
group={group}
|
||||
isRelativeTime
|
||||
>
|
||||
<Row px={2} gapX={2} height="18px">
|
||||
<Action bg="white" onClick={doCopy}>{copyDisplay}</Action>
|
||||
{adminLinks}
|
||||
</Row>
|
||||
</Author>
|
||||
></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"
|
||||
>
|
||||
<Action bg="white" onClick={doCopy}>
|
||||
{copyDisplay}
|
||||
</Action>
|
||||
{(window.ship == post?.author && !disabled) ? (
|
||||
<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>
|
||||
<GraphContent
|
||||
borderRadius={1}
|
||||
p={1}
|
||||
mb={1}
|
||||
ml="28px"
|
||||
backgroundColor={highlighted ? 'washedBlue' : 'white'}
|
||||
transcluded={0}
|
||||
contents={post.contents}
|
||||
|
@ -1,5 +1,14 @@
|
||||
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 { FormikHelpers } from 'formik';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
@ -15,12 +24,11 @@ import CommentInput from './CommentInput';
|
||||
import { CommentItem } from './CommentItem';
|
||||
import airlock from '~/logic/api';
|
||||
import useGraphState from '~/logic/state/graph';
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
interface CommentsProps {
|
||||
comments: GraphNode;
|
||||
association: Association;
|
||||
name: string;
|
||||
ship: string;
|
||||
baseUrl: string;
|
||||
group: Group;
|
||||
}
|
||||
@ -29,14 +37,14 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
|
||||
const {
|
||||
association,
|
||||
comments,
|
||||
ship,
|
||||
name,
|
||||
history,
|
||||
baseUrl,
|
||||
group,
|
||||
...rest
|
||||
} = props;
|
||||
const addNode = useGraphState(s => s.addNode);
|
||||
const history = useHistory();
|
||||
|
||||
const { ship, name } = resourceFromPath(association.resource);
|
||||
|
||||
const { query } = useQuery();
|
||||
const selectedComment = useMemo(() => {
|
||||
@ -80,7 +88,7 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
|
||||
|
||||
const content = tokenizeMessage(comment);
|
||||
const post = createPost(
|
||||
`~${window.ship}`,
|
||||
window.ship,
|
||||
content,
|
||||
commentNode.post.index,
|
||||
parseInt((idx + 1).toString(), 10).toString()
|
||||
@ -96,7 +104,7 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
|
||||
let commentContent = null;
|
||||
if (editCommentId) {
|
||||
const commentNode = comments.children.get(bigInt(editCommentId));
|
||||
const [,post] = getLatestCommentRevision(commentNode);
|
||||
const [, post] = getLatestCommentRevision(commentNode);
|
||||
commentContent = post.contents.reduce((val, curr) => {
|
||||
if ('text' in curr) {
|
||||
val = val + curr.text;
|
||||
@ -130,15 +138,6 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
|
||||
|
||||
return (
|
||||
<Col {...rest} minWidth={0}>
|
||||
{( !editCommentId && canComment ? <CommentInput onSubmit={onSubmit} /> : null )}
|
||||
{( editCommentId ? (
|
||||
<CommentInput
|
||||
onSubmit={onEdit}
|
||||
label='Edit Comment'
|
||||
loadingText='Editing...'
|
||||
initial={commentContent}
|
||||
/>
|
||||
) : null )}
|
||||
{children.reverse()
|
||||
.map(([idx, comment], i) => {
|
||||
const highlighted = selectedComment?.eq(idx) ?? false;
|
||||
@ -155,7 +154,15 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
|
||||
pending={idx.toString() === editCommentId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
{( editCommentId ? (
|
||||
<CommentInput
|
||||
onSubmit={onEdit}
|
||||
label='Edit Comment'
|
||||
initial={commentContent}
|
||||
/>
|
||||
) : null )}
|
||||
{( !editCommentId && canComment ? <CommentInput placeholder="Comment" onSubmit={onSubmit} /> : null )}
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
27
pkg/interface/src/views/components/GraphScroller.tsx
Normal file
27
pkg/interface/src/views/components/GraphScroller.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
@ -88,7 +88,7 @@ const errorRetry = (meta, focus, uploading, clickUploadButton) => {
|
||||
if (!focus && meta.error !== undefined) {
|
||||
return (
|
||||
<Text
|
||||
position='absolute'
|
||||
position="absolute"
|
||||
left={2}
|
||||
display='flex'
|
||||
height='100%'
|
||||
@ -98,7 +98,8 @@ const errorRetry = (meta, focus, uploading, clickUploadButton) => {
|
||||
style={{ pointerEvents: 'none' }}
|
||||
onSelect={e => e.preventDefault}
|
||||
>
|
||||
{meta.error}{', '}please{' '}
|
||||
{meta.error()}
|
||||
{', '}please{' '}
|
||||
<Text
|
||||
fontWeight='500'
|
||||
cursor='pointer'
|
||||
@ -115,25 +116,24 @@ const errorRetry = (meta, focus, uploading, clickUploadButton) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const clearButton = (field, uploading, clearEvt) => {
|
||||
export const clearButton = (field, uploading, clearEvt) => {
|
||||
if (field.value && !uploading) {
|
||||
return (
|
||||
<Box
|
||||
position='absolute'
|
||||
position="absolute"
|
||||
right={0}
|
||||
top={0}
|
||||
px={1}
|
||||
height='100%'
|
||||
cursor='pointer'
|
||||
height="100%"
|
||||
cursor="pointer"
|
||||
onClick={clearEvt}
|
||||
backgroundColor='white'
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
borderRadius='0 4px 4px 0'
|
||||
border='1px solid'
|
||||
borderColor='lightGray'
|
||||
backgroundColor="white"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
borderRadius="0 4px 4px 0"
|
||||
border="1px solid"
|
||||
borderColor="lightGray"
|
||||
>
|
||||
<Icon icon='X' />
|
||||
<Icon icon="X" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -178,7 +178,7 @@ export function ImageInput(props: ImageInputProps): ReactElement {
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" {...props}>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{label ? <Label htmlFor={id}>{label}</Label> : null}
|
||||
{caption ? (
|
||||
<Label mt={2} gray>
|
||||
{caption}
|
||||
@ -201,10 +201,7 @@ export function ImageInput(props: ImageInputProps): ReactElement {
|
||||
</Box>
|
||||
{canUpload && (
|
||||
<>
|
||||
<Button
|
||||
display='none'
|
||||
onClick={clickUploadButton}
|
||||
/>
|
||||
<Button display="none" onClick={clickUploadButton} />
|
||||
<BaseInput
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
|
13
pkg/interface/src/views/components/Link.tsx
Normal file
13
pkg/interface/src/views/components/Link.tsx
Normal 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);
|
@ -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 }>;
|
339
pkg/interface/src/views/components/RemoteContent/embed.tsx
Normal file
339
pkg/interface/src/views/components/RemoteContent/embed.tsx
Normal 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>
|
||||
);
|
||||
}
|
102
pkg/interface/src/views/components/RemoteContent/index.tsx
Normal file
102
pkg/interface/src/views/components/RemoteContent/index.tsx
Normal 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);
|
79
pkg/interface/src/views/components/RemoteContent/wrapper.tsx
Normal file
79
pkg/interface/src/views/components/RemoteContent/wrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
49
pkg/interface/src/views/components/ShipImage.tsx
Normal file
49
pkg/interface/src/views/components/ShipImage.tsx
Normal 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>
|
||||
);
|
||||
}
|
87
pkg/interface/src/views/components/StatelessUrlInput.tsx
Normal file
87
pkg/interface/src/views/components/StatelessUrlInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
48
pkg/interface/src/views/components/ThreadScroller.tsx
Normal file
48
pkg/interface/src/views/components/ThreadScroller.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
117
pkg/interface/src/views/components/Titlebar.tsx
Normal file
117
pkg/interface/src/views/components/Titlebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
10
pkg/interface/src/views/components/TruncatedText.tsx
Normal file
10
pkg/interface/src/views/components/TruncatedText.tsx
Normal 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;
|
||||
`;
|
||||
|
@ -1,6 +1,4 @@
|
||||
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 normalizeWheel from 'normalize-wheel';
|
||||
import React, { Component, SyntheticEvent, useCallback } from 'react';
|
||||
@ -17,13 +15,20 @@ const ScrollbarLessBox = styled(Box)`
|
||||
}
|
||||
`;
|
||||
|
||||
interface RendererProps {
|
||||
index: BigInteger;
|
||||
interface RendererProps<K> {
|
||||
index: K;
|
||||
scrollWindow: any;
|
||||
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
|
||||
*/
|
||||
@ -37,7 +42,7 @@ interface VirtualScrollerProps<T> {
|
||||
/**
|
||||
* The data to iterate over
|
||||
*/
|
||||
data: BigIntOrderedMap<T>;
|
||||
data: OrderedMap<K,V>;
|
||||
/*
|
||||
* The component to render the items
|
||||
*
|
||||
@ -46,12 +51,11 @@ interface VirtualScrollerProps<T> {
|
||||
* 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;
|
||||
renderer: (props: RendererProps<K>) => JSX.Element | null;
|
||||
onStartReached?(): void;
|
||||
onEndReached?(): void;
|
||||
size: number;
|
||||
pendingSize: number;
|
||||
totalSize: number;
|
||||
/*
|
||||
* Average height of a single rendered item
|
||||
*
|
||||
@ -73,10 +77,22 @@ interface VirtualScrollerProps<T> {
|
||||
* Callback to execute when finished loading from start
|
||||
*/
|
||||
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 {
|
||||
visibleItems: BigInteger[];
|
||||
interface VirtualScrollerState<K> {
|
||||
visibleItems: K[];
|
||||
scrollbar: number;
|
||||
loaded: {
|
||||
top: boolean;
|
||||
@ -85,7 +101,9 @@ interface VirtualScrollerState {
|
||||
}
|
||||
|
||||
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) => {
|
||||
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`
|
||||
* 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
|
||||
*/
|
||||
@ -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
|
||||
*/
|
||||
private savedIndex: BigInteger | null = null;
|
||||
private savedIndex: K | null = null;
|
||||
/*
|
||||
* If saving, the distance between the top of `this.savedEl` and the bottom
|
||||
* of the screen
|
||||
@ -144,7 +162,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
||||
|
||||
private cleanupRefInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(props: VirtualScrollerProps<T>) {
|
||||
constructor(props: VirtualScrollerProps<K,V>) {
|
||||
super(props);
|
||||
this.state = {
|
||||
visibleItems: [],
|
||||
@ -177,8 +195,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
||||
return;
|
||||
}
|
||||
[...this.orphans].forEach((o) => {
|
||||
const index = bigInt(o);
|
||||
this.childRefs.delete(index.toString());
|
||||
this.childRefs.delete(o);
|
||||
});
|
||||
this.orphans.clear();
|
||||
};
|
||||
@ -198,7 +215,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
||||
this.scrollRef.style[this.props.origin] = `${result}px`;
|
||||
}, 50);
|
||||
|
||||
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState) {
|
||||
componentDidUpdate(prevProps: VirtualScrollerProps<K,V>, _prevState: VirtualScrollerState<K>) {
|
||||
const { size, pendingSize } = this.props;
|
||||
|
||||
if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) {
|
||||
@ -220,13 +237,13 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
||||
}
|
||||
|
||||
startOffset() {
|
||||
const { data } = this.props;
|
||||
const { data, keyEq } = this.props;
|
||||
const startIndex = this.state.visibleItems?.[0];
|
||||
if(!startIndex) {
|
||||
return 0;
|
||||
}
|
||||
const dataList = Array.from(data);
|
||||
const offset = dataList.findIndex(([i]) => i.eq(startIndex));
|
||||
const offset = dataList.findIndex(([i]) => keyEq(i, startIndex));
|
||||
if(offset === -1) {
|
||||
// TODO: revisit when we remove nodes for any other reason than
|
||||
// pending indices being removed
|
||||
@ -401,6 +418,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
||||
}
|
||||
|
||||
restore() {
|
||||
const { keyToString } = this.props;
|
||||
if(!this.window || !this.savedIndex) {
|
||||
return;
|
||||
}
|
||||
@ -418,7 +436,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = this.childRefs.get(this.savedIndex.toString());
|
||||
const ref = this.childRefs.get(keyToString(this.savedIndex));
|
||||
if(!ref) {
|
||||
return;
|
||||
}
|
||||
@ -435,17 +453,18 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
||||
});
|
||||
}
|
||||
|
||||
scrollToIndex = (index: BigInteger) => {
|
||||
let ref = this.childRefs.get(index.toString());
|
||||
scrollToIndex = (index: K) => {
|
||||
const { keyToString, keyEq } = this.props;
|
||||
let ref = this.childRefs.get(keyToString(index));
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
this.scrollLocked = false;
|
||||
this.updateVisible(Math.max(offset - this.pageDelta, 0));
|
||||
requestAnimationFrame(() => {
|
||||
ref = this.childRefs.get(index.toString());
|
||||
ref = this.childRefs.get(keyToString(index));
|
||||
requestAnimationFrame(() => {
|
||||
this.savedIndex = null;
|
||||
this.savedDistance = 0;
|
||||
@ -468,6 +487,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
||||
if(!this.window || this.savedIndex) {
|
||||
return;
|
||||
}
|
||||
log('reflow', `saving @ ${this.saveDepth}`);
|
||||
if(this.saveDepth !== 0) {
|
||||
return;
|
||||
}
|
||||
@ -476,13 +496,14 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
||||
|
||||
this.saveDepth++;
|
||||
const { visibleItems } = this.state;
|
||||
const { keyToString } = this.props;
|
||||
|
||||
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(index.toString());
|
||||
const el = this.childRefs.get(keyToString(index));
|
||||
if(!el) {
|
||||
return;
|
||||
}
|
||||
@ -500,7 +521,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
||||
}
|
||||
|
||||
this.savedIndex = bottomIndex;
|
||||
const ref = this.childRefs.get(bottomIndex.toString())!;
|
||||
const ref = this.childRefs.get(keyToString(bottomIndex))!;
|
||||
if(!ref) {
|
||||
this.saveDepth--;
|
||||
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
|
||||
shiftLayout = { save: () => {}, restore: () => {} };
|
||||
|
||||
setRef = (element: HTMLElement | null, index: BigInteger) => {
|
||||
setRef = (element: HTMLElement | null, index: K) => {
|
||||
const { keyToString } = this.props;
|
||||
if(element) {
|
||||
this.childRefs.set(index.toString(), element);
|
||||
this.orphans.delete(index.toString());
|
||||
this.childRefs.set(keyToString(index), element);
|
||||
this.orphans.delete(keyToString(index));
|
||||
} 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 {
|
||||
origin = 'top',
|
||||
renderer,
|
||||
style
|
||||
style,
|
||||
keyEq,
|
||||
keyBunt,
|
||||
keyToString
|
||||
} = this.props;
|
||||
|
||||
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 children = isTop ? visibleItems : [...visibleItems].reverse();
|
||||
|
||||
const atStart = (this.props.data.peekLargest()?.[0] ?? bigInt.zero).eq(visibleItems?.[0] || bigInt.zero);
|
||||
const atEnd = (this.props.data.peekSmallest()?.[0] ?? bigInt.zero).eq(visibleItems?.[visibleItems.length -1 ] || bigInt.zero);
|
||||
const atStart = keyEq(this.props.data.peekLargest()?.[0] ?? keyBunt, visibleItems?.[0] || keyBunt);
|
||||
const atEnd = keyEq(this.props.data.peekSmallest()?.[0] ?? keyBunt, visibleItems?.[visibleItems.length -1 ] || keyBunt);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -559,8 +584,8 @@ backgroundColor="lightGray"
|
||||
</Center>)}
|
||||
<VirtualContext.Provider value={this.shiftLayout}>
|
||||
{children.map(index => (
|
||||
<VirtualChild
|
||||
key={index.toString()}
|
||||
<VirtualChild<K>
|
||||
key={keyToString(index)}
|
||||
setRef={this.setRef}
|
||||
index={index}
|
||||
scrollWindow={this.window}
|
||||
@ -579,14 +604,14 @@ backgroundColor="lightGray"
|
||||
}
|
||||
}
|
||||
|
||||
interface VirtualChildProps {
|
||||
index: BigInteger;
|
||||
interface VirtualChildProps<K> {
|
||||
index: K;
|
||||
scrollWindow: any;
|
||||
setRef: (el: HTMLElement | null, index: BigInteger) => void;
|
||||
renderer: (p: RendererProps) => JSX.Element | null;
|
||||
setRef: (el: HTMLElement | null, index: K) => void;
|
||||
renderer: (p: RendererProps<K>) => JSX.Element | null;
|
||||
}
|
||||
|
||||
function VirtualChild(props: VirtualChildProps) {
|
||||
function VirtualChild<K>(props: VirtualChildProps<K>) {
|
||||
const { setRef, renderer: Renderer, ...rest } = props;
|
||||
|
||||
const ref = useCallback((el: HTMLElement | null) => {
|
||||
|
@ -5,9 +5,9 @@ import bigInt from 'big-integer';
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import { resourceFromPath } from '~/logic/lib/group';
|
||||
import VirtualScroller from '~/views/components/VirtualScroller';
|
||||
import PostItem from './PostItem/PostItem';
|
||||
import PostInput from './PostInput';
|
||||
import { GraphScroller } from '~/views/components/GraphScroller';
|
||||
import useGraphState, { GraphState } from '~/logic/state/graph';
|
||||
import shallow from 'zustand/shallow';
|
||||
|
||||
@ -210,14 +210,13 @@ class PostFeed extends React.Component<PostFeedProps, any> {
|
||||
|
||||
return (
|
||||
<Col width="100%" height="100%" position="relative">
|
||||
<VirtualScroller
|
||||
<GraphScroller
|
||||
key={history.location.pathname}
|
||||
origin="top"
|
||||
offset={0}
|
||||
data={graph}
|
||||
averageHeight={106}
|
||||
averageHeight={80}
|
||||
size={graph.size}
|
||||
totalSize={graph.size}
|
||||
style={virtualScrollerStyle}
|
||||
pendingSize={pendingSize}
|
||||
renderer={this.renderItem}
|
||||
|
@ -4,10 +4,10 @@ import bigInt from 'big-integer';
|
||||
import React from 'react';
|
||||
import { RouteComponentProps, useHistory } from 'react-router';
|
||||
import { resourceFromPath } from '~/logic/lib/group';
|
||||
import ArrayVirtualScroller, {
|
||||
indexEqual,
|
||||
import {
|
||||
arrToString
|
||||
} from '~/views/components/ArrayVirtualScroller';
|
||||
} from '@urbit/api/lib/BigIntArrayOrderedMap';
|
||||
import { keyEq, ThreadScroller } from '~/views/components/ThreadScroller';
|
||||
import PostItem from './PostItem/PostItem';
|
||||
import PostInput from './PostInput';
|
||||
import useGraphState, { GraphState } from '~/logic/state/graph';
|
||||
@ -66,9 +66,9 @@ class PostFlatFeed extends React.Component<PostFeedProps, {}> {
|
||||
|
||||
const first = flatGraph.peekLargest()?.[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) {
|
||||
return (
|
||||
<Col
|
||||
@ -195,12 +195,12 @@ class PostFlatFeed extends React.Component<PostFeedProps, {}> {
|
||||
|
||||
return (
|
||||
<Col width="100%" height="100%" position="relative">
|
||||
<ArrayVirtualScroller
|
||||
<ThreadScroller
|
||||
key={history.location.pathname}
|
||||
origin="top"
|
||||
offset={0}
|
||||
data={flatGraph}
|
||||
averageHeight={122}
|
||||
averageHeight={80}
|
||||
size={flatGraph.size}
|
||||
style={virtualScrollerStyle}
|
||||
pendingSize={pendingSize}
|
||||
|
@ -6,7 +6,7 @@ import React, {
|
||||
} from 'react';
|
||||
import { resourceFromPath } from '~/logic/lib/group';
|
||||
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 PostFlatFeed from './PostFlatFeed';
|
||||
import PostInput from './PostInput';
|
||||
|
@ -82,7 +82,7 @@ export function NewChannel(props: NewChannelProps): ReactElement {
|
||||
}
|
||||
if (group) {
|
||||
await airlock.thread(createManagedGraph(
|
||||
`~${window.ship}`,
|
||||
window.ship,
|
||||
resId,
|
||||
name,
|
||||
description,
|
||||
@ -106,7 +106,7 @@ export function NewChannel(props: NewChannelProps): ReactElement {
|
||||
}
|
||||
} else {
|
||||
await airlock.thread(createUnmanagedGraph(
|
||||
`~${window.ship}`,
|
||||
window.ship,
|
||||
resId,
|
||||
name,
|
||||
description,
|
||||
|
@ -42,6 +42,9 @@ export function Resource(props: ResourceProps): ReactElement {
|
||||
<Helmet defer={false}>
|
||||
<title>{notificationsCount ? `(${String(notificationsCount)}) ` : ''}{ title }</title>
|
||||
</Helmet>
|
||||
{ app === 'link' ? (
|
||||
<LinkResource {...props} />
|
||||
) : (
|
||||
<ResourceSkeleton
|
||||
{...skelProps}
|
||||
baseUrl={relativePath('')}
|
||||
@ -50,10 +53,9 @@ export function Resource(props: ResourceProps): ReactElement {
|
||||
<ChatResource {...props} />
|
||||
) : app === 'publish' ? (
|
||||
<PublishResource {...props} />
|
||||
) : (
|
||||
<LinkResource {...props} />
|
||||
)}
|
||||
) : null }
|
||||
</ResourceSkeleton>
|
||||
)}
|
||||
<Switch>
|
||||
<Route
|
||||
path={relativePath('/settings')}
|
||||
|
@ -6,6 +6,7 @@ import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { isWriter } from '~/logic/lib/group';
|
||||
import { useResize } from '~/logic/lib/useResize';
|
||||
import { getItemTitle } from '~/logic/lib/util';
|
||||
import useContactState from '~/logic/state/contact';
|
||||
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
|
||||
@ -199,9 +200,9 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
|
||||
</Link>
|
||||
);
|
||||
|
||||
const actionsRef = useCallback((actionsRef) => {
|
||||
setActionsWidth(actionsRef?.getBoundingClientRect().width);
|
||||
}, [rid]);
|
||||
const bind = useResize<HTMLDivElement>(useCallback((entry) => {
|
||||
setActionsWidth(entry.borderBoxSize[0].inlineSize);
|
||||
}, []));
|
||||
|
||||
return (
|
||||
<Col width='100%' height='100%' overflow='hidden'>
|
||||
@ -231,7 +232,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
flexShrink={0}
|
||||
ref={actionsRef}
|
||||
{...bind}
|
||||
>
|
||||
{extraControls}
|
||||
{menuControl}
|
||||
|
@ -27,3 +27,7 @@ ol, ul {
|
||||
margin-block-end: 0;
|
||||
|
||||
}
|
||||
|
||||
span {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
@ -472,7 +472,7 @@ export const getFirstborn = (
|
||||
index: string
|
||||
): Scry => ({
|
||||
app: 'graph-store',
|
||||
path: `/firstborn/${ship}/${name}/${encodeIndex(index)}`
|
||||
path: `/firstborn/${ship}/${name}${encodeIndex(index)}`
|
||||
});
|
||||
|
||||
/**
|
||||
@ -489,7 +489,7 @@ export const getNode = (
|
||||
index: string
|
||||
): Scry => ({
|
||||
app: 'graph-store',
|
||||
path: `/node/${ship}/${name}/${encodeIndex(index)}`
|
||||
path: `/node/${ship}/${name}${encodeIndex(index)}`
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -94,7 +94,7 @@ export class Urbit {
|
||||
credentials: 'include',
|
||||
accept: '*',
|
||||
headers,
|
||||
signal: this.abort
|
||||
signal: this.abort.signal
|
||||
};
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user