Merge pull request #5063 from urbit/lf/omnibus

interface: collections fixes
This commit is contained in:
matildepark 2021-07-01 13:42:49 -04:00 committed by GitHub
commit b0f8a6e083
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 324 additions and 108 deletions

View File

@ -8,6 +8,7 @@ import useInviteState from '../state/invite';
import useLaunchState from '../state/launch';
import useSettingsState from '../state/settings';
import useLocalState from '../state/local';
import useStorageState from '../state/storage';
export async function bootstrapApi() {
airlock.onError = (e) => {
@ -38,7 +39,8 @@ export async function bootstrapApi() {
useSettingsState,
useLaunchState,
useInviteState,
useGraphState
useGraphState,
useStorageState
].map(state => state.getState().initialize(airlock));
await Promise.all(promises);
}

View File

@ -0,0 +1,38 @@
export type SuspendState = 'result' | 'error' | 'pending';
export interface Suspender<T> {
read: () => T;
}
export function suspend<T>(awaiting: Promise<T>): Suspender<T> {
let state: SuspendState = 'pending';
let result: T | null = null;
const promise = awaiting
.then((res) => {
state = 'result';
result = res;
})
.catch((e) => {
state = 'error';
result = e;
});
return {
read: () => {
if (state === 'result') {
return result!;
} else if (state === 'error') {
throw result;
} else {
throw promise;
}
}
};
}
export function suspendWithResult<T>(result: T): Suspender<T> {
return {
read: () => result
};
}

View File

@ -540,6 +540,15 @@ export function binaryIndexOf(arr: BigInteger[], target: BigInteger): number | u
return undefined;
}
export async function jsonFetch<T>(info: RequestInfo, init?: RequestInit): Promise<T> {
const res = await fetch(info, init);
if(!res.ok) {
throw new Error('Bad Fetch Response');
}
const data = await res.json();
return data as T;
}
export function clone<T>(a: T) {
return JSON.parse(JSON.stringify(a)) as T;
}

View File

@ -0,0 +1,44 @@
import { useCallback } from 'react';
import create from 'zustand';
import { suspend, Suspender , suspendWithResult } from '../lib/suspend';
import { jsonFetch } from '~/logic/lib/util';
export interface EmbedState {
embeds: {
[url: string]: any;
};
getEmbed: (url: string) => Suspender<any>;
fetch: (url: string) => Promise<any>;
}
const OEMBED_PROVIDER = 'http://noembed.com/embed';
const useEmbedState = create<EmbedState>((set, get) => ({
embeds: {},
fetch: async (url: string) => {
const { embeds } = get();
if(url in embeds) {
return embeds[url];
}
const search = new URLSearchParams({
url
});
const embed = await jsonFetch(`${OEMBED_PROVIDER}?${search.toString()}`);
const { embeds: es } = get();
set({ embeds: { ...es, [url]: embed } });
return embed;
},
getEmbed: (url: string): Suspender<any> => {
const { fetch, embeds } = get();
if(url in embeds) {
return suspendWithResult(embeds[url]);
}
return suspend(fetch(url));
}
}));
export function useEmbed(url: string) {
return useEmbedState(useCallback(s => s.getEmbed(url), [url]));
}
export default useEmbedState;

View File

@ -88,7 +88,7 @@ export function LinkResource(props: LinkResourceProps) {
mb={3}
/>
) : (
<LinkBlocks graph={graph} association={resource} />
<LinkBlocks key={rid} graph={graph} association={resource} />
)}
</Col>
);

View File

