Merge pull request #4996 from urbit/lf/nu-collections

Collections: new layout
This commit is contained in:
matildepark 2021-06-23 11:09:52 -05:00 committed by GitHub
commit fe5e0456cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 2068 additions and 638 deletions

View File

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

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@

View File

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

View File

@ -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>
); );
} }

View File

@ -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"

View File

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

View File

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

View File

@ -0,0 +1,118 @@
import { Col, Row, Text } from '@tlon/indigo-react';
import { Association, Graph, GraphNode } from '@urbit/api';
import React, { useCallback, useState, useMemo } from 'react';
import _ from 'lodash';
import { useResize } from '~/logic/lib/useResize';
import { LinkBlockItem } from './LinkBlockItem';
import { LinkBlockInput } from './LinkBlockInput';
import useLocalState from '~/logic/state/local';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import bigInt from 'big-integer';
import 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>
);
}

View File

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

View File

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

View File

@ -1,9 +1,10 @@
import { BaseInput, Box, Button, LoadingSpinner, Text } from '@tlon/indigo-react'; import { BaseInput, Box, Button, LoadingSpinner } from '@tlon/indigo-react';
import { hasProvider } from 'oembed-parser'; import { hasProvider } from 'oembed-parser';
import React, { useCallback, useState, DragEvent, useEffect } from 'react'; import React, { useCallback, useState, DragEvent, useEffect } from 'react';
import { parsePermalink, permalinkToReference } from '~/logic/lib/permalinks'; import { parsePermalink, permalinkToReference } from '~/logic/lib/permalinks';
import { useFileDrag } from '~/logic/lib/useDrag'; import { useFileDrag } from '~/logic/lib/useDrag';
import useStorage from '~/logic/lib/useStorage'; import useStorage from '~/logic/lib/useStorage';
import { StatelessUrlInput } from '~/views/components/StatelessUrlInput';
import SubmitDragger from '~/views/components/SubmitDragger'; import SubmitDragger from '~/views/components/SubmitDragger';
import useGraphState from '~/logic/state/graph'; import useGraphState from '~/logic/state/graph';
import { createPost } from '@urbit/api'; import { createPost } from '@urbit/api';
@ -20,7 +21,6 @@ const LinkSubmit = (props: LinkSubmitProps) => {
const addPost = useGraphState(s => s.addPost); const addPost = useGraphState(s => s.addPost);
const [submitFocused, setSubmitFocused] = useState(false); const [submitFocused, setSubmitFocused] = useState(false);
const [urlFocused, setUrlFocused] = useState(false);
const [linkValue, setLinkValue] = useState(''); const [linkValue, setLinkValue] = useState('');
const [linkTitle, setLinkTitle] = useState(''); const [linkTitle, setLinkTitle] = useState('');
const [disabled, setDisabled] = useState(false); const [disabled, setDisabled] = useState(false);
@ -35,7 +35,7 @@ const LinkSubmit = (props: LinkSubmitProps) => {
setDisabled(true); setDisabled(true);
const parentIndex = props.parentIndex || ''; const parentIndex = props.parentIndex || '';
const post = createPost(`~${window.ship}`, contents, parentIndex); const post = createPost(window.ship, contents, parentIndex);
addPost( addPost(
`~${props.ship}`, `~${props.ship}`,
@ -135,26 +135,6 @@ const LinkSubmit = (props: LinkSubmitProps) => {
} }
}; };
const placeholder = <Text
gray
position="absolute"
px={2}
pt={2}
style={{ pointerEvents: 'none' }}
>{canUpload
? <>
Drop or{' '}
<Text
cursor='pointer'
color='blue'
style={{ pointerEvents: 'all' }}
onClick={() => promptUpload().then(setLinkValue)}
>upload</Text>
{' '}a file, or paste a link here
</>
: 'Paste a link here'
}</Text>;
return ( return (
<> <>
{/* @ts-ignore archaic event type mismatch */} {/* @ts-ignore archaic event type mismatch */}
@ -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}

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
); );
} }

View File

