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
|
++ to-range
|
||||||
|= [item=byts f=@ k=byts]
|
|= [item=byts f=@ k=byts]
|
||||||
^- @
|
^- @
|
||||||
(rsh [0 64] (mul f (swp 3 dat:(siphash k item))))
|
(rsh [0 64] (mul f (rev 3 (siphash k item))))
|
||||||
:: +set-construct: return sorted hashes of scriptpubkeys
|
:: +set-construct: return sorted hashes of scriptpubkeys
|
||||||
::
|
::
|
||||||
++ set-construct
|
++ set-construct
|
||||||
|
@ -36,8 +36,34 @@ export const globalTypes = {
|
|||||||
|
|
||||||
export const decorators = [
|
export const decorators = [
|
||||||
(Story, context) => {
|
(Story, context) => {
|
||||||
|
window.ship = 'sampel-palnet';
|
||||||
const theme = context.globals.theme === 'light' ? light : dark;
|
const theme = context.globals.theme === 'light' ? light : dark;
|
||||||
|
|
||||||
|
useContactState.setState({
|
||||||
|
contacts: {
|
||||||
|
'~ridlur-figbud': {
|
||||||
|
status: 'please like and subscribe',
|
||||||
|
'last-updated': 1616609090555,
|
||||||
|
avatar: null,
|
||||||
|
cover: null,
|
||||||
|
bio: '',
|
||||||
|
nickname: 'Gav',
|
||||||
|
color: '0x26.3e0f',
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
|
'~sampel-palnet': {
|
||||||
|
status: 'A test status',
|
||||||
|
'last-updated': 1616609090555,
|
||||||
|
avatar: null,
|
||||||
|
cover: null,
|
||||||
|
bio: '',
|
||||||
|
nickname: 'You',
|
||||||
|
color: '0x26.3e0f',
|
||||||
|
groups: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useMetadataState.setState({
|
useMetadataState.setState({
|
||||||
associations: {
|
associations: {
|
||||||
groups: {
|
groups: {
|
||||||
@ -66,6 +92,25 @@ export const decorators = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
graph: {
|
graph: {
|
||||||
|
'/ship/~bitbet-bolbel/links': {
|
||||||
|
metadata: {
|
||||||
|
preview: false,
|
||||||
|
vip: '',
|
||||||
|
title: 'Link Collection',
|
||||||
|
description: '',
|
||||||
|
creator: '~darrux-landes',
|
||||||
|
picture: '',
|
||||||
|
hidden: false,
|
||||||
|
config: {
|
||||||
|
graph: 'link',
|
||||||
|
},
|
||||||
|
'date-created': '~2020.4.6..21.53.30..dc68',
|
||||||
|
color: '0x0',
|
||||||
|
},
|
||||||
|
'app-name': 'graph',
|
||||||
|
resource: '/ship/~bitbet-bolbel/links',
|
||||||
|
group: '/ship/~bitbet-bolbel/urbit-community',
|
||||||
|
},
|
||||||
'/ship/~darrux-landes/development': {
|
'/ship/~darrux-landes/development': {
|
||||||
metadata: {
|
metadata: {
|
||||||
preview: false,
|
preview: false,
|
||||||
|
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": {
|
"@hapi/hoek": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.0.tgz",
|
||||||
@ -24185,6 +24204,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"lit-element": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-ogu7PiJTA33bEK0xGu1dmaX5vhcRjBXCFexPja0e7P7jqLhTpNKYRPmE+GmiCaRVAbiQKGkUgkh/i6+bh++dPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"lit-html": "^1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lit-html": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"loader-runner": {
|
"loader-runner": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
|
||||||
@ -29727,6 +29761,15 @@
|
|||||||
"integrity": "sha512-7t+/wpKLanLzSnQPX8WAcuLCCeuSHoWdQuh9SB3xD0kNOM38DNf+0Oa+wmvxmYueRzkmh6IcdKFtvTa+ecgPDw==",
|
"integrity": "sha512-7t+/wpKLanLzSnQPX8WAcuLCCeuSHoWdQuh9SB3xD0kNOM38DNf+0Oa+wmvxmYueRzkmh6IcdKFtvTa+ecgPDw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"storybook-addon-designs": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/storybook-addon-designs/-/storybook-addon-designs-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-nwwUusxOmUt82ajTjfBDQfOU2zEO3WrHxG9J7rZmSLnoC42OC8P/FJtAuhL5JregCQildVbjIfeFfz7pMGJOjQ==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@figspec/react": "^0.1.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"stream-browserify": {
|
"stream-browserify": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
|
||||||
|
@ -105,6 +105,7 @@
|
|||||||
"react-hot-loader": "^4.13.0",
|
"react-hot-loader": "^4.13.0",
|
||||||
"sass": "^1.32.5",
|
"sass": "^1.32.5",
|
||||||
"sass-loader": "^8.0.2",
|
"sass-loader": "^8.0.2",
|
||||||
|
"storybook-addon-designs": "^6.0.0",
|
||||||
"ts-mdast": "^1.0.0",
|
"ts-mdast": "^1.0.0",
|
||||||
"typescript": "^4.2.4",
|
"typescript": "^4.2.4",
|
||||||
"webpack": "^4.46.0",
|
"webpack": "^4.46.0",
|
||||||
|
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;
|
} as Meta;
|
||||||
|
|
||||||
const Template: Story<RemoteContentProps> = args => (
|
const Template: Story<RemoteContentProps> = args => (
|
||||||
<Box backgroundColor="white" p="2" width="500px">
|
<Box backgroundColor="white" p="2" width="800px">
|
||||||
<RemoteContent {...args} />
|
<RemoteContent {...args} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@ -38,3 +38,15 @@ Twitter.args = {
|
|||||||
// massive test flake
|
// massive test flake
|
||||||
unfold: false
|
unfold: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Image = Template.bind({});
|
||||||
|
|
||||||
|
Image.args = {
|
||||||
|
url: 'https://pbs.twimg.com/media/E343N9_UUAIm0Iw.jpg'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Fallback = Template.bind({});
|
||||||
|
|
||||||
|
Fallback.args = {
|
||||||
|
url: 'https://www.are.na/edouard-urcades/edouard'
|
||||||
|
};
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
} from '@urbit/api';
|
} from '@urbit/api';
|
||||||
import bigInt, { BigInteger } from 'big-integer';
|
import bigInt, { BigInteger } from 'big-integer';
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
import { GraphScroller } from '~/views/components/GraphScroller';
|
||||||
import VirtualScroller from '~/views/components/VirtualScroller';
|
import VirtualScroller from '~/views/components/VirtualScroller';
|
||||||
import ChatMessage from './ChatMessage';
|
import ChatMessage from './ChatMessage';
|
||||||
import UnreadNotice from './UnreadNotice';
|
import UnreadNotice from './UnreadNotice';
|
||||||
@ -45,7 +46,7 @@ class ChatWindow extends Component<
|
|||||||
ChatWindowProps,
|
ChatWindowProps,
|
||||||
ChatWindowState
|
ChatWindowState
|
||||||
> {
|
> {
|
||||||
private virtualList: VirtualScroller<GraphNode> | null;
|
private virtualList: VirtualScroller<bigInt.BigInteger, GraphNode> | null;
|
||||||
private prevSize = 0;
|
private prevSize = 0;
|
||||||
private unreadSet = false;
|
private unreadSet = false;
|
||||||
|
|
||||||
@ -257,7 +258,7 @@ class ChatWindow extends Component<
|
|||||||
dismissUnread={this.props.dismissUnread}
|
dismissUnread={this.props.dismissUnread}
|
||||||
onClick={this.scrollToUnread}
|
onClick={this.scrollToUnread}
|
||||||
/>)}
|
/>)}
|
||||||
<VirtualScroller<GraphNode>
|
<GraphScroller
|
||||||
ref={(list) => {
|
ref={(list) => {
|
||||||
this.virtualList = list;
|
this.virtualList = list;
|
||||||
}}
|
}}
|
||||||
|
@ -3,21 +3,21 @@ import { Group } from '@urbit/api';
|
|||||||
import { Association } from '@urbit/api/metadata';
|
import { Association } from '@urbit/api/metadata';
|
||||||
import bigInt from 'big-integer';
|
import bigInt from 'big-integer';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Link, Route, Switch } from 'react-router-dom';
|
import { Link, Route, Switch, useLocation } from 'react-router-dom';
|
||||||
|
import { useQuery } from '~/logic/lib/useQuery';
|
||||||
|
import { Titlebar } from '~/views/components/Titlebar';
|
||||||
import useGraphState from '~/logic/state/graph';
|
import useGraphState from '~/logic/state/graph';
|
||||||
import useMetadataState from '~/logic/state/metadata';
|
import useMetadataState from '~/logic/state/metadata';
|
||||||
import { Comments } from '~/views/components/Comments';
|
|
||||||
import useGroupState from '../../../logic/state/group';
|
import useGroupState from '../../../logic/state/group';
|
||||||
import { LinkItem } from './components/LinkItem';
|
import { LinkBlocks } from './components/LinkBlocks';
|
||||||
|
import { LinkDetail } from './components/LinkDetail';
|
||||||
import './css/custom.css';
|
import './css/custom.css';
|
||||||
import LinkWindow from './LinkWindow';
|
import LinkWindow from './LinkWindow';
|
||||||
|
|
||||||
const emptyMeasure = () => {};
|
interface LinkResourceProps {
|
||||||
|
|
||||||
type LinkResourceProps = {
|
|
||||||
association: Association;
|
association: Association;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export function LinkResource(props: LinkResourceProps) {
|
export function LinkResource(props: LinkResourceProps) {
|
||||||
const {
|
const {
|
||||||
@ -41,6 +41,9 @@ export function LinkResource(props: LinkResourceProps) {
|
|||||||
const graphs = useGraphState(state => state.graphs);
|
const graphs = useGraphState(state => state.graphs);
|
||||||
const graph = graphs[resourcePath] || null;
|
const graph = graphs[resourcePath] || null;
|
||||||
const graphTimesentMap = useGraphState(state => state.graphTimesentMap);
|
const graphTimesentMap = useGraphState(state => state.graphTimesentMap);
|
||||||
|
const { query } = useQuery();
|
||||||
|
const isList = query.has('list');
|
||||||
|
const { pathname, search } = useLocation();
|
||||||
const getGraph = useGraphState(s => s.getGraph);
|
const getGraph = useGraphState(s => s.getGraph);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -48,19 +51,31 @@ export function LinkResource(props: LinkResourceProps) {
|
|||||||
}, [association]);
|
}, [association]);
|
||||||
|
|
||||||
const resourceUrl = `${baseUrl}/resource/link${rid}`;
|
const resourceUrl = `${baseUrl}/resource/link${rid}`;
|
||||||
if (!graph) {
|
if (!graph || !resource) {
|
||||||
return <Center width='100%' height='100%'><LoadingSpinner /></Center>;
|
return <Center width='100%' height='100%'><LoadingSpinner /></Center>;
|
||||||
}
|
}
|
||||||
|
const { title, description } = resource.metadata;
|
||||||
|
|
||||||
|
const titlebar = (back?: string) => (
|
||||||
|
<Titlebar back={back && `${back}${search}`} title={title} description={description} workspace={baseUrl} baseUrl={resourceUrl} >
|
||||||
|
<Link to={{ pathname, search: isList ? '' : '?list=true' }}>
|
||||||
|
<Text bold pr='3' color='blue'>
|
||||||
|
Switch to {!isList ? 'list' : 'grid' }
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
</Titlebar>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col alignItems="center" height="100%" width="100%" overflowY="hidden">
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path={relativePath('')}
|
path={relativePath('')}
|
||||||
render={(props) => {
|
render={(props) => {
|
||||||
return (
|
return (
|
||||||
// @ts-ignore state helper weirdness
|
<Col minWidth="0" overflow="hidden">
|
||||||
|
{titlebar()}
|
||||||
|
{ isList ? /* @ts-ignore withState typings */ (
|
||||||
<LinkWindow
|
<LinkWindow
|
||||||
key={rid}
|
key={rid}
|
||||||
association={resource}
|
association={resource}
|
||||||
@ -72,6 +87,10 @@ export function LinkResource(props: LinkResourceProps) {
|
|||||||
pendingSize={Object.keys(graphTimesentMap[resourcePath] || {}).length}
|
pendingSize={Object.keys(graphTimesentMap[resourcePath] || {}).length}
|
||||||
mb={3}
|
mb={3}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<LinkBlocks graph={graph} association={resource} />
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -79,7 +98,6 @@ export function LinkResource(props: LinkResourceProps) {
|
|||||||
path={relativePath('/index/:index')}
|
path={relativePath('/index/:index')}
|
||||||
render={(props) => {
|
render={(props) => {
|
||||||
const index = bigInt(props.match.params.index);
|
const index = bigInt(props.match.params.index);
|
||||||
const editCommentId = props.match.params.commentId || null;
|
|
||||||
|
|
||||||
if (!index) {
|
if (!index) {
|
||||||
return <div>Malformed URL</div>;
|
return <div>Malformed URL</div>;
|
||||||
@ -99,38 +117,19 @@ export function LinkResource(props: LinkResourceProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Col alignItems="center" overflowY="auto" width="100%">
|
<Col overflow="hidden">
|
||||||
<Col width="100%" p={3} maxWidth="768px">
|
{titlebar(relativePath(''))}
|
||||||
<Link to={resourceUrl}><Text px={3} bold>{'<- Back'}</Text></Link>
|
<LinkDetail
|
||||||
<LinkItem
|
|
||||||
key={node.post.index}
|
|
||||||
resource={resourcePath}
|
|
||||||
node={node}
|
node={node}
|
||||||
baseUrl={resourceUrl}
|
|
||||||
association={association}
|
association={association}
|
||||||
group={group as Group}
|
baseUrl={pathname}
|
||||||
path={resource?.group}
|
flexGrow={1}
|
||||||
mt={3}
|
maxHeight="calc(100% - 48px)"
|
||||||
measure={emptyMeasure}
|
|
||||||
/>
|
/>
|
||||||
<Comments
|
|
||||||
ship={ship}
|
|
||||||
name={name}
|
|
||||||
comments={node}
|
|
||||||
resource={resourcePath}
|
|
||||||
association={association}
|
|
||||||
editCommentId={editCommentId}
|
|
||||||
history={props.history}
|
|
||||||
baseUrl={`${resourceUrl}/index/${props.match.params.index}`}
|
|
||||||
group={group as Group}
|
|
||||||
px={3}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Col>
|
</Col>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Col>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { Box, Col, Text } from '@tlon/indigo-react';
|
import { Box, Col, Text } from '@tlon/indigo-react';
|
||||||
import { Association, Graph, Group } from '@urbit/api';
|
import { Association, Graph, Group } from '@urbit/api';
|
||||||
import bigInt from 'big-integer';
|
import bigInt, { BigInteger } from 'big-integer';
|
||||||
import React, {
|
import React, {
|
||||||
Component, ReactNode
|
Component, ReactNode
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { isWriter } from '~/logic/lib/group';
|
import { isWriter } from '~/logic/lib/group';
|
||||||
import VirtualScroller from '~/views/components/VirtualScroller';
|
import { GraphScroller } from '~/views/components/GraphScroller';
|
||||||
import { LinkItem } from './components/LinkItem';
|
import { LinkItem } from './components/LinkItem';
|
||||||
import LinkSubmit from './components/LinkSubmit';
|
import LinkSubmit from './components/LinkSubmit';
|
||||||
|
|
||||||
@ -58,7 +58,6 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
|
|||||||
...props,
|
...props,
|
||||||
node
|
node
|
||||||
};
|
};
|
||||||
{ /* @ts-ignore calling @liam-fitzgerald on Uint8Array props */ }
|
|
||||||
if (this.canWrite() && index.eq(first ?? bigInt.zero)) {
|
if (this.canWrite() && index.eq(first ?? bigInt.zero)) {
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={index.toString()}>
|
<React.Fragment key={index.toString()}>
|
||||||
@ -123,9 +122,9 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col width="100%" height="100%" position="relative">
|
<Col width="100%" height="calc(100% - 48px)" position="relative">
|
||||||
{/* @ts-ignore calling @liam-fitzgerald on virtualscroller */}
|
{/* @ts-ignore calling @liam-fitzgerald on virtualscroller */}
|
||||||
<VirtualScroller
|
<GraphScroller
|
||||||
origin="top"
|
origin="top"
|
||||||
offset={0}
|
offset={0}
|
||||||
style={style}
|
style={style}
|
||||||
|
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;
|
association: Association;
|
||||||
resource: string;
|
resource: string;
|
||||||
group: Group;
|
group: Group;
|
||||||
path: string;
|
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
mt?: number;
|
mt?: number;
|
||||||
measure?: any;
|
measure?: any;
|
||||||
@ -35,7 +34,10 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
|
|||||||
return <Redirect to="/~404" />;
|
return <Redirect to="/~404" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteRef = useRef<typeof RemoteContent | null>(null);
|
const remoteRef = useRef<HTMLDivElement>(null);
|
||||||
|
const setRef = useCallback((el: HTMLDivElement | null ) => {
|
||||||
|
remoteRef.current = el;
|
||||||
|
}, []);
|
||||||
const index = node.post.index.split('/')[1];
|
const index = node.post.index.split('/')[1];
|
||||||
|
|
||||||
const markRead = useCallback(() => {
|
const markRead = useCallback(() => {
|
||||||
@ -100,7 +102,7 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
|
|||||||
const unreads = useHarkState(state => state.unreads?.[appPath]);
|
const unreads = useHarkState(state => state.unreads?.[appPath]);
|
||||||
const commColor = (unreads?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray';
|
const commColor = (unreads?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray';
|
||||||
// @ts-ignore hark will have to choose between sets and numbers
|
// @ts-ignore hark will have to choose between sets and numbers
|
||||||
const isUnread = (unreads?.['/']?.unreads ?? new Set()).has(node.post.index);
|
const isUnread = unreads?.['/']?.unreads?.has?.(node.post.index);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@ -125,7 +127,7 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
|
|||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
onClick={markRead}
|
onClick={markRead}
|
||||||
>
|
>
|
||||||
<Text p={2}>{contents[0].text}</Text>
|
{contents[0].text ? <Text p={2}>{contents[0].text}</Text> : null}
|
||||||
{ 'reference' in contents[1] ? (
|
{ 'reference' in contents[1] ? (
|
||||||
<>
|
<>
|
||||||
<Rule />
|
<Rule />
|
||||||
@ -134,34 +136,11 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RemoteContent
|
<RemoteContent
|
||||||
ref={(r) => {
|
embedRef={setRef}
|
||||||
// @ts-ignore RemoteContent weirdness
|
|
||||||
remoteRef.current = r;
|
|
||||||
}}
|
|
||||||
// @ts-ignore RemoteContent weirdness
|
// @ts-ignore RemoteContent weirdness
|
||||||
renderUrl={false}
|
renderUrl={false}
|
||||||
url={href}
|
url={href}
|
||||||
text={contents[0].text}
|
tall
|
||||||
unfold={true}
|
|
||||||
style={{ alignSelf: 'center' }}
|
|
||||||
oembedProps={{
|
|
||||||
p: 2,
|
|
||||||
className: 'links embed-container',
|
|
||||||
onClick: markRead
|
|
||||||
}}
|
|
||||||
imageProps={{
|
|
||||||
marginLeft: 'auto',
|
|
||||||
marginRight: 'auto',
|
|
||||||
display: 'block'
|
|
||||||
}}
|
|
||||||
textProps={{
|
|
||||||
overflow: 'hidden',
|
|
||||||
color: 'black',
|
|
||||||
display: 'block',
|
|
||||||
alignSelf: 'center',
|
|
||||||
style: { textOverflow: 'ellipsis', whiteSpace: 'pre', width: '100%' },
|
|
||||||
p: 2
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Text color="gray" p={2} flexShrink={0}>
|
<Text color="gray" p={2} flexShrink={0}>
|
||||||
<Anchor target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }} href={href}>
|
<Anchor target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }} href={href}>
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { BaseInput, Box, Button, LoadingSpinner, Text } from '@tlon/indigo-react';
|
import { BaseInput, Box, Button, LoadingSpinner } from '@tlon/indigo-react';
|
||||||
import { hasProvider } from 'oembed-parser';
|
import { hasProvider } from 'oembed-parser';
|
||||||
import React, { useCallback, useState, DragEvent, useEffect } from 'react';
|
import React, { useCallback, useState, DragEvent, useEffect } from 'react';
|
||||||
import { parsePermalink, permalinkToReference } from '~/logic/lib/permalinks';
|
import { parsePermalink, permalinkToReference } from '~/logic/lib/permalinks';
|
||||||
import { useFileDrag } from '~/logic/lib/useDrag';
|
import { useFileDrag } from '~/logic/lib/useDrag';
|
||||||
import useStorage from '~/logic/lib/useStorage';
|
import useStorage from '~/logic/lib/useStorage';
|
||||||
|
import { StatelessUrlInput } from '~/views/components/StatelessUrlInput';
|
||||||
import SubmitDragger from '~/views/components/SubmitDragger';
|
import SubmitDragger from '~/views/components/SubmitDragger';
|
||||||
import useGraphState from '~/logic/state/graph';
|
import useGraphState from '~/logic/state/graph';
|
||||||
import { createPost } from '@urbit/api';
|
import { createPost } from '@urbit/api';
|
||||||
@ -20,7 +21,6 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
|||||||
const addPost = useGraphState(s => s.addPost);
|
const addPost = useGraphState(s => s.addPost);
|
||||||
|
|
||||||
const [submitFocused, setSubmitFocused] = useState(false);
|
const [submitFocused, setSubmitFocused] = useState(false);
|
||||||
const [urlFocused, setUrlFocused] = useState(false);
|
|
||||||
const [linkValue, setLinkValue] = useState('');
|
const [linkValue, setLinkValue] = useState('');
|
||||||
const [linkTitle, setLinkTitle] = useState('');
|
const [linkTitle, setLinkTitle] = useState('');
|
||||||
const [disabled, setDisabled] = useState(false);
|
const [disabled, setDisabled] = useState(false);
|
||||||
@ -35,7 +35,7 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
|||||||
|
|
||||||
setDisabled(true);
|
setDisabled(true);
|
||||||
const parentIndex = props.parentIndex || '';
|
const parentIndex = props.parentIndex || '';
|
||||||
const post = createPost(`~${window.ship}`, contents, parentIndex);
|
const post = createPost(window.ship, contents, parentIndex);
|
||||||
|
|
||||||
addPost(
|
addPost(
|
||||||
`~${props.ship}`,
|
`~${props.ship}`,
|
||||||
@ -135,26 +135,6 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const placeholder = <Text
|
|
||||||
gray
|
|
||||||
position="absolute"
|
|
||||||
px={2}
|
|
||||||
pt={2}
|
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
>{canUpload
|
|
||||||
? <>
|
|
||||||
Drop or{' '}
|
|
||||||
<Text
|
|
||||||
cursor='pointer'
|
|
||||||
color='blue'
|
|
||||||
style={{ pointerEvents: 'all' }}
|
|
||||||
onClick={() => promptUpload().then(setLinkValue)}
|
|
||||||
>upload</Text>
|
|
||||||
{' '}a file, or paste a link here
|
|
||||||
</>
|
|
||||||
: 'Paste a link here'
|
|
||||||
}</Text>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* @ts-ignore archaic event type mismatch */}
|
{/* @ts-ignore archaic event type mismatch */}
|
||||||
@ -182,25 +162,16 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
|||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</Box>}
|
</Box>}
|
||||||
{dragging && <SubmitDragger />}
|
{dragging && <SubmitDragger />}
|
||||||
<Box position='relative'>
|
<StatelessUrlInput
|
||||||
{!(linkValue || urlFocused || disabled) && placeholder}
|
|
||||||
<BaseInput
|
|
||||||
type="url"
|
|
||||||
pl={2}
|
|
||||||
width="100%"
|
|
||||||
py={2}
|
|
||||||
color="black"
|
|
||||||
backgroundColor="transparent"
|
|
||||||
onChange={e => setLinkValue(e.target.value)}
|
|
||||||
onBlur={() => [setUrlFocused(false), setSubmitFocused(false)]}
|
|
||||||
onFocus={() => [setUrlFocused(true), setSubmitFocused(true)]}
|
|
||||||
spellCheck="false"
|
|
||||||
// @ts-ignore archaic event type mismatch error
|
|
||||||
onPaste={onPaste}
|
|
||||||
onKeyPress={onKeyPress}
|
|
||||||
value={linkValue}
|
value={linkValue}
|
||||||
|
promptUpload={promptUpload}
|
||||||
|
canUpload={canUpload}
|
||||||
|
onSubmit={doPost}
|
||||||
|
onChange={setLinkValue}
|
||||||
|
error={linkValid ? 'Invalid URL' : undefined}
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
onPaste={onPaste}
|
||||||
/>
|
/>
|
||||||
</Box>
|
|
||||||
<BaseInput
|
<BaseInput
|
||||||
type="text"
|
type="text"
|
||||||
pl={2}
|
pl={2}
|
||||||
|
@ -4,7 +4,6 @@ import {
|
|||||||
GroupNotifIndex,
|
GroupNotifIndex,
|
||||||
GroupUpdate
|
GroupUpdate
|
||||||
} from '@urbit/api';
|
} from '@urbit/api';
|
||||||
import bigInt from 'big-integer';
|
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import React, { ReactElement } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
import { useAssocForGroup } from '~/logic/state/metadata';
|
import { useAssocForGroup } from '~/logic/state/metadata';
|
||||||
@ -35,7 +34,6 @@ interface GroupNotificationProps {
|
|||||||
index: GroupNotifIndex;
|
index: GroupNotifIndex;
|
||||||
contents: GroupNotificationContents;
|
contents: GroupNotificationContents;
|
||||||
time: number;
|
time: number;
|
||||||
timebox: bigInt.BigInteger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupNotification(props: GroupNotificationProps): ReactElement {
|
export function GroupNotification(props: GroupNotificationProps): ReactElement {
|
||||||
|
@ -132,7 +132,6 @@ export function Notification(props: NotificationProps) {
|
|||||||
<GroupNotification
|
<GroupNotification
|
||||||
index={index}
|
index={index}
|
||||||
contents={c}
|
contents={c}
|
||||||
timebox={props.time}
|
|
||||||
time={time}
|
time={time}
|
||||||
/>
|
/>
|
||||||
</NotificationWrapper>
|
</NotificationWrapper>
|
||||||
|
@ -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'}
|
fontWeight={showNickname ? '500' : '400'}
|
||||||
mr={showNickname ? 0 : '2px'}
|
mr={showNickname ? 0 : '2px'}
|
||||||
mt={showNickname ? 0 : '0px'}
|
mt={showNickname ? 0 : '0px'}
|
||||||
|
overflow="hidden"
|
||||||
|
textOverflow="ellipsis"
|
||||||
|
whiteSpace="nowrap"
|
||||||
title={showNickname ? cite(ship) : contact?.nickname}
|
title={showNickname ? cite(ship) : contact?.nickname}
|
||||||
onClick={doCopy}
|
onClick={doCopy}
|
||||||
>
|
>
|
||||||
|
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 {
|
||||||
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
Action,
|
||||||
import React from 'react';
|
BaseTextArea as TextArea,
|
||||||
|
Box,
|
||||||
|
Col,
|
||||||
|
Row
|
||||||
|
} from '@tlon/indigo-react';
|
||||||
|
import {
|
||||||
|
Formik,
|
||||||
|
FormikHelpers,
|
||||||
|
useField,
|
||||||
|
useFormikContext
|
||||||
|
} from 'formik';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import { AsyncButton } from './AsyncButton';
|
import { ShipImage } from './ShipImage';
|
||||||
|
|
||||||
interface FormSchema {
|
interface FormSchema {
|
||||||
comment: string;
|
comment: string;
|
||||||
@ -18,26 +29,62 @@ interface CommentInputProps {
|
|||||||
actions: FormikHelpers<FormSchema>
|
actions: FormikHelpers<FormSchema>
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
initial?: string;
|
initial?: string;
|
||||||
loadingText?: string;
|
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
const SubmitTextArea = (props) => {
|
const SubmitTextArea = (props) => {
|
||||||
const { submitForm } = useFormikContext<FormSchema>();
|
const { submitForm } = useFormikContext<FormSchema>();
|
||||||
|
const [field] = useField(props.id);
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.getModifierState('Control') || e.metaKey) && e.key === 'Enter') {
|
if ((e.getModifierState('Control') || e.metaKey) && e.key === 'Enter') {
|
||||||
submitForm();
|
submitForm();
|
||||||
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return <TextArea onKeyDown={onKeyDown} {...props} />;
|
return (
|
||||||
|
<TextArea
|
||||||
|
lineHeight="tall"
|
||||||
|
backgroundColor="white"
|
||||||
|
color="black"
|
||||||
|
fontFamily="sans"
|
||||||
|
fontWeight="500"
|
||||||
|
fontSize="1"
|
||||||
|
flexGrow={1}
|
||||||
|
style={{ resize: 'vertical' }}
|
||||||
|
{...field}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function FormikHelper(props: { initialValues: any }) {
|
||||||
|
const { initialValues } = props;
|
||||||
|
const { resetForm } = useFormikContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetForm(initialValues);
|
||||||
|
}, [initialValues]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CommentInput(props: CommentInputProps) {
|
export default function CommentInput(props: CommentInputProps) {
|
||||||
const initialValues: FormSchema = { comment: props.initial || '' };
|
const initialValues: FormSchema = { comment: props.initial || '' };
|
||||||
const label = props.label || 'Add Comment';
|
const label = props.label || 'Comment';
|
||||||
const loading = props.loadingText || 'Commenting...';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Row
|
||||||
|
marginLeft="-8px"
|
||||||
|
width="105%"
|
||||||
|
border="1"
|
||||||
|
borderColor="lightGray"
|
||||||
|
borderRadius="2"
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<Box p="2">
|
||||||
|
<ShipImage ship={`~${window.ship}`} />
|
||||||
|
</Box>
|
||||||
<Formik
|
<Formik
|
||||||
validationSchema={formSchema}
|
validationSchema={formSchema}
|
||||||
onSubmit={props.onSubmit}
|
onSubmit={props.onSubmit}
|
||||||
@ -45,15 +92,27 @@ export default function CommentInput(props: CommentInputProps) {
|
|||||||
validateOnBlur={false}
|
validateOnBlur={false}
|
||||||
validateOnChange={false}
|
validateOnChange={false}
|
||||||
>
|
>
|
||||||
<Form>
|
{({ submitForm }) => (
|
||||||
|
<Col pb="1" pr="2" pt="2" flexGrow={1}>
|
||||||
|
<FormikHelper initialValues={initialValues} />
|
||||||
<SubmitTextArea
|
<SubmitTextArea
|
||||||
|
width="100%"
|
||||||
id="comment"
|
id="comment"
|
||||||
placeholder={props.placeholder || ''}
|
placeholder={props.placeholder || ''}
|
||||||
/>
|
/>
|
||||||
<AsyncButton mt={2} loadingText={loading} border type="submit">
|
<Action
|
||||||
|
type="submit"
|
||||||
|
my="1"
|
||||||
|
width="fit-content"
|
||||||
|
alignSelf="flex-end"
|
||||||
|
backgroundColor="white"
|
||||||
|
onClick={submitForm}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</AsyncButton>
|
</Action>
|
||||||
</Form>
|
</Col>
|
||||||
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,18 @@
|
|||||||
import { Action, Box, Row, Text } from '@tlon/indigo-react';
|
import { Action, Box, Col, Icon, Row, Text } from '@tlon/indigo-react';
|
||||||
import { Group, removePosts } from '@urbit/api';
|
import { Group, removePosts } from '@urbit/api';
|
||||||
import { GraphNode } from '@urbit/api/graph';
|
import { GraphNode } from '@urbit/api/graph';
|
||||||
import bigInt from 'big-integer';
|
import bigInt from 'big-integer';
|
||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { roleForShip } from '~/logic/lib/group';
|
import { roleForShip } from '~/logic/lib/group';
|
||||||
import { getPermalinkForGraph } from '~/logic/lib/permalinks';
|
import { getPermalinkForGraph } from '~/logic/lib/permalinks';
|
||||||
import { getLatestCommentRevision } from '~/logic/lib/publish';
|
import { getLatestCommentRevision } from '~/logic/lib/publish';
|
||||||
import { useCopy } from '~/logic/lib/useCopy';
|
import { useCopy } from '~/logic/lib/useCopy';
|
||||||
|
import { useHovering } from '~/logic/lib/util';
|
||||||
import useMetadataState from '~/logic/state/metadata';
|
import useMetadataState from '~/logic/state/metadata';
|
||||||
import Author from '~/views/components/Author';
|
import Author from '~/views/components/Author';
|
||||||
|
import { ActionLink } from '~/views/components/Link';
|
||||||
import { GraphContent } from '../landscape/components/Graph/GraphContent';
|
import { GraphContent } from '../landscape/components/Graph/GraphContent';
|
||||||
|
import { Dropdown } from './Dropdown';
|
||||||
import airlock from '~/logic/api';
|
import airlock from '~/logic/api';
|
||||||
|
|
||||||
interface CommentItemProps {
|
interface CommentItemProps {
|
||||||
@ -28,7 +30,10 @@ export function CommentItem(props: CommentItemProps) {
|
|||||||
let { highlighted } = props;
|
let { highlighted } = props;
|
||||||
const { ship, name, comment, group } = props;
|
const { ship, name, comment, group } = props;
|
||||||
const association = useMetadataState(
|
const association = useMetadataState(
|
||||||
useCallback(s => s.associations.graph[`/ship/${ship}/${name}`], [ship,name])
|
useCallback(s => s.associations.graph[`/ship/${ship}/${name}`], [
|
||||||
|
ship,
|
||||||
|
name
|
||||||
|
])
|
||||||
);
|
);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [, post] = getLatestCommentRevision(comment);
|
const [, post] = getLatestCommentRevision(comment);
|
||||||
@ -63,32 +68,14 @@ return false;
|
|||||||
highlighted = true;
|
highlighted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const { hovering, bind } = useHovering();
|
||||||
|
|
||||||
const commentIndexArray = (comment.post?.index || '/').split('/');
|
const commentIndexArray = (comment.post?.index || '/').split('/');
|
||||||
const commentIndex = commentIndexArray[commentIndexArray.length - 1];
|
const commentIndex = commentIndexArray[commentIndexArray.length - 1];
|
||||||
|
|
||||||
const adminLinks: JSX.Element[] = [];
|
|
||||||
const ourRole = roleForShip(group, window.ship);
|
const ourRole = roleForShip(group, window.ship);
|
||||||
if (window.ship == post?.author && !disabled) {
|
|
||||||
adminLinks.push(
|
|
||||||
<Link to={{ pathname: props.baseUrl, search: `?edit=${commentIndex}` }}>
|
|
||||||
<Action bg="white">
|
|
||||||
Update
|
|
||||||
</Action>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((window.ship == post?.author || ourRole == 'admin') && !disabled) {
|
|
||||||
adminLinks.push(
|
|
||||||
<Action bg="white" onClick={onDelete} destructive>
|
|
||||||
Delete
|
|
||||||
</Action>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if(ref.current && props.highlighted) {
|
if (ref.current && props.highlighted) {
|
||||||
ref.current.scrollIntoView({ block: 'center' });
|
ref.current.scrollIntoView({ block: 'center' });
|
||||||
}
|
}
|
||||||
}, [ref, props.highlighted]);
|
}, [ref, props.highlighted]);
|
||||||
@ -111,26 +98,68 @@ return false;
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={ref} mb={4} opacity={post?.pending ? '60%' : '100%'}>
|
<Box {...bind} ref={ref} mb={4} opacity={post?.pending ? '60%' : '100%'}>
|
||||||
<Row px={1} my={3}>
|
<Row justifyContent="space-between" alignItems="center" my={1} pr="1">
|
||||||
<Author
|
<Author
|
||||||
|
size={24}
|
||||||
|
sigilPadding={4}
|
||||||
showImage
|
showImage
|
||||||
ship={post?.author}
|
ship={post?.author}
|
||||||
date={post?.['time-sent']}
|
date={post?.['time-sent']}
|
||||||
unread={props.unread}
|
unread={props.unread}
|
||||||
group={group}
|
group={group}
|
||||||
isRelativeTime
|
isRelativeTime
|
||||||
|
></Author>
|
||||||
|
<Box opacity={hovering ? '100%' : '0%'}>
|
||||||
|
<Dropdown
|
||||||
|
alignX="right"
|
||||||
|
alignY="top"
|
||||||
|
options={
|
||||||
|
<Col
|
||||||
|
p="2"
|
||||||
|
border="1"
|
||||||
|
borderRadius="1"
|
||||||
|
borderColor="lightGray"
|
||||||
|
backgroundColor="white"
|
||||||
|
gapY="2"
|
||||||
>
|
>
|
||||||
<Row px={2} gapX={2} height="18px">
|
<Action bg="white" onClick={doCopy}>
|
||||||
<Action bg="white" onClick={doCopy}>{copyDisplay}</Action>
|
{copyDisplay}
|
||||||
{adminLinks}
|
</Action>
|
||||||
</Row>
|
{(window.ship == post?.author && !disabled) ? (
|
||||||
</Author>
|
<ActionLink
|
||||||
|
color="blue"
|
||||||
|
to={{
|
||||||
|
pathname: props.baseUrl,
|
||||||
|
search: `?edit=${commentIndex}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</ActionLink>
|
||||||
|
) : null}
|
||||||
|
{(window.ship == post?.author || ourRole == 'admin') &&
|
||||||
|
!disabled ? (
|
||||||
|
<Action
|
||||||
|
height="unset"
|
||||||
|
bg="white"
|
||||||
|
onClick={onDelete}
|
||||||
|
destructive
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Action>
|
||||||
|
) : null}
|
||||||
|
</Col>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon icon="Ellipsis" />
|
||||||
|
</Dropdown>
|
||||||
|
</Box>
|
||||||
</Row>
|
</Row>
|
||||||
<GraphContent
|
<GraphContent
|
||||||
borderRadius={1}
|
borderRadius={1}
|
||||||
p={1}
|
p={1}
|
||||||
mb={1}
|
mb={1}
|
||||||
|
ml="28px"
|
||||||
backgroundColor={highlighted ? 'washedBlue' : 'white'}
|
backgroundColor={highlighted ? 'washedBlue' : 'white'}
|
||||||
transcluded={0}
|
transcluded={0}
|
||||||
contents={post.contents}
|
contents={post.contents}
|
||||||
|
@ -1,5 +1,14 @@
|
|||||||
import { Col } from '@tlon/indigo-react';
|
import { Col } from '@tlon/indigo-react';
|
||||||
import { createPost, createBlankNodeWithChildPost, Association, GraphNode, Group, markCountAsRead, addPost } from '@urbit/api';
|
import {
|
||||||
|
createPost,
|
||||||
|
createBlankNodeWithChildPost,
|
||||||
|
Association,
|
||||||
|
GraphNode,
|
||||||
|
Group,
|
||||||
|
markCountAsRead,
|
||||||
|
addPost,
|
||||||
|
resourceFromPath
|
||||||
|
} from '@urbit/api';
|
||||||
import bigInt from 'big-integer';
|
import bigInt from 'big-integer';
|
||||||
import { FormikHelpers } from 'formik';
|
import { FormikHelpers } from 'formik';
|
||||||
import React, { useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
@ -15,12 +24,11 @@ import CommentInput from './CommentInput';
|
|||||||
import { CommentItem } from './CommentItem';
|
import { CommentItem } from './CommentItem';
|
||||||
import airlock from '~/logic/api';
|
import airlock from '~/logic/api';
|
||||||
import useGraphState from '~/logic/state/graph';
|
import useGraphState from '~/logic/state/graph';
|
||||||
|
import { useHistory } from 'react-router';
|
||||||
|
|
||||||
interface CommentsProps {
|
interface CommentsProps {
|
||||||
comments: GraphNode;
|
comments: GraphNode;
|
||||||
association: Association;
|
association: Association;
|
||||||
name: string;
|
|
||||||
ship: string;
|
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
group: Group;
|
group: Group;
|
||||||
}
|
}
|
||||||
@ -29,14 +37,14 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
|
|||||||
const {
|
const {
|
||||||
association,
|
association,
|
||||||
comments,
|
comments,
|
||||||
ship,
|
|
||||||
name,
|
|
||||||
history,
|
|
||||||
baseUrl,
|
baseUrl,
|
||||||
group,
|
group,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
const addNode = useGraphState(s => s.addNode);
|
const addNode = useGraphState(s => s.addNode);
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const { ship, name } = resourceFromPath(association.resource);
|
||||||
|
|
||||||
const { query } = useQuery();
|
const { query } = useQuery();
|
||||||
const selectedComment = useMemo(() => {
|
const selectedComment = useMemo(() => {
|
||||||
@ -80,7 +88,7 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
|
|||||||
|
|
||||||
const content = tokenizeMessage(comment);
|
const content = tokenizeMessage(comment);
|
||||||
const post = createPost(
|
const post = createPost(
|
||||||
`~${window.ship}`,
|
window.ship,
|
||||||
content,
|
content,
|
||||||
commentNode.post.index,
|
commentNode.post.index,
|
||||||
parseInt((idx + 1).toString(), 10).toString()
|
parseInt((idx + 1).toString(), 10).toString()
|
||||||
@ -96,7 +104,7 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
|
|||||||
let commentContent = null;
|
let commentContent = null;
|
||||||
if (editCommentId) {
|
if (editCommentId) {
|
||||||
const commentNode = comments.children.get(bigInt(editCommentId));
|
const commentNode = comments.children.get(bigInt(editCommentId));
|
||||||
const [,post] = getLatestCommentRevision(commentNode);
|
const [, post] = getLatestCommentRevision(commentNode);
|
||||||
commentContent = post.contents.reduce((val, curr) => {
|
commentContent = post.contents.reduce((val, curr) => {
|
||||||
if ('text' in curr) {
|
if ('text' in curr) {
|
||||||
val = val + curr.text;
|
val = val + curr.text;
|
||||||
@ -130,15 +138,6 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Col {...rest} minWidth={0}>
|
<Col {...rest} minWidth={0}>
|
||||||
{( !editCommentId && canComment ? <CommentInput onSubmit={onSubmit} /> : null )}
|
|
||||||
{( editCommentId ? (
|
|
||||||
<CommentInput
|
|
||||||
onSubmit={onEdit}
|
|
||||||
label='Edit Comment'
|
|
||||||
loadingText='Editing...'
|
|
||||||
initial={commentContent}
|
|
||||||
/>
|
|
||||||
) : null )}
|
|
||||||
{children.reverse()
|
{children.reverse()
|
||||||
.map(([idx, comment], i) => {
|
.map(([idx, comment], i) => {
|
||||||
const highlighted = selectedComment?.eq(idx) ?? false;
|
const highlighted = selectedComment?.eq(idx) ?? false;
|
||||||
@ -156,6 +155,14 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{( editCommentId ? (
|
||||||
|
<CommentInput
|
||||||
|
onSubmit={onEdit}
|
||||||
|
label='Edit Comment'
|
||||||
|
initial={commentContent}
|
||||||
|
/>
|
||||||
|
) : null )}
|
||||||
|
{( !editCommentId && canComment ? <CommentInput placeholder="Comment" onSubmit={onSubmit} /> : null )}
|
||||||
</Col>
|
</Col>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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) {
|
if (!focus && meta.error !== undefined) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
position='absolute'
|
position="absolute"
|
||||||
left={2}
|
left={2}
|
||||||
display='flex'
|
display='flex'
|
||||||
height='100%'
|
height='100%'
|
||||||
@ -98,7 +98,8 @@ const errorRetry = (meta, focus, uploading, clickUploadButton) => {
|
|||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: 'none' }}
|
||||||
onSelect={e => e.preventDefault}
|
onSelect={e => e.preventDefault}
|
||||||
>
|
>
|
||||||
{meta.error}{', '}please{' '}
|
{meta.error()}
|
||||||
|
{', '}please{' '}
|
||||||
<Text
|
<Text
|
||||||
fontWeight='500'
|
fontWeight='500'
|
||||||
cursor='pointer'
|
cursor='pointer'
|
||||||
@ -115,25 +116,24 @@ const errorRetry = (meta, focus, uploading, clickUploadButton) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearButton = (field, uploading, clearEvt) => {
|
export const clearButton = (field, uploading, clearEvt) => {
|
||||||
if (field.value && !uploading) {
|
if (field.value && !uploading) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
position='absolute'
|
position="absolute"
|
||||||
right={0}
|
right={0}
|
||||||
top={0}
|
|
||||||
px={1}
|
px={1}
|
||||||
height='100%'
|
height="100%"
|
||||||
cursor='pointer'
|
cursor="pointer"
|
||||||
onClick={clearEvt}
|
onClick={clearEvt}
|
||||||
backgroundColor='white'
|
backgroundColor="white"
|
||||||
display='flex'
|
display="flex"
|
||||||
alignItems='center'
|
alignItems="center"
|
||||||
borderRadius='0 4px 4px 0'
|
borderRadius="0 4px 4px 0"
|
||||||
border='1px solid'
|
border="1px solid"
|
||||||
borderColor='lightGray'
|
borderColor="lightGray"
|
||||||
>
|
>
|
||||||
<Icon icon='X' />
|
<Icon icon="X" />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -178,7 +178,7 @@ export function ImageInput(props: ImageInputProps): ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box display="flex" flexDirection="column" {...props}>
|
<Box display="flex" flexDirection="column" {...props}>
|
||||||
<Label htmlFor={id}>{label}</Label>
|
{label ? <Label htmlFor={id}>{label}</Label> : null}
|
||||||
{caption ? (
|
{caption ? (
|
||||||
<Label mt={2} gray>
|
<Label mt={2} gray>
|
||||||
{caption}
|
{caption}
|
||||||
@ -201,10 +201,7 @@ export function ImageInput(props: ImageInputProps): ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
{canUpload && (
|
{canUpload && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button display="none" onClick={clickUploadButton} />
|
||||||
display='none'
|
|
||||||
onClick={clickUploadButton}
|
|
||||||
/>
|
|
||||||
<BaseInput
|
<BaseInput
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
type="file"
|
type="file"
|
||||||
|
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 { Box, Center, LoadingSpinner } from '@tlon/indigo-react';
|
||||||
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
|
|
||||||
import bigInt, { BigInteger } from 'big-integer';
|
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import normalizeWheel from 'normalize-wheel';
|
import normalizeWheel from 'normalize-wheel';
|
||||||
import React, { Component, SyntheticEvent, useCallback } from 'react';
|
import React, { Component, SyntheticEvent, useCallback } from 'react';
|
||||||
@ -17,13 +15,20 @@ const ScrollbarLessBox = styled(Box)`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface RendererProps {
|
interface RendererProps<K> {
|
||||||
index: BigInteger;
|
index: K;
|
||||||
scrollWindow: any;
|
scrollWindow: any;
|
||||||
ref: (el: HTMLElement | null) => void;
|
ref: (el: HTMLElement | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VirtualScrollerProps<T> {
|
interface OrderedMap<K,V> extends Iterable<[K,V]> {
|
||||||
|
peekLargest: () => [K,V] | undefined;
|
||||||
|
peekSmallest: () => [K,V] | undefined;
|
||||||
|
size: number;
|
||||||
|
keys: () => K[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualScrollerProps<K,V> {
|
||||||
/**
|
/**
|
||||||
* Start scroll from
|
* Start scroll from
|
||||||
*/
|
*/
|
||||||
@ -37,7 +42,7 @@ interface VirtualScrollerProps<T> {
|
|||||||
/**
|
/**
|
||||||
* The data to iterate over
|
* The data to iterate over
|
||||||
*/
|
*/
|
||||||
data: BigIntOrderedMap<T>;
|
data: OrderedMap<K,V>;
|
||||||
/*
|
/*
|
||||||
* The component to render the items
|
* The component to render the items
|
||||||
*
|
*
|
||||||
@ -46,12 +51,11 @@ interface VirtualScrollerProps<T> {
|
|||||||
* This component must be referentially stable, so either use `useCallback` or
|
* This component must be referentially stable, so either use `useCallback` or
|
||||||
* a instance method. It must also forward the DOM ref from its root DOM node
|
* a instance method. It must also forward the DOM ref from its root DOM node
|
||||||
*/
|
*/
|
||||||
renderer: (props: RendererProps) => JSX.Element | null;
|
renderer: (props: RendererProps<K>) => JSX.Element | null;
|
||||||
onStartReached?(): void;
|
onStartReached?(): void;
|
||||||
onEndReached?(): void;
|
onEndReached?(): void;
|
||||||
size: number;
|
size: number;
|
||||||
pendingSize: number;
|
pendingSize: number;
|
||||||
totalSize: number;
|
|
||||||
/*
|
/*
|
||||||
* Average height of a single rendered item
|
* Average height of a single rendered item
|
||||||
*
|
*
|
||||||
@ -73,10 +77,22 @@ interface VirtualScrollerProps<T> {
|
|||||||
* Callback to execute when finished loading from start
|
* Callback to execute when finished loading from start
|
||||||
*/
|
*/
|
||||||
onBottomLoaded?: () => void;
|
onBottomLoaded?: () => void;
|
||||||
|
/*
|
||||||
|
* equality function for the key type
|
||||||
|
*/
|
||||||
|
keyEq: (a: K, b: K) => boolean;
|
||||||
|
/*
|
||||||
|
* string conversion for key type
|
||||||
|
*/
|
||||||
|
keyToString: (k: K) => string;
|
||||||
|
/*
|
||||||
|
* default value for key type
|
||||||
|
*/
|
||||||
|
keyBunt: K;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VirtualScrollerState {
|
interface VirtualScrollerState<K> {
|
||||||
visibleItems: BigInteger[];
|
visibleItems: K[];
|
||||||
scrollbar: number;
|
scrollbar: number;
|
||||||
loaded: {
|
loaded: {
|
||||||
top: boolean;
|
top: boolean;
|
||||||
@ -85,7 +101,9 @@ interface VirtualScrollerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LogLevel = 'scroll' | 'network' | 'bail' | 'reflow';
|
type LogLevel = 'scroll' | 'network' | 'bail' | 'reflow';
|
||||||
const logLevel = ['network', 'bail', 'scroll', 'reflow'] as LogLevel[];
|
const logLevel = process.env.NODE_ENV === 'production'
|
||||||
|
? []
|
||||||
|
: ['network', 'bail', 'scroll', 'reflow'] as LogLevel[];
|
||||||
|
|
||||||
const log = (level: LogLevel, message: string) => {
|
const log = (level: LogLevel, message: string) => {
|
||||||
if(logLevel.includes(level)) {
|
if(logLevel.includes(level)) {
|
||||||
@ -104,7 +122,7 @@ const ZONE_SIZE = IS_IOS ? 20 : 80;
|
|||||||
* VirtualScroller does not clean up or reset itself, so please use `key`
|
* VirtualScroller does not clean up or reset itself, so please use `key`
|
||||||
* to ensure a new instance is created for each BigIntOrderedMap
|
* to ensure a new instance is created for each BigIntOrderedMap
|
||||||
*/
|
*/
|
||||||
export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T>, VirtualScrollerState> {
|
export default class VirtualScroller<K,V> extends Component<VirtualScrollerProps<K,V>, VirtualScrollerState<K>> {
|
||||||
/*
|
/*
|
||||||
* A reference to our scroll container
|
* A reference to our scroll container
|
||||||
*/
|
*/
|
||||||
@ -120,7 +138,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
/*
|
/*
|
||||||
* If saving, the bottommost visible element that we pin our scroll to
|
* If saving, the bottommost visible element that we pin our scroll to
|
||||||
*/
|
*/
|
||||||
private savedIndex: BigInteger | null = null;
|
private savedIndex: K | null = null;
|
||||||
/*
|
/*
|
||||||
* If saving, the distance between the top of `this.savedEl` and the bottom
|
* If saving, the distance between the top of `this.savedEl` and the bottom
|
||||||
* of the screen
|
* of the screen
|
||||||
@ -144,7 +162,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
|
|
||||||
private cleanupRefInterval: NodeJS.Timeout | null = null;
|
private cleanupRefInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(props: VirtualScrollerProps<T>) {
|
constructor(props: VirtualScrollerProps<K,V>) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
visibleItems: [],
|
visibleItems: [],
|
||||||
@ -177,8 +195,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
[...this.orphans].forEach((o) => {
|
[...this.orphans].forEach((o) => {
|
||||||
const index = bigInt(o);
|
this.childRefs.delete(o);
|
||||||
this.childRefs.delete(index.toString());
|
|
||||||
});
|
});
|
||||||
this.orphans.clear();
|
this.orphans.clear();
|
||||||
};
|
};
|
||||||
@ -198,7 +215,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
this.scrollRef.style[this.props.origin] = `${result}px`;
|
this.scrollRef.style[this.props.origin] = `${result}px`;
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState) {
|
componentDidUpdate(prevProps: VirtualScrollerProps<K,V>, _prevState: VirtualScrollerState<K>) {
|
||||||
const { size, pendingSize } = this.props;
|
const { size, pendingSize } = this.props;
|
||||||
|
|
||||||
if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) {
|
if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) {
|
||||||
@ -220,13 +237,13 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
}
|
}
|
||||||
|
|
||||||
startOffset() {
|
startOffset() {
|
||||||
const { data } = this.props;
|
const { data, keyEq } = this.props;
|
||||||
const startIndex = this.state.visibleItems?.[0];
|
const startIndex = this.state.visibleItems?.[0];
|
||||||
if(!startIndex) {
|
if(!startIndex) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const dataList = Array.from(data);
|
const dataList = Array.from(data);
|
||||||
const offset = dataList.findIndex(([i]) => i.eq(startIndex));
|
const offset = dataList.findIndex(([i]) => keyEq(i, startIndex));
|
||||||
if(offset === -1) {
|
if(offset === -1) {
|
||||||
// TODO: revisit when we remove nodes for any other reason than
|
// TODO: revisit when we remove nodes for any other reason than
|
||||||
// pending indices being removed
|
// pending indices being removed
|
||||||
@ -401,6 +418,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
}
|
}
|
||||||
|
|
||||||
restore() {
|
restore() {
|
||||||
|
const { keyToString } = this.props;
|
||||||
if(!this.window || !this.savedIndex) {
|
if(!this.window || !this.savedIndex) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -418,7 +436,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ref = this.childRefs.get(this.savedIndex.toString());
|
const ref = this.childRefs.get(keyToString(this.savedIndex));
|
||||||
if(!ref) {
|
if(!ref) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -435,17 +453,18 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToIndex = (index: BigInteger) => {
|
scrollToIndex = (index: K) => {
|
||||||
let ref = this.childRefs.get(index.toString());
|
const { keyToString, keyEq } = this.props;
|
||||||
|
let ref = this.childRefs.get(keyToString(index));
|
||||||
if(!ref) {
|
if(!ref) {
|
||||||
const offset = [...this.props.data].findIndex(([idx]) => idx.eq(index));
|
const offset = [...this.props.data].findIndex(([idx]) => keyEq(idx, index));
|
||||||
if(offset === -1) {
|
if(offset === -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.scrollLocked = false;
|
this.scrollLocked = false;
|
||||||
this.updateVisible(Math.max(offset - this.pageDelta, 0));
|
this.updateVisible(Math.max(offset - this.pageDelta, 0));
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
ref = this.childRefs.get(index.toString());
|
ref = this.childRefs.get(keyToString(index));
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this.savedIndex = null;
|
this.savedIndex = null;
|
||||||
this.savedDistance = 0;
|
this.savedDistance = 0;
|
||||||
@ -468,6 +487,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
if(!this.window || this.savedIndex) {
|
if(!this.window || this.savedIndex) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
log('reflow', `saving @ ${this.saveDepth}`);
|
||||||
if(this.saveDepth !== 0) {
|
if(this.saveDepth !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -476,13 +496,14 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
|
|
||||||
this.saveDepth++;
|
this.saveDepth++;
|
||||||
const { visibleItems } = this.state;
|
const { visibleItems } = this.state;
|
||||||
|
const { keyToString } = this.props;
|
||||||
|
|
||||||
let bottomIndex = visibleItems[visibleItems.length - 1];
|
let bottomIndex = visibleItems[visibleItems.length - 1];
|
||||||
const { scrollTop, scrollHeight } = this.window;
|
const { scrollTop, scrollHeight } = this.window;
|
||||||
const topSpacing = this.props.origin === 'top' ? scrollTop : scrollHeight - scrollTop;
|
const topSpacing = this.props.origin === 'top' ? scrollTop : scrollHeight - scrollTop;
|
||||||
const items = this.props.origin === 'top' ? visibleItems : [...visibleItems].reverse();
|
const items = this.props.origin === 'top' ? visibleItems : [...visibleItems].reverse();
|
||||||
items.forEach((index) => {
|
items.forEach((index) => {
|
||||||
const el = this.childRefs.get(index.toString());
|
const el = this.childRefs.get(keyToString(index));
|
||||||
if(!el) {
|
if(!el) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -500,7 +521,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.savedIndex = bottomIndex;
|
this.savedIndex = bottomIndex;
|
||||||
const ref = this.childRefs.get(bottomIndex.toString())!;
|
const ref = this.childRefs.get(keyToString(bottomIndex))!;
|
||||||
if(!ref) {
|
if(!ref) {
|
||||||
this.saveDepth--;
|
this.saveDepth--;
|
||||||
log('bail', 'missing ref');
|
log('bail', 'missing ref');
|
||||||
@ -513,12 +534,13 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
// disabled until we work out race conditions with loading new nodes
|
// disabled until we work out race conditions with loading new nodes
|
||||||
shiftLayout = { save: () => {}, restore: () => {} };
|
shiftLayout = { save: () => {}, restore: () => {} };
|
||||||
|
|
||||||
setRef = (element: HTMLElement | null, index: BigInteger) => {
|
setRef = (element: HTMLElement | null, index: K) => {
|
||||||
|
const { keyToString } = this.props;
|
||||||
if(element) {
|
if(element) {
|
||||||
this.childRefs.set(index.toString(), element);
|
this.childRefs.set(keyToString(index), element);
|
||||||
this.orphans.delete(index.toString());
|
this.orphans.delete(keyToString(index));
|
||||||
} else {
|
} else {
|
||||||
this.orphans.add(index.toString());
|
this.orphans.add(keyToString(index));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -530,7 +552,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
const {
|
const {
|
||||||
origin = 'top',
|
origin = 'top',
|
||||||
renderer,
|
renderer,
|
||||||
style
|
style,
|
||||||
|
keyEq,
|
||||||
|
keyBunt,
|
||||||
|
keyToString
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const isTop = origin === 'top';
|
const isTop = origin === 'top';
|
||||||
@ -538,8 +563,8 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
|
|||||||
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
|
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
|
||||||
const children = isTop ? visibleItems : [...visibleItems].reverse();
|
const children = isTop ? visibleItems : [...visibleItems].reverse();
|
||||||
|
|
||||||
const atStart = (this.props.data.peekLargest()?.[0] ?? bigInt.zero).eq(visibleItems?.[0] || bigInt.zero);
|
const atStart = keyEq(this.props.data.peekLargest()?.[0] ?? keyBunt, visibleItems?.[0] || keyBunt);
|
||||||
const atEnd = (this.props.data.peekSmallest()?.[0] ?? bigInt.zero).eq(visibleItems?.[visibleItems.length -1 ] || bigInt.zero);
|
const atEnd = keyEq(this.props.data.peekSmallest()?.[0] ?? keyBunt, visibleItems?.[visibleItems.length -1 ] || keyBunt);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -559,8 +584,8 @@ backgroundColor="lightGray"
|
|||||||
</Center>)}
|
</Center>)}
|
||||||
<VirtualContext.Provider value={this.shiftLayout}>
|
<VirtualContext.Provider value={this.shiftLayout}>
|
||||||
{children.map(index => (
|
{children.map(index => (
|
||||||
<VirtualChild
|
<VirtualChild<K>
|
||||||
key={index.toString()}
|
key={keyToString(index)}
|
||||||
setRef={this.setRef}
|
setRef={this.setRef}
|
||||||
index={index}
|
index={index}
|
||||||
scrollWindow={this.window}
|
scrollWindow={this.window}
|
||||||
@ -579,14 +604,14 @@ backgroundColor="lightGray"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VirtualChildProps {
|
interface VirtualChildProps<K> {
|
||||||
index: BigInteger;
|
index: K;
|
||||||
scrollWindow: any;
|
scrollWindow: any;
|
||||||
setRef: (el: HTMLElement | null, index: BigInteger) => void;
|
setRef: (el: HTMLElement | null, index: K) => void;
|
||||||
renderer: (p: RendererProps) => JSX.Element | null;
|
renderer: (p: RendererProps<K>) => JSX.Element | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function VirtualChild(props: VirtualChildProps) {
|
function VirtualChild<K>(props: VirtualChildProps<K>) {
|
||||||
const { setRef, renderer: Renderer, ...rest } = props;
|
const { setRef, renderer: Renderer, ...rest } = props;
|
||||||
|
|
||||||
const ref = useCallback((el: HTMLElement | null) => {
|
const ref = useCallback((el: HTMLElement | null) => {
|
||||||
|
@ -5,9 +5,9 @@ import bigInt from 'big-integer';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useHistory } from 'react-router';
|
import { useHistory } from 'react-router';
|
||||||
import { resourceFromPath } from '~/logic/lib/group';
|
import { resourceFromPath } from '~/logic/lib/group';
|
||||||
import VirtualScroller from '~/views/components/VirtualScroller';
|
|
||||||
import PostItem from './PostItem/PostItem';
|
import PostItem from './PostItem/PostItem';
|
||||||
import PostInput from './PostInput';
|
import PostInput from './PostInput';
|
||||||
|
import { GraphScroller } from '~/views/components/GraphScroller';
|
||||||
import useGraphState, { GraphState } from '~/logic/state/graph';
|
import useGraphState, { GraphState } from '~/logic/state/graph';
|
||||||
import shallow from 'zustand/shallow';
|
import shallow from 'zustand/shallow';
|
||||||
|
|
||||||
@ -210,14 +210,13 @@ class PostFeed extends React.Component<PostFeedProps, any> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Col width="100%" height="100%" position="relative">
|
<Col width="100%" height="100%" position="relative">
|
||||||
<VirtualScroller
|
<GraphScroller
|
||||||
key={history.location.pathname}
|
key={history.location.pathname}
|
||||||
origin="top"
|
origin="top"
|
||||||
offset={0}
|
offset={0}
|
||||||
data={graph}
|
data={graph}
|
||||||
averageHeight={106}
|
averageHeight={80}
|
||||||
size={graph.size}
|
size={graph.size}
|
||||||
totalSize={graph.size}
|
|
||||||
style={virtualScrollerStyle}
|
style={virtualScrollerStyle}
|
||||||
pendingSize={pendingSize}
|
pendingSize={pendingSize}
|
||||||
renderer={this.renderItem}
|
renderer={this.renderItem}
|
||||||
|
@ -4,10 +4,10 @@ import bigInt from 'big-integer';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RouteComponentProps, useHistory } from 'react-router';
|
import { RouteComponentProps, useHistory } from 'react-router';
|
||||||
import { resourceFromPath } from '~/logic/lib/group';
|
import { resourceFromPath } from '~/logic/lib/group';
|
||||||
import ArrayVirtualScroller, {
|
import {
|
||||||
indexEqual,
|
|
||||||
arrToString
|
arrToString
|
||||||
} from '~/views/components/ArrayVirtualScroller';
|
} from '@urbit/api/lib/BigIntArrayOrderedMap';
|
||||||
|
import { keyEq, ThreadScroller } from '~/views/components/ThreadScroller';
|
||||||
import PostItem from './PostItem/PostItem';
|
import PostItem from './PostItem/PostItem';
|
||||||
import PostInput from './PostInput';
|
import PostInput from './PostInput';
|
||||||
import useGraphState, { GraphState } from '~/logic/state/graph';
|
import useGraphState, { GraphState } from '~/logic/state/graph';
|
||||||
@ -66,9 +66,9 @@ class PostFlatFeed extends React.Component<PostFeedProps, {}> {
|
|||||||
|
|
||||||
const first = flatGraph.peekLargest()?.[0];
|
const first = flatGraph.peekLargest()?.[0];
|
||||||
const last = flatGraph.peekSmallest()?.[0];
|
const last = flatGraph.peekSmallest()?.[0];
|
||||||
const isLast = last ? indexEqual(index, last) : false;
|
const isLast = last ? keyEq(index, last) : false;
|
||||||
|
|
||||||
if (indexEqual(index, (first ?? [bigInt.zero]))) {
|
if (keyEq(index, (first ?? [bigInt.zero]))) {
|
||||||
if (isThread) {
|
if (isThread) {
|
||||||
return (
|
return (
|
||||||
<Col
|
<Col
|
||||||
@ -195,12 +195,12 @@ class PostFlatFeed extends React.Component<PostFeedProps, {}> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Col width="100%" height="100%" position="relative">
|
<Col width="100%" height="100%" position="relative">
|
||||||
<ArrayVirtualScroller
|
<ThreadScroller
|
||||||
key={history.location.pathname}
|
key={history.location.pathname}
|
||||||
origin="top"
|
origin="top"
|
||||||
offset={0}
|
offset={0}
|
||||||
data={flatGraph}
|
data={flatGraph}
|
||||||
averageHeight={122}
|
averageHeight={80}
|
||||||
size={flatGraph.size}
|
size={flatGraph.size}
|
||||||
style={virtualScrollerStyle}
|
style={virtualScrollerStyle}
|
||||||
pendingSize={pendingSize}
|
pendingSize={pendingSize}
|
||||||
|
@ -6,7 +6,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { resourceFromPath } from '~/logic/lib/group';
|
import { resourceFromPath } from '~/logic/lib/group';
|
||||||
import { Loading } from '~/views/components/Loading';
|
import { Loading } from '~/views/components/Loading';
|
||||||
import { arrToString } from '~/views/components/ArrayVirtualScroller';
|
import { arrToString } from '@urbit/api/lib/BigIntArrayOrderedMap';
|
||||||
import useGraphState from '~/logic/state/graph';
|
import useGraphState from '~/logic/state/graph';
|
||||||
import PostFlatFeed from './PostFlatFeed';
|
import PostFlatFeed from './PostFlatFeed';
|
||||||
import PostInput from './PostInput';
|
import PostInput from './PostInput';
|
||||||
|
@ -82,7 +82,7 @@ export function NewChannel(props: NewChannelProps): ReactElement {
|
|||||||
}
|
}
|
||||||
if (group) {
|
if (group) {
|
||||||
await airlock.thread(createManagedGraph(
|
await airlock.thread(createManagedGraph(
|
||||||
`~${window.ship}`,
|
window.ship,
|
||||||
resId,
|
resId,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@ -106,7 +106,7 @@ export function NewChannel(props: NewChannelProps): ReactElement {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await airlock.thread(createUnmanagedGraph(
|
await airlock.thread(createUnmanagedGraph(
|
||||||
`~${window.ship}`,
|
window.ship,
|
||||||
resId,
|
resId,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
|
@ -42,6 +42,9 @@ export function Resource(props: ResourceProps): ReactElement {
|
|||||||
<Helmet defer={false}>
|
<Helmet defer={false}>
|
||||||
<title>{notificationsCount ? `(${String(notificationsCount)}) ` : ''}{ title }</title>
|
<title>{notificationsCount ? `(${String(notificationsCount)}) ` : ''}{ title }</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
{ app === 'link' ? (
|
||||||
|
<LinkResource {...props} />
|
||||||
|
) : (
|
||||||
<ResourceSkeleton
|
<ResourceSkeleton
|
||||||
{...skelProps}
|
{...skelProps}
|
||||||
baseUrl={relativePath('')}
|
baseUrl={relativePath('')}
|
||||||
@ -50,10 +53,9 @@ export function Resource(props: ResourceProps): ReactElement {
|
|||||||
<ChatResource {...props} />
|
<ChatResource {...props} />
|
||||||
) : app === 'publish' ? (
|
) : app === 'publish' ? (
|
||||||
<PublishResource {...props} />
|
<PublishResource {...props} />
|
||||||
) : (
|
) : null }
|
||||||
<LinkResource {...props} />
|
|
||||||
)}
|
|
||||||
</ResourceSkeleton>
|
</ResourceSkeleton>
|
||||||
|
)}
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route
|
<Route
|
||||||
path={relativePath('/settings')}
|
path={relativePath('/settings')}
|
||||||
|
@ -6,6 +6,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import urbitOb from 'urbit-ob';
|
import urbitOb from 'urbit-ob';
|
||||||
import { isWriter } from '~/logic/lib/group';
|
import { isWriter } from '~/logic/lib/group';
|
||||||
|
import { useResize } from '~/logic/lib/useResize';
|
||||||
import { getItemTitle } from '~/logic/lib/util';
|
import { getItemTitle } from '~/logic/lib/util';
|
||||||
import useContactState from '~/logic/state/contact';
|
import useContactState from '~/logic/state/contact';
|
||||||
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
|
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
|
||||||
@ -199,9 +200,9 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
const actionsRef = useCallback((actionsRef) => {
|
const bind = useResize<HTMLDivElement>(useCallback((entry) => {
|
||||||
setActionsWidth(actionsRef?.getBoundingClientRect().width);
|
setActionsWidth(entry.borderBoxSize[0].inlineSize);
|
||||||
}, [rid]);
|
}, []));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col width='100%' height='100%' overflow='hidden'>
|
<Col width='100%' height='100%' overflow='hidden'>
|
||||||
@ -231,7 +232,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
|
|||||||
display='flex'
|
display='flex'
|
||||||
alignItems='center'
|
alignItems='center'
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
ref={actionsRef}
|
{...bind}
|
||||||
>
|
>
|
||||||
{extraControls}
|
{extraControls}
|
||||||
{menuControl}
|
{menuControl}
|
||||||
|
@ -27,3 +27,7 @@ ol, ul {
|
|||||||
margin-block-end: 0;
|
margin-block-end: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
@ -472,7 +472,7 @@ export const getFirstborn = (
|
|||||||
index: string
|
index: string
|
||||||
): Scry => ({
|
): Scry => ({
|
||||||
app: 'graph-store',
|
app: 'graph-store',
|
||||||
path: `/firstborn/${ship}/${name}/${encodeIndex(index)}`
|
path: `/firstborn/${ship}/${name}${encodeIndex(index)}`
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -489,7 +489,7 @@ export const getNode = (
|
|||||||
index: string
|
index: string
|
||||||
): Scry => ({
|
): Scry => ({
|
||||||
app: 'graph-store',
|
app: 'graph-store',
|
||||||
path: `/node/${ship}/${name}/${encodeIndex(index)}`
|
path: `/node/${ship}/${name}${encodeIndex(index)}`
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -94,7 +94,7 @@ export class Urbit {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
accept: '*',
|
accept: '*',
|
||||||
headers,
|
headers,
|
||||||
signal: this.abort
|
signal: this.abort.signal
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user