mirror of
https://github.com/urbit/shrub.git
synced 2024-12-01 06:35:32 +03:00
Merge pull request #4996 from urbit/lf/nu-collections
Collections: new layout
This commit is contained in:
commit
fe5e0456cc
@ -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",
|
||||||
|
@ -103,6 +103,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'
|
||||||
|
};
|
||||||
|
@ -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,89 +51,85 @@ 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 (
|
||||||
|
<Col minWidth="0" overflow="hidden">
|
||||||
|
{titlebar()}
|
||||||
|
{ isList ? /* @ts-ignore withState typings */ (
|
||||||
|
<LinkWindow
|
||||||
|
key={rid}
|
||||||
|
association={resource}
|
||||||
|
resource={resourcePath}
|
||||||
|
graph={graph}
|
||||||
|
baseUrl={resourceUrl}
|
||||||
|
group={group as Group}
|
||||||
|
path={resource.group}
|
||||||
|
pendingSize={Object.keys(graphTimesentMap[resourcePath] || {}).length}
|
||||||
|
mb={3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LinkBlocks graph={graph} association={resource} />
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={relativePath('/index/:index')}
|
||||||
|
render={(props) => {
|
||||||
|
const index = bigInt(props.match.params.index);
|
||||||
|
|
||||||
|
if (!index) {
|
||||||
|
return <div>Malformed URL</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = graph ? graph.get(index) : null;
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
return <Box>Not found</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node.post === 'string') {
|
||||||
return (
|
return (
|
||||||
// @ts-ignore state helper weirdness
|
<Col width="100%" textAlign="center" pt="2">
|
||||||
<LinkWindow
|
<Text gray>This link has been deleted.</Text>
|
||||||
key={rid}
|
|
||||||
association={resource}
|
|
||||||
resource={resourcePath}
|
|
||||||
graph={graph}
|
|
||||||
baseUrl={resourceUrl}
|
|
||||||
group={group as Group}
|
|
||||||
path={resource.group}
|
|
||||||
pendingSize={Object.keys(graphTimesentMap[resourcePath] || {}).length}
|
|
||||||
mb={3}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={relativePath('/index/:index')}
|
|
||||||
render={(props) => {
|
|
||||||
const index = bigInt(props.match.params.index);
|
|
||||||
const editCommentId = props.match.params.commentId || null;
|
|
||||||
|
|
||||||
if (!index) {
|
|
||||||
return <div>Malformed URL</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = graph ? graph.get(index) : null;
|
|
||||||
|
|
||||||
if (!node) {
|
|
||||||
return <Box>Not found</Box>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof node.post === 'string') {
|
|
||||||
return (
|
|
||||||
<Col width="100%" textAlign="center" pt="2">
|
|
||||||
<Text gray>This link has been deleted.</Text>
|
|
||||||
</Col>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Col alignItems="center" overflowY="auto" width="100%">
|
|
||||||
<Col width="100%" p={3} maxWidth="768px">
|
|
||||||
<Link to={resourceUrl}><Text px={3} bold>{'<- Back'}</Text></Link>
|
|
||||||
<LinkItem
|
|
||||||
key={node.post.index}
|
|
||||||
resource={resourcePath}
|
|
||||||
node={node}
|
|
||||||
baseUrl={resourceUrl}
|
|
||||||
association={association}
|
|
||||||
group={group as Group}
|
|
||||||
path={resource?.group}
|
|
||||||
mt={3}
|
|
||||||
measure={emptyMeasure}
|
|
||||||
/>
|
|
||||||
<Comments
|
|
||||||
ship={ship}
|
|
||||||
name={name}
|
|
||||||
comments={node}
|
|
||||||
resource={resourcePath}
|
|
||||||
association={association}
|
|
||||||
editCommentId={editCommentId}
|
|
||||||
history={props.history}
|
|
||||||
baseUrl={`${resourceUrl}/index/${props.match.params.index}`}
|
|
||||||
group={group as Group}
|
|
||||||
px={3}
|
|
||||||
/>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Col>
|
|
||||||
);
|
);
|
||||||
}}
|
}
|
||||||
/>
|
return (
|
||||||
</Switch>
|
<Col overflow="hidden">
|
||||||
</Col>
|
{titlebar(relativePath(''))}
|
||||||
|
<LinkDetail
|
||||||
|
node={node}
|
||||||
|
association={association}
|
||||||
|
baseUrl={pathname}
|
||||||
|
flexGrow={1}
|
||||||
|
maxHeight="calc(100% - 48px)"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
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';
|
||||||
@ -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,7 +122,7 @@ 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
|
<VirtualScroller
|
||||||
origin="top"
|
origin="top"
|
||||||
|
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 VirtualScroller from '~/views/components/VirtualScroller';
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<VirtualScroller
|
||||||
|
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 */}
|
||||||
@ -181,26 +161,17 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
|||||||
>
|
>
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</Box>}
|
</Box>}
|
||||||
{dragging && <SubmitDragger />}
|
{dragging && <SubmitDragger />}
|
||||||
<Box position='relative'>
|
<StatelessUrlInput
|
||||||
{!(linkValue || urlFocused || disabled) && placeholder}
|
value={linkValue}
|
||||||
<BaseInput
|
promptUpload={promptUpload}
|
||||||
type="url"
|
canUpload={canUpload}
|
||||||
pl={2}
|
onSubmit={doPost}
|
||||||
width="100%"
|
onChange={setLinkValue}
|
||||||
py={2}
|
error={linkValid ? 'Invalid URL' : undefined}
|
||||||
color="black"
|
onKeyPress={onKeyPress}
|
||||||
backgroundColor="transparent"
|
onPaste={onPaste}
|
||||||
onChange={e => setLinkValue(e.target.value)}
|
/>
|
||||||
onBlur={() => [setUrlFocused(false), setSubmitFocused(false)]}
|
|
||||||
onFocus={() => [setUrlFocused(true), setSubmitFocused(true)]}
|
|
||||||
spellCheck="false"
|
|
||||||
// @ts-ignore archaic event type mismatch error
|
|
||||||
onPaste={onPaste}
|
|
||||||
onKeyPress={onKeyPress}
|
|
||||||
value={linkValue}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<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>
|
||||||
|
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}
|
||||||
>
|
>
|
||||||
|
@ -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,42 +29,90 @@ 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 (
|
||||||
<Formik
|
<Row
|
||||||
validationSchema={formSchema}
|
marginLeft="-8px"
|
||||||
onSubmit={props.onSubmit}
|
width="105%"
|
||||||
initialValues={initialValues}
|
border="1"
|
||||||
validateOnBlur={false}
|
borderColor="lightGray"
|
||||||
validateOnChange={false}
|
borderRadius="2"
|
||||||
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
<Form>
|
<Box p="2">
|
||||||
<SubmitTextArea
|
<ShipImage ship={`~${window.ship}`} />
|
||||||
id="comment"
|
</Box>
|
||||||
placeholder={props.placeholder || ''}
|
<Formik
|
||||||
/>
|
validationSchema={formSchema}
|
||||||
<AsyncButton mt={2} loadingText={loading} border type="submit">
|
onSubmit={props.onSubmit}
|
||||||
{label}
|
initialValues={initialValues}
|
||||||
</AsyncButton>
|
validateOnBlur={false}
|
||||||
</Form>
|
validateOnChange={false}
|
||||||
</Formik>
|
>
|
||||||
|
{({ submitForm }) => (
|
||||||
|
<Col pb="1" pr="2" pt="2" flexGrow={1}>
|
||||||
|
<FormikHelper initialValues={initialValues} />
|
||||||
|
<SubmitTextArea
|
||||||
|
width="100%"
|
||||||
|
id="comment"
|
||||||
|
placeholder={props.placeholder || ''}
|
||||||
|
/>
|
||||||
|
<Action
|
||||||
|
type="submit"
|
||||||
|
my="1"
|
||||||
|
width="fit-content"
|
||||||
|
alignSelf="flex-end"
|
||||||
|
backgroundColor="white"
|
||||||
|
onClick={submitForm}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Action>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,18 @@
|
|||||||
import { Action, Box, Row, Text } from '@tlon/indigo-react';
|
import { Action, Box, Col, Icon, Row, Text } from '@tlon/indigo-react';
|
||||||
import { Group, removePosts } from '@urbit/api';
|
import { 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>
|
||||||
<Row px={2} gapX={2} height="18px">
|
<Box opacity={hovering ? '100%' : '0%'}>
|
||||||
<Action bg="white" onClick={doCopy}>{copyDisplay}</Action>
|
<Dropdown
|
||||||
{adminLinks}
|
alignX="right"
|
||||||
</Row>
|
alignY="top"
|
||||||
</Author>
|
options={
|
||||||
|
<Col
|
||||||
|
p="2"
|
||||||
|
border="1"
|
||||||
|
borderRadius="1"
|
||||||
|
borderColor="lightGray"
|
||||||
|
backgroundColor="white"
|
||||||
|
gapY="2"
|
||||||
|
>
|
||||||
|
<Action bg="white" onClick={doCopy}>
|
||||||
|
{copyDisplay}
|
||||||
|
</Action>
|
||||||
|
{(window.ship == post?.author && !disabled) ? (
|
||||||
|
<ActionLink
|
||||||
|
color="blue"
|
||||||
|
to={{
|
||||||
|
pathname: props.baseUrl,
|
||||||
|
search: `?edit=${commentIndex}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</ActionLink>
|
||||||
|
) : null}
|
||||||
|
{(window.ship == post?.author || ourRole == 'admin') &&
|
||||||
|
!disabled ? (
|
||||||
|
<Action
|
||||||
|
height="unset"
|
||||||
|
bg="white"
|
||||||
|
onClick={onDelete}
|
||||||
|
destructive
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Action>
|
||||||
|
) : null}
|
||||||
|
</Col>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon icon="Ellipsis" />
|
||||||
|
</Dropdown>
|
||||||
|
</Box>
|
||||||
</Row>
|
</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;
|
||||||
@ -155,7 +154,15 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
|
|||||||
pending={idx.toString() === editCommentId}
|
pending={idx.toString() === editCommentId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{( editCommentId ? (
|
||||||
|
<CommentInput
|
||||||
|
onSubmit={onEdit}
|
||||||
|
label='Edit Comment'
|
||||||
|
initial={commentContent}
|
||||||
|
/>
|
||||||
|
) : null )}
|
||||||
|
{( !editCommentId && canComment ? <CommentInput placeholder="Comment" onSubmit={onSubmit} /> : null )}
|
||||||
</Col>
|
</Col>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
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;
|
||||||
|
`;
|
||||||
|
|
@ -51,7 +51,7 @@ interface VirtualScrollerProps<T> {
|
|||||||
onEndReached?(): void;
|
onEndReached?(): void;
|
||||||
size: number;
|
size: number;
|
||||||
pendingSize: number;
|
pendingSize: number;
|
||||||
totalSize: number;
|
totalSize?: number;
|
||||||
/*
|
/*
|
||||||
* Average height of a single rendered item
|
* Average height of a single rendered item
|
||||||
*
|
*
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user