@ -38,7 +38,7 @@ export function LinkBlockInput(props: LinkBlockInputProps) {
const handleChange = useCallback((val: string) => {
setUrl(val);
setValid(URLparser.test(val));
setValid(URLparser.test(val) || Boolean(parsePermalink(val)));
}, []);
const doPost = () => {

View File

@ -28,6 +28,7 @@ import {
} from '~/views/components/RemoteContent/embed';
import { PermalinkEmbed } from '../../permalinks/embed';
import { referenceToPermalink } from '~/logic/lib/permalinks';
import AsyncFallback from '~/views/components/AsyncFallback';
export interface LinkBlockItemProps {
node: GraphNode;
@ -76,7 +77,8 @@ export function LinkBlockItem(props: LinkBlockItemProps & CenterProps) {
{...rest}
{...bind}
>
{isReference ? (
<AsyncFallback fallback={<RemoteContentEmbedFallback url={url} />}>
{isReference ? (
summary ? (
<RemoteContentPermalinkEmbed
reference={content[0] as ReferenceContent}
@ -96,6 +98,7 @@ export function LinkBlockItem(props: LinkBlockItemProps & CenterProps) {
) : (
<RemoteContentEmbedFallback url={url} />
)}
</AsyncFallback>
<Box
backgroundColor="white"
display={summary && hovering ? 'block' : 'none'}

View File

@ -20,18 +20,20 @@ export function LinkDetail(props: LinkDetailProps) {
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} />
<Row height="100%" width="100%" flexDirection={['column', 'column', 'row']} {...rest}>
<LinkBlockItem minWidth="0" minHeight="0" maxHeight={['50%', '50%', '100%']} maxWidth={['100%', '100%', 'calc(100% - 350px)']} flexGrow={1} border={0} node={node} />
<Col
minHeight="0"
flexShrink={0}
width={['100%', '100%', '350px']}
flexGrow={0}
gapY="4"
borderLeft="1"
borderColor="lightGray"
pt="4"
gapY={[2,4]}
borderLeft={[0, 0, 1]}
borderTop={[1, 1, 0]}
borderColor={['lightGray', 'lightGray', 'lightGray']}
pt={[2,4]}
>
<Col px="4" gapY="2">
<Col minHeight="0" px={[3,4]} gapY="2">
{title.length > 0 ? (
<TruncatedText fontWeight="medium" lineHeight="tall">
{title}
@ -46,11 +48,11 @@ export function LinkDetail(props: LinkDetailProps) {
/>
</Col>
<Col
height="100%"
minHeight="0"
overflowY="auto"
borderTop="1"
borderTopColor="lightGray"
p="4"
p={[3,4]}
>
<Comments
association={association}

View File

@ -1,9 +1,7 @@
import {
Box,
Center, Col, Icon,
ToggleSwitch, Text,
Text,
StatelessToggleSwitchField
} from '@tlon/indigo-react';
import { Association, GraphConfig, resourceFromPath } from '@urbit/api';
@ -37,17 +35,19 @@ function GroupWithChannels(props: { association: Association }) {
s.notificationsGroupConfig.includes(association.group)
);
const [{ value }, meta, { setValue }] = useField(
const [, , { setValue }] = useField(
`groups["${association.group}"]`
);
const [optValue, setOptValue] = useState(groupWatched);
const onChange = () => {
setValue(!value);
setOptValue(v => !v);
};
useEffect(() => {
setValue(groupWatched);
}, []);
setValue(optValue);
}, [optValue]);
const graphs = useGraphsForGroup(association.group);
const joinedGraphs = useGraphState(s => s.graphKeys);
@ -84,7 +84,7 @@ function GroupWithChannels(props: { association: Association }) {
<Text>{metadata.title}</Text>
</Box>
<Box gridArea="groupToggle">
<StatelessToggleSwitchField selected={value} onChange={onChange} />
<StatelessToggleSwitchField selected={optValue} onChange={onChange} />
</Box>
{open &&
_.map(joinedGroupGraphs, (a: Association, graph: string) => (
@ -101,19 +101,19 @@ function Channel(props: { association: Association }) {
return isWatching(config, association.resource);
});
const [{ value }, meta, { setValue, setTouched }] = useField(
const [, , { setValue, setTouched }] = useField(
`graph["${association.resource}"]`
);
const [optValue, setOptValue] = useState(watching);
useEffect(() => {
setValue(watching);
setValue(optValue);
setTouched(true);
}, [watching]);
const onClick = () => {
setValue(!value);
setTouched(true);
}
setOptValue(v => !v);
};
const icon = getModuleIcon((metadata.config as GraphConfig)?.graph as GraphModule);
@ -126,7 +126,7 @@ function Channel(props: { association: Association }) {
<Text> {metadata.title}</Text>
</Box>
<Box gridColumn={4}>
<StatelessToggleSwitchField selected={value} onClick={onClick} />
<StatelessToggleSwitchField selected={optValue} onClick={onClick} />
</Box>
</>
);

View File

@ -0,0 +1,33 @@
import React from 'react';
interface AsyncFallbackProps {
fallback?: JSX.Element;
}
class AsyncFallback extends React.Component<
AsyncFallbackProps,
{
error: boolean;
}
> {
constructor(props: AsyncFallbackProps) {
super(props);
this.state = { error: false };
}
static getDerivedStateFromError(error) {
return { error: true };
}
componentDidCatch(error, info) {}
render() {
const { fallback, children } = this.props;
return (
<React.Suspense fallback={fallback}>
{this.state.error ? fallback : children}
</React.Suspense>
);
}
}
export default AsyncFallback;

View File

@ -1,8 +1,8 @@
import React, {
MouseEvent,
useState,
useEffect,
useCallback
useCallback,
useMemo,
useState
} from 'react';
import styled from 'styled-components';
import UnstyledEmbedContainer from 'react-oembed-container';
@ -26,6 +26,8 @@ import { Link } from 'react-router-dom';
import { referenceToPermalink } from '~/logic/lib/permalinks';
import useMetadataState from '~/logic/state/metadata';
import { RemoteContentWrapper } from './wrapper';
import { useEmbed } from '~/logic/state/embed';
import { IS_SAFARI } from '~/logic/lib/platform';
interface RemoteContentEmbedProps {
url: string;
@ -99,7 +101,12 @@ const BaseAudio = styled.audio<React.PropsWithChildren<BaseAudioProps>>(
);
export function RemoteContentAudioEmbed(props: RemoteContentEmbedProps) {
const { url, noCors, ...rest } = props;
const { url, ...rest } = props;
const [noCors, setNoCors] = useState(false);
// maybe audio isn't set up for CORS embeds
const onError = useCallback(() => {
setNoCors(true);
}, []);
return (
<BaseAudio
@ -110,6 +117,7 @@ export function RemoteContentAudioEmbed(props: RemoteContentEmbedProps) {
height="24px"
width="100%"
minWidth={['90vw', '384px']}
onError={onError}
{...(noCors ? {} : { crossOrigin: 'anonymous' })}
{...rest}
/>
@ -128,7 +136,7 @@ const BaseVideo = styled.video<React.PropsWithChildren<BaseVideoProps>>(
export function RemoteContentVideoEmbed(
props: RemoteContentEmbedProps & PropFunc<typeof BaseVideo>
) {
const { url, noCors, ...rest } = props;
const { url, ...rest } = props;
return (
<BaseVideo
@ -138,7 +146,6 @@ export function RemoteContentVideoEmbed(
objectFit="contain"
height="100%"
width="100%"
{...(noCors ? {} : { crossOrigin: 'anonymous' })}
{...rest}
/>
);
@ -146,25 +153,55 @@ export function RemoteContentVideoEmbed(
const EmbedContainer = styled(UnstyledEmbedContainer)`
width: 100%;
grid-template-rows: 1fr max-content 1fr;
grid-template-rows: 1fr -webkit-max-content 1fr;
grid-template-columns: 1fr max-content 1fr;
grid-template-columns: 1fr -webkit-max-content 1fr;
height: 100%;
display: ${IS_SAFARI ? 'flex' : 'grid'};
flex-direction: column;
flex-grow: 1;
${IS_SAFARI ? `
align-items: center;
justify-content: center;
` : ''}
overflow: auto;
`;
const EmbedBox = styled.div<{ aspect?: number }>`
${p => p.aspect ? `
const EmbedBox = styled.div<{ aspect?: number; iHeight?: number; iWidth?: number; }>`
display: flex;
grid-row: 2 / 3;
${p => (p.aspect && !IS_SAFARI) ? `
height: 0;
overflow: hidden;
padding-bottom: calc(100% / ${p.aspect});
padding-bottom: min(100vh - 130px, calc(100% / ${p.aspect}));
@media screen and (max-width: ${p => p.theme.breakpoints[1]}) {
padding-bottom: min(25vh, calc(100% / ${p.aspect}));
}
position: relative;
` : `
height: auto;
width: 100%;
grid-column: 1 / 4;
` : IS_SAFARI ? `
width: max-content;
height: max-content;
max-height: 100%;
flex-grow: 1;
` : `
grid-column: 2 / 3;
`}
& iframe {
${p => (p.iHeight && p.iWidth) ? `
height: ${p.iHeight}px !important;
width: ${p.iWidth}px !important;
` : `
height: 100%;
width: 100%;
${p => p.aspect && 'position: absolute;'}
`
}
${p => p.aspect && !IS_SAFARI && 'position: absolute;'}
}
`;
@ -242,48 +279,36 @@ type RemoteContentOembedProps = {
} & RemoteContentEmbedProps &
PropFunc<typeof Box>;
/**
* Some providers do not report sizes correctly, so we report an aspect ratio
* instead of a height/width
*/
const BAD_SIZERS = [/youtu(\.?)be/];
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>();
const { url, renderUrl = false, thumbnail = false, ...rest } = props;
const oembed = useEmbed(url);
const embed = oembed.read();
const fallbackError = new Error('fallback');
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 [aspect, width, height] = useMemo(() => {
if(!('height' in embed && typeof embed.height === 'number'
&& 'width' in embed && typeof embed.width === 'number')) {
return [undefined, undefined, undefined];
}
const { height, width } = embed;
if(BAD_SIZERS.some(r => r.test(url))) {
return [width/height, undefined, undefined];
}
return [undefined, width, height];
}, [embed, url]);
const detail = (
<Col
width="100%"
flexShrink={0}
height="100%"
justifyContent="center"
alignItems="center"
@ -300,12 +325,17 @@ export const RemoteContentOembed = React.forwardRef<
<EmbedBox
ref={ref}
aspect={aspect}
iHeight={height}
iWidth={width}
dangerouslySetInnerHTML={{ __html: embed.html }}
></EmbedBox>
</EmbedContainer>
) : renderUrl ? (
<RemoteContentEmbedFallback url={url} />
) : null}
) : (() => {
throw fallbackError;
})()
}
</Col>
);
if (!renderUrl) {
@ -325,7 +355,6 @@ export function RemoteContentEmbedFallback(props: RemoteContentEmbedProps) {
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"
@ -336,9 +365,10 @@ export function RemoteContentEmbedFallback(props: RemoteContentEmbedProps) {
textOverflow="ellipsis"
color="gray"
>
<TruncatedText maxWidth="100%" gray>
{url}
</BaseAnchor>
</TruncatedText>
</TruncatedText>
</BaseAnchor>
</Row>
);
}

View File

@ -9,6 +9,7 @@ import {
} from './embed';
import { TruncatedText } from '~/views/components/TruncatedText';
import { RemoteContentWrapper } from './wrapper';
import AsyncFallback from '../AsyncFallback';
export interface RemoteContentProps {
/**
@ -63,6 +64,12 @@ export function RemoteContent(props: RemoteContentProps) {
embedOnly: !renderUrl || tall
};
const fallback = !renderUrl ? null : (
<RemoteContentWrapper {...wrapperProps}>
<TruncatedText>{url}</TruncatedText>
</RemoteContentWrapper>
);
if (isImage && remoteContentPolicy.imageShown) {
return (
<RemoteContentWrapper {...wrapperProps} noOp={transcluded} replaced>
@ -86,17 +93,12 @@ export function RemoteContent(props: RemoteContentProps) {
);
} else if (isOembed && remoteContentPolicy.oembedShown) {
return (
<RemoteContentOembed ref={embedRef} url={url} renderUrl={renderUrl} />
<AsyncFallback fallback={fallback}>
<RemoteContentOembed ref={embedRef} url={url} renderUrl={renderUrl} />
</AsyncFallback>
);
} else if (renderUrl) {
return (
<RemoteContentWrapper {...wrapperProps}>
<TruncatedText>{url}</TruncatedText>
</RemoteContentWrapper>
);
} else {
return null;
}
return fallback;
}
export default React.memo(RemoteContent);

View File

@ -1,5 +1,6 @@
import React, { MouseEvent, useCallback, useState } from 'react';
import { Row, Box, Icon, BaseAnchor } from '@tlon/indigo-react';
import AsyncFallback from '../AsyncFallback';
interface RemoteContentWrapperProps {
children: JSX.Element;
@ -74,7 +75,7 @@ export function RemoteContentWrapper(props: RemoteContentWrapperProps) {
{children}
</BaseAnchor>
</Row>
{unfold ? detail : null}
{unfold ? <AsyncFallback fallback={null} >{detail}</AsyncFallback> : null}
</Box>
);
}

View File

@ -15,6 +15,19 @@ const ScrollbarLessBox = styled(Box)`
}
`;
const Scrollbar = styled(Box)`
&:hover {
width: 8px;
}
z-index: 3;
width: 4px;
border-radius: 999px;
right: 0;
height: 50px;
position: absolute;
pointer: cursor;
`;
interface RendererProps<K> {
index: K;
scrollWindow: any;
@ -162,6 +175,8 @@ export default class VirtualScroller<K,V> extends Component<VirtualScrollerProps
private cleanupRefInterval: NodeJS.Timeout | null = null;
private scrollDragging = false;
constructor(props: VirtualScrollerProps<K,V>) {
super(props);
this.state = {
@ -200,6 +215,39 @@ export default class VirtualScroller<K,V> extends Component<VirtualScrollerProps
this.orphans.clear();
};
onDown = (e: PointerEvent) => {
this.scrollRef.setPointerCapture(e.pointerId);
this.scrollDragging = true;
}
onUp = (e: PointerEvent) => {
this.scrollRef.releasePointerCapture(e.pointerId);
this.scrollDragging = false;
}
onMove = (e: MouseEvent) => {
if(!this.scrollDragging) {
return;
}
const scrollProgress = e.movementY / this.window.offsetHeight;
const scrollDir = this.props.origin === 'top' ? 1 : -1;
const windowScroll = scrollDir * scrollProgress * this.window.scrollHeight;
this.window.scrollBy(0, windowScroll);
}
setScrollRef = (el: HTMLDivElement | null) => {
if(!el) {
this.scrollRef.removeEventListener('pointerdown', this.onDown);
this.scrollRef.removeEventListener('pointermove', this.onMove);
this.scrollRef.removeEventListener('pointerup', this.onUp);
this.scrollRef = null;
return;
}
this.scrollRef = el;
this.scrollRef.addEventListener('pointerdown', this.onDown);
this.scrollRef.addEventListener('pointermove', this.onMove);
this.scrollRef.addEventListener('pointerup', this.onUp);
}
// manipulate scrollbar manually, to dodge change detection
updateScroll = IS_IOS ? () => {} : _.throttle(() => {
if(!this.window || !this.scrollRef) {
@ -207,12 +255,15 @@ export default class VirtualScroller<K,V> extends Component<VirtualScrollerProps
}
const { scrollTop, scrollHeight } = this.window;
const unloaded = (this.startOffset() / this.pageSize);
const totalpages = this.props.size / this.pageSize;
// const unloaded = (this.startOffset() / this.pageSize);
// const totalpages = this.props.size / this.pageSize;
const loaded = (scrollTop / scrollHeight);
const result = ((unloaded + loaded) / totalpages) *this.window.offsetHeight;
this.scrollRef.style[this.props.origin] = `${result}px`;
// unused, maybe useful
/* const result = this.scrollDragging
? (loaded * this.window.offsetHeight)
: ((unloaded + loaded) / totalpages) *this.window.offsetHeight;*/
this.scrollRef.style[this.props.origin] = `${loaded * this.window.offsetHeight}px`;
}, 50);
componentDidUpdate(prevProps: VirtualScrollerProps<K,V>, _prevState: VirtualScrollerState<K>) {
@ -568,13 +619,10 @@ export default class VirtualScroller<K,V> extends Component<VirtualScrollerProps
return (
<>
{!IS_IOS && (<Box borderRadius={3} top ={isTop ? '0' : undefined}
bottom={!isTop ? '0' : undefined} ref={(el) => {
this.scrollRef = el;
}}
right={0} height="50px"
position="absolute" width="4px"
backgroundColor="lightGray"
{!IS_IOS && (<Scrollbar
top ={isTop ? '0' : undefined}
bottom={!isTop ? '0' : undefined} ref={this.setScrollRef}
backgroundColor="lightGray"
/>)}
<ScrollbarLessBox overflowY='scroll' ref={this.setWindow} onScroll={this.onScroll} style={{ ...style, ...{ transform }, 'WebkitOverflowScrolling': 'auto' }}>

View File

@ -20,22 +20,26 @@ export const Skeleton = React.memo((props: SkeletonProps): ReactElement => {
setSidebar(s => !s);
}, []));
return !sidebar ? (<Body> {props.children} </Body>) : (
return (
<Body
display="grid"
gridTemplateColumns={
['100%', 'minmax(150px, 1fr) 3fr', 'minmax(250px, 1fr) 4fr']
sidebar
? ['100%', 'minmax(150px, 1fr) 3fr', 'minmax(250px, 1fr) 4fr']
: '100%'
}
gridTemplateRows="100%"
>
<ErrorBoundary>
<Sidebar
recentGroups={props.recentGroups}
selected={props.selected}
baseUrl={props.baseUrl}
mobileHide={props.mobileHide}
workspace={props.workspace}
/>
{ sidebar && (
<Sidebar
recentGroups={props.recentGroups}
selected={props.selected}
baseUrl={props.baseUrl}
mobileHide={props.mobileHide}
workspace={props.workspace}
/>
)}
</ErrorBoundary>
{props.children}
</Body>