mirror of
https://github.com/urbit/shrub.git
synced 2024-12-25 04:52:06 +03:00
Merge pull request #5063 from urbit/lf/omnibus
interface: collections fixes
This commit is contained in:
commit
b0f8a6e083
@ -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);
|
||||
}
|
||||
|
38
pkg/interface/src/logic/lib/suspend.ts
Normal file
38
pkg/interface/src/logic/lib/suspend.ts
Normal 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
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
44
pkg/interface/src/logic/state/embed.tsx
Normal file
44
pkg/interface/src/logic/state/embed.tsx
Normal 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;
|
@ -88,7 +88,7 @@ export function LinkResource(props: LinkResourceProps) {
|
||||
mb={3}
|
||||
/>
|
||||
) : (
|
||||
<LinkBlocks graph={graph} association={resource} />
|
||||
<LinkBlocks key={rid} graph={graph} association={resource} />
|
||||
)}
|
||||
</Col>
|
||||
);
|
||||
|
@ -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 = () => {
|
||||
|
@ -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'}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
33
pkg/interface/src/views/components/AsyncFallback.tsx
Normal file
33
pkg/interface/src/views/components/AsyncFallback.tsx
Normal 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;
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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' }}>
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user