@ -1,16 +1,18 @@
import { Action, Box, Row, Text } from '@tlon/indigo-react'; import { Action, Box, Col, Icon, Row, Text } from '@tlon/indigo-react';
import { Group, removePosts } from '@urbit/api'; import { Group, removePosts } from '@urbit/api';
import { GraphNode } from '@urbit/api/graph'; import { GraphNode } from '@urbit/api/graph';
import bigInt from 'big-integer'; import bigInt from 'big-integer';
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { roleForShip } from '~/logic/lib/group'; import { roleForShip } from '~/logic/lib/group';
import { getPermalinkForGraph } from '~/logic/lib/permalinks'; import { getPermalinkForGraph } from '~/logic/lib/permalinks';
import { getLatestCommentRevision } from '~/logic/lib/publish'; import { getLatestCommentRevision } from '~/logic/lib/publish';
import { useCopy } from '~/logic/lib/useCopy'; import { useCopy } from '~/logic/lib/useCopy';
import { useHovering } from '~/logic/lib/util';
import useMetadataState from '~/logic/state/metadata'; import useMetadataState from '~/logic/state/metadata';
import Author from '~/views/components/Author'; import Author from '~/views/components/Author';
import { ActionLink } from '~/views/components/Link';
import { GraphContent } from '../landscape/components/Graph/GraphContent'; import { GraphContent } from '../landscape/components/Graph/GraphContent';
import { Dropdown } from './Dropdown';
import airlock from '~/logic/api'; import airlock from '~/logic/api';
interface CommentItemProps { interface CommentItemProps {
@ -28,7 +30,10 @@ export function CommentItem(props: CommentItemProps) {
let { highlighted } = props; let { highlighted } = props;
const { ship, name, comment, group } = props; const { ship, name, comment, group } = props;
const association = useMetadataState( const association = useMetadataState(
useCallback(s => s.associations.graph[`/ship/${ship}/${name}`], [ship,name]) useCallback(s => s.associations.graph[`/ship/${ship}/${name}`], [
ship,
name
])
); );
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [, post] = getLatestCommentRevision(comment); const [, post] = getLatestCommentRevision(comment);
@ -63,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}

View File

@ -1,5 +1,14 @@
import { Col } from '@tlon/indigo-react'; import { Col } from '@tlon/indigo-react';
import { createPost, createBlankNodeWithChildPost, Association, GraphNode, Group, markCountAsRead, addPost } from '@urbit/api'; import {
createPost,
createBlankNodeWithChildPost,
Association,
GraphNode,
Group,
markCountAsRead,
addPost,
resourceFromPath
} from '@urbit/api';
import bigInt from 'big-integer'; import bigInt from 'big-integer';
import { FormikHelpers } from 'formik'; import { FormikHelpers } from 'formik';
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
@ -15,12 +24,11 @@ import CommentInput from './CommentInput';
import { CommentItem } from './CommentItem'; import { CommentItem } from './CommentItem';
import airlock from '~/logic/api'; import airlock from '~/logic/api';
import useGraphState from '~/logic/state/graph'; import useGraphState from '~/logic/state/graph';
import { useHistory } from 'react-router';
interface CommentsProps { interface CommentsProps {
comments: GraphNode; comments: GraphNode;
association: Association; association: Association;
name: string;
ship: string;
baseUrl: string; baseUrl: string;
group: Group; group: Group;
} }
@ -29,14 +37,14 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
const { const {
association, association,
comments, comments,
ship,
name,
history,
baseUrl, baseUrl,
group, group,
...rest ...rest
} = props; } = props;
const addNode = useGraphState(s => s.addNode); const addNode = useGraphState(s => s.addNode);
const history = useHistory();
const { ship, name } = resourceFromPath(association.resource);
const { query } = useQuery(); const { query } = useQuery();
const selectedComment = useMemo(() => { const selectedComment = useMemo(() => {
@ -80,7 +88,7 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
const content = tokenizeMessage(comment); const content = tokenizeMessage(comment);
const post = createPost( const post = createPost(
`~${window.ship}`, window.ship,
content, content,
commentNode.post.index, commentNode.post.index,
parseInt((idx + 1).toString(), 10).toString() parseInt((idx + 1).toString(), 10).toString()
@ -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>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
* *

View File

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

View File

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

View File

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

View File

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