Merge pull request #4032 from tylershuster/upload-fixes

s3: upload behavior
This commit is contained in:
matildepark 2020-12-10 13:48:28 -05:00 committed by GitHub
commit 5ffcd57b83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 506 additions and 579 deletions

View File

@ -1,15 +1,39 @@
import { useState, useCallback, useMemo, useEffect } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
function validateDragEvent(e: DragEvent): FileList | null { function validateDragEvent(e: DragEvent): FileList | File[] | true | null {
const files = e.dataTransfer?.files; const files: File[] = [];
console.log(files); let valid = false;
if(!files?.length) { if (e.dataTransfer?.files) {
return null; Array.from(e.dataTransfer.files).forEach(f => files.push(f));
} }
return files || null; if (e.dataTransfer?.items) {
Array.from(e.dataTransfer.items || [])
.filter((i) => i.kind === 'file')
.forEach(f => {
valid = true; // Valid if file exists, but on DragOver, won't reveal its contents for security
const data = f.getAsFile();
if (data) {
files.push(data);
}
});
}
if (files.length) {
return [...new Set(files)];
}
if (navigator.userAgent.includes('Safari')) {
if (e.dataTransfer?.effectAllowed === 'all') {
valid = true;
} else if (e.dataTransfer?.files.length) {
return e.dataTransfer.files;
}
}
if (valid) {
return true;
}
return null;
} }
export function useFileDrag(dragged: (f: FileList) => void) { export function useFileDrag(dragged: (f: FileList | File[], e: DragEvent) => void) {
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const onDragEnter = useCallback( const onDragEnter = useCallback(
@ -25,18 +49,21 @@ export function useFileDrag(dragged: (f: FileList) => void) {
const onDrop = useCallback( const onDrop = useCallback(
(e: DragEvent) => { (e: DragEvent) => {
setDragging(false); setDragging(false);
e.preventDefault();
const files = validateDragEvent(e); const files = validateDragEvent(e);
if (!files) { if (!files || files === true) {
return; return;
} }
dragged(files); e.preventDefault();
dragged(files, e);
}, },
[setDragging, dragged] [setDragging, dragged]
); );
const onDragOver = useCallback( const onDragOver = useCallback(
(e: DragEvent) => { (e: DragEvent) => {
if (!validateDragEvent(e)) {
return;
}
e.preventDefault(); e.preventDefault();
setDragging(true); setDragging(true);
}, },
@ -53,6 +80,18 @@ export function useFileDrag(dragged: (f: FileList) => void) {
[setDragging] [setDragging]
); );
useEffect(() => {
const mouseleave = (e) => {
if (!e.relatedTarget && !e.toElement) {
setDragging(false);
}
};
document.body.addEventListener('mouseout', mouseleave);
return () => {
document.body.removeEventListener('mouseout', mouseleave);
}
}, []);
const bind = { const bind = {
onDragLeave, onDragLeave,
onDragOver, onDragOver,

View File

@ -1,51 +1,96 @@
import { useCallback, useMemo, useEffect, useRef } from "react"; import { useCallback, useMemo, useEffect, useRef, useState } from "react";
import { S3State } from "../../types/s3-update"; import { S3State } from "../../types/s3-update";
import S3 from "aws-sdk/clients/s3"; import S3 from "aws-sdk/clients/s3";
import { dateToDa, deSig } from "./util";
export function useS3(s3: S3State) { export interface IuseS3 {
const { configuration, credentials } = s3; canUpload: boolean;
upload: (file: File, bucket: string) => Promise<string>;
uploadDefault: (file: File) => Promise<string>;
uploading: boolean;
promptUpload: () => Promise<string | undefined>;
}
const useS3 = (s3: S3State, { accept = '*' } = { accept: '*' }): IuseS3 => {
const [uploading, setUploading] = useState(false);
const client = useRef<S3 | null>(null); const client = useRef<S3 | null>(null);
useEffect(() => { useEffect(() => {
if (!credentials) { if (!s3.credentials) {
return; return;
} }
client.current = new S3({ credentials, endpoint: credentials.endpoint }); client.current = new S3({
}, [credentials]); credentials: s3.credentials,
endpoint: s3.credentials.endpoint
});
}, [s3.credentials]);
const canUpload = useMemo( const canUpload = useMemo(
() => () =>
(client && credentials && configuration.currentBucket !== "") || false, (client && s3.credentials && s3.configuration.currentBucket !== "") || false,
[credentials, configuration.currentBucket, client] [s3.credentials, s3.configuration.currentBucket, client]
); );
const uploadDefault = useCallback(async (file: File) => {
if (configuration.currentBucket === "") {
throw new Error("current bucket not set");
}
return upload(file, configuration.currentBucket);
}, []);
const upload = useCallback( const upload = useCallback(
async (file: File, bucket: string) => { async (file: File, bucket: string) => {
if (!client.current) { if (!client.current) {
throw new Error("S3 not ready"); throw new Error("S3 not ready");
} }
const fileParts = file.name.split('.');
const fileName = fileParts.slice(0, -1);
const fileExtension = fileParts.pop();
const timestamp = deSig(dateToDa(new Date()));
const params = { const params = {
Bucket: bucket, Bucket: bucket,
Key: file.name, Key: `${window.ship}/${timestamp}-${fileName}.${fileExtension}`,
Body: file, Body: file,
ACL: "public-read", ACL: "public-read",
ContentType: file.type, ContentType: file.type,
}; };
setUploading(true);
const { Location } = await client.current.upload(params).promise(); const { Location } = await client.current.upload(params).promise();
setUploading(false);
return Location; return Location;
}, },
[client] [client, setUploading]
); );
return { canUpload, upload, uploadDefault }; const uploadDefault = useCallback(async (file: File) => {
} if (s3.configuration.currentBucket === "") {
throw new Error("current bucket not set");
}
return upload(file, s3.configuration.currentBucket);
}, [s3]);
const promptUpload = useCallback(
() => {
return new Promise((resolve, reject) => {
const fileSelector = document.createElement('input');
fileSelector.setAttribute('type', 'file');
fileSelector.setAttribute('accept', accept);
fileSelector.addEventListener('change', () => {
const files = fileSelector.files;
if (!files || files.length <= 0) {
reject();
return;
}
uploadDefault(files[0]).then(resolve);
})
fileSelector.click();
})
},
[uploadDefault]
);
return { canUpload, upload, uploadDefault, uploading, promptUpload };
};
export default useS3;

View File

@ -353,4 +353,4 @@ export function usePreventWindowUnload(shouldPreventDefault: boolean, message =
export function pluralize(text: string, isPlural = false, vowel = false) { export function pluralize(text: string, isPlural = false, vowel = false) {
return isPlural ? `${text}s`: `${vowel ? 'an' : 'a'} ${text}`; return isPlural ? `${text}s`: `${vowel ? 'an' : 'a'} ${text}`;
} }

View File

@ -9,8 +9,9 @@ import { useFileDrag } from '~/logic/lib/useDrag';
import ChatWindow from './components/ChatWindow'; import ChatWindow from './components/ChatWindow';
import ChatInput from './components/ChatInput'; import ChatInput from './components/ChatInput';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { SubmitDragger } from '~/views/components/s3-upload'; import SubmitDragger from '~/views/components/SubmitDragger';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState'; import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import useS3 from '~/logic/lib/useS3';
type ChatResourceProps = StoreState & { type ChatResourceProps = StoreState & {
association: Association; association: Association;
@ -65,20 +66,6 @@ export function ChatResource(props: ChatResourceProps) {
const ourContact = contacts?.[window.ship]; const ourContact = contacts?.[window.ship];
const lastMsgNum = envelopes.length || 0; const lastMsgNum = envelopes.length || 0;
const chatInput = useRef<ChatInput>();
const onFileDrag = useCallback(
(files: FileList) => {
if (!chatInput.current) {
return;
}
chatInput.current?.uploadFiles(files);
},
[chatInput?.current]
);
const { bind, dragging } = useFileDrag(onFileDrag);
const [unsent, setUnsent] = useLocalStorageState<Record<string, string>>( const [unsent, setUnsent] = useLocalStorageState<Record<string, string>>(
'chat-unsent', 'chat-unsent',
{} {}
@ -89,13 +76,29 @@ export function ChatResource(props: ChatResourceProps) {
[station] [station]
); );
const clearUnsent = useCallback(() => setUnsent(s => _.omit(s, station)), [ const clearUnsent = useCallback(
station () => setUnsent(s => _.omit(s, station)),
]); [station]
);
const chatInput = useRef<ChatInput>();
const onFileDrag = useCallback(
(files: FileList | File[]) => {
if (!chatInput.current) {
return;
}
chatInput.current.uploadFiles(files);
},
[chatInput.current]
);
const { bind, dragging } = useFileDrag(onFileDrag);
const scrollTo = new URLSearchParams(location.search).get('msg'); const scrollTo = new URLSearchParams(location.search).get('msg');
useEffect(() => { useEffect(() => {
const clear = () => { const clear = () => {
props.history.replace(location.pathname); props.history.replace(location.pathname);
}; };
setTimeout(clear, 10000); setTimeout(clear, 10000);

View File

@ -1,15 +1,16 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import ChatEditor from './chat-editor'; import ChatEditor from './chat-editor';
import { S3Upload } from '~/views/components/s3-upload' ; import { IuseS3 } from '~/logic/lib/useS3';
import { uxToHex } from '~/logic/lib/util'; import { uxToHex } from '~/logic/lib/util';
import { Sigil } from '~/logic/lib/sigil'; import { Sigil } from '~/logic/lib/sigil';
import tokenizeMessage, { isUrl } from '~/logic/lib/tokenizeMessage'; import tokenizeMessage, { isUrl } from '~/logic/lib/tokenizeMessage';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { Envelope } from '~/types/chat-update'; import { Envelope } from '~/types/chat-update';
import { Contacts } from '~/types'; import { Contacts } from '~/types';
import { Row, BaseImage, Box, Icon } from '@tlon/indigo-react'; import { Row, BaseImage, Box, Icon, LoadingSpinner } from '@tlon/indigo-react';
import withS3 from '~/views/components/withS3';
interface ChatInputProps { type ChatInputProps = IuseS3 & {
api: GlobalApi; api: GlobalApi;
numMsgs: number; numMsgs: number;
station: any; station: any;
@ -22,7 +23,6 @@ interface ChatInputProps {
message: string; message: string;
deleteMessage(): void; deleteMessage(): void;
hideAvatars: boolean; hideAvatars: boolean;
onPaste?(): void;
} }
interface ChatInputState { interface ChatInputState {
@ -31,8 +31,7 @@ interface ChatInputState {
uploadingPaste: boolean; uploadingPaste: boolean;
} }
export default class ChatInput extends Component<ChatInputProps, ChatInputState> { class ChatInput extends Component<ChatInputProps, ChatInputState> {
public s3Uploader: React.RefObject<S3Upload>;
private chatEditor: React.RefObject<ChatEditor>; private chatEditor: React.RefObject<ChatEditor>;
constructor(props) { constructor(props) {
@ -44,11 +43,12 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
uploadingPaste: false uploadingPaste: false
}; };
this.s3Uploader = React.createRef();
this.chatEditor = React.createRef(); this.chatEditor = React.createRef();
this.submit = this.submit.bind(this); this.submit = this.submit.bind(this);
this.toggleCode = this.toggleCode.bind(this); this.toggleCode = this.toggleCode.bind(this);
this.uploadSuccess = this.uploadSuccess.bind(this);
this.uploadError = this.uploadError.bind(this);
} }
toggleCode() { toggleCode() {
@ -136,10 +136,6 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
// no-op for now // no-op for now
} }
readyToUpload(): boolean {
return Boolean(this.s3Uploader.current?.inputRef.current);
}
onPaste(codemirrorInstance, event: ClipboardEvent) { onPaste(codemirrorInstance, event: ClipboardEvent) {
if (!event.clipboardData || !event.clipboardData.files.length) { if (!event.clipboardData || !event.clipboardData.files.length) {
return; return;
@ -150,16 +146,15 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
this.uploadFiles(event.clipboardData.files); this.uploadFiles(event.clipboardData.files);
} }
uploadFiles(files: FileList) { uploadFiles(files: FileList | File[]) {
if (!this.readyToUpload()) { if (!this.props.canUpload) {
return; return;
} }
if (!this.s3Uploader.current || !this.s3Uploader.current.inputRef.current) Array.from(files).forEach(file => {
return; this.props.uploadDefault(file)
this.s3Uploader.current.inputRef.current.files = files; .then(this.uploadSuccess)
const fire = document.createEvent('HTMLEvents'); .catch(this.uploadError);
fire.initEvent('change', true, true); });
this.s3Uploader.current?.inputRef.current?.dispatchEvent(fire);
} }
render() { render() {
@ -189,13 +184,13 @@ return;
<Row <Row
alignItems='center' alignItems='center'
position='relative' position='relative'
flexGrow='1' flexGrow={1}
flexShrink='0' flexShrink={0}
borderTop='1' borderTop={1}
borderTopColor='washedGray' borderTopColor='washedGray'
backgroundColor='white' backgroundColor='white'
className='cf' className='cf'
zIndex='0' zIndex={0}
> >
<Row p='2' alignItems='center'> <Row p='2' alignItems='center'>
{avatar} {avatar}
@ -210,29 +205,26 @@ return;
placeholder='Message...' placeholder='Message...'
/> />
<Box <Box
mx='2' mx={2}
flexShrink='0' flexShrink={0}
height='16px' height='16px'
width='16px' width='16px'
flexBasis='16px' flexBasis='16px'
> >
<S3Upload {this.props.canUpload
ref={this.s3Uploader} ? this.props.uploading
configuration={props.s3.configuration} ? <LoadingSpinner />
credentials={props.s3.credentials} : <Icon icon='Links'
uploadSuccess={this.uploadSuccess.bind(this)} width="16"
uploadError={this.uploadError.bind(this)} height="16"
accept="*" onClick={() => this.props.promptUpload().then(this.uploadSuccess)}
> />
<Icon icon='Links' : null
width="16" }
height="16"
/>
</S3Upload>
</Box> </Box>
<Box <Box
mr='2' mr={2}
flexShrink='0' flexShrink={0}
height='16px' height='16px'
width='16px' width='16px'
flexBasis='16px' flexBasis='16px'
@ -247,3 +239,5 @@ return;
); );
} }
} }
export default withS3(ChatInput, {accept: 'image/*'});

View File

@ -9,7 +9,7 @@ import { uxToHex } from '~/logic/lib/util';
import { RouteComponentProps } from "react-router-dom"; import { RouteComponentProps } from "react-router-dom";
import { LinkItem } from "./components/LinkItem"; import { LinkItem } from "./components/LinkItem";
import { LinkSubmit } from "./components/link-submit"; import LinkSubmit from "./components/LinkSubmit";
import { LinkPreview } from "./components/link-preview"; import { LinkPreview } from "./components/link-preview";
import { Comments } from "~/views/components/comments"; import { Comments } from "~/views/components/comments";

View File

@ -0,0 +1,228 @@
import { BaseInput, Box, Button, LoadingSpinner, Text } from "@tlon/indigo-react";
import React, { useCallback, useState } from "react";
import GlobalApi from "~/logic/api/global";
import { useFileDrag } from "~/logic/lib/useDrag";
import useS3 from "~/logic/lib/useS3";
import { S3State } from "~/types";
import SubmitDragger from "~/views/components/SubmitDragger";
import { createPost } from "~/logic/api/graph";
import { hasProvider } from "oembed-parser";
interface LinkSubmitProps {
api: GlobalApi;
s3: S3State;
name: string;
ship: string;
};
const LinkSubmit = (props: LinkSubmitProps) => {
let { canUpload, uploadDefault, uploading, promptUpload } = useS3(props.s3);
const [submitFocused, setSubmitFocused] = useState(false);
const [urlFocused, setUrlFocused] = useState(false);
const [linkValue, setLinkValueHook] = useState('');
const [linkTitle, setLinkTitle] = useState('');
const [disabled, setDisabled] = useState(false);
const [linkValid, setLinkValid] = useState(false);
const doPost = () => {
const url = linkValue;
const text = linkTitle ? linkTitle : linkValue;
setDisabled(true);
const parentIndex = props.parentIndex || '';
const post = createPost([
{ text },
{ url }
], parentIndex);
props.api.graph.addPost(
`~${props.ship}`,
props.name,
post
).then(() => {
setDisabled(false);
setLinkValue('');
setLinkTitle('');
setLinkValid(false);
});
};
const validateLink = (link) => {
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}/
);
let linkValid = URLparser.test(link);
if (!linkValid) {
linkValid = URLparser.test(`http://${link}`);
if (linkValid) {
link = `http://${link}`;
setLinkValue(link);
}
}
if (linkValid) {
if (hasProvider(linkValue)) {
fetch(`https://noembed.com/embed?url=${linkValue}`)
.then(response => response.json())
.then((result) => {
if (result.title && !linkTitle) {
setLinkTitle(result.title);
}
}).catch((error) => { /* noop*/ });
} else if (!linkTitle) {
setLinkTitle(decodeURIComponent(link
.split('/')
.pop()
.split('.')
.slice(0, -1)
.join('.')
.replace('_', ' ')
.replace(/\d{4}\.\d{1,2}\.\d{2}\.\.\d{2}\.\d{2}\.\d{2}-/, '')
));
}
}
return link;
};
const onFileDrag = useCallback(
(files: FileList | File[], e: DragEvent): void => {
if (!canUpload) {
return;
}
uploadDefault(files[0]).then(setLinkValue);
},
[uploadDefault, canUpload]
);
const { bind, dragging } = useFileDrag(onFileDrag);
const onLinkChange = (linkValue: string) => {
setLinkValueHook(linkValue);
const link = validateLink(linkValue)
setLinkValid(link);
};
const setLinkValue = (linkValue: string) => {
onLinkChange(linkValue);
setLinkValueHook(linkValue);
};
const onPaste = useCallback(
(event: ClipboardEvent) => {
if (!event.clipboardData || !event.clipboardData.files.length) {
return;
}
event.preventDefault();
event.stopPropagation();
uploadDefault(event.clipboardData.files[0]).then(setLinkValue);
}, [setLinkValue, uploadDefault]
);
const onKeyPress = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
doPost();
}
};
const placeholder = <Text
gray
position="absolute"
px={2}
pt={2}
fontSize={0}
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 (
<>
<Box
flexShrink={0}
position='relative'
border='1px solid'
borderColor={submitFocused ? 'black' : 'washedGray'}
width='100%'
borderRadius={2}
{...bind}
>
{uploading && <Box
display="flex"
width="100%"
height="100%"
position="absolute"
left={0}
right={0}
bg="white"
zIndex={9}
alignItems="center"
justifyContent="center"
>
<LoadingSpinner />
</Box>}
{dragging && <SubmitDragger />}
<Box position='relative'>
{!(linkValue || urlFocused || disabled) && placeholder}
<BaseInput
type="url"
pl={2}
width="100%"
fontSize={0}
py={2}
color="black"
backgroundColor="transparent"
onChange={e => onLinkChange(e.target.value)}
onBlur={() => [setUrlFocused(false), setSubmitFocused(false)]}
onFocus={() => [setUrlFocused(true), setSubmitFocused(true)]}
spellCheck="false"
onPaste={onPaste}
onKeyPress={onKeyPress}
value={linkValue}
/>
</Box>
<BaseInput
type="text"
pl={2}
backgroundColor="transparent"
width="100%"
fontSize={0}
color="black"
style={{
resize: 'none',
height: 40
}}
placeholder="Provide a title"
onChange={e => setLinkTitle(e.target.value)}
onBlur={() => setSubmitFocused(false)}
onFocus={() => setSubmitFocused(true)}
spellCheck="false"
onKeyPress={onKeyPress}
value={linkTitle}
/>
</Box>
<Box mt={2} mb={4}>
<Button
primary
flexShrink={0}
disabled={!linkValid || disabled}
onClick={doPost}
>Post link</Button>
</Box>
</>
);
};
export default LinkSubmit;

View File

@ -1,310 +0,0 @@
import React, { Component } from 'react';
import { hasProvider } from 'oembed-parser';
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload';
import { Box, Text, BaseInput, Button } from '@tlon/indigo-react';
import GlobalApi from '~/logic/api/global';
import { S3State } from '~/types';
import { createPost } from '~/logic/api/graph';
interface LinkSubmitProps {
api: GlobalApi;
s3: S3State;
name: string;
ship: string;
}
interface LinkSubmitState {
linkValue: string;
linkTitle: string;
linkValid: boolean;
submitFocus: boolean;
urlFocus: boolean;
disabled: boolean;
dragover: boolean;
}
export class LinkSubmit extends Component<LinkSubmitProps, LinkSubmitState> {
private s3Uploader: React.RefObject<S3Upload>;
constructor(props) {
super(props);
this.state = {
linkValue: '',
linkTitle: '',
linkValid: false,
submitFocus: false,
urlFocus: false,
disabled: false,
dragover: false
};
this.setLinkValue = this.setLinkValue.bind(this);
this.setLinkTitle = this.setLinkTitle.bind(this);
this.onDragEnter = this.onDragEnter.bind(this);
this.onDrop = this.onDrop.bind(this);
this.onPaste = this.onPaste.bind(this);
this.uploadFiles = this.uploadFiles.bind(this);
this.s3Uploader = React.createRef();
}
onClickPost() {
const link = this.state.linkValue;
const title = this.state.linkTitle
? this.state.linkTitle
: this.state.linkValue;
this.setState({ disabled: true });
const parentIndex = this.props.parentIndex || '';
const post = createPost([
{ text: title },
{ url: link }
], parentIndex);
this.props.api.graph.addPost(
`~${this.props.ship}`,
this.props.name,
post
).then((r) => {
this.setState({
disabled: false,
linkValue: '',
linkTitle: '',
linkValid: false
});
});
}
setLinkValid(linkValue) {
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}/
);
let linkValid = URLparser.test(linkValue);
if (!linkValid) {
linkValid = URLparser.test(`http://${linkValue}`);
if (linkValid) {
linkValue = `http://${linkValue}`;
}
}
this.setState({ linkValid, linkValue });
if (linkValid) {
if (hasProvider(linkValue)) {
fetch(`https://noembed.com/embed?url=${linkValue}`)
.then(response => response.json())
.then((result) => {
if (result.title && !this.state.linkTitle) {
this.setState({ linkTitle: result.title });
}
}).catch((error) => { /* noop*/ });
} else if (!this.state.linkTitle) {
this.setState({
linkTitle: decodeURIComponent(linkValue
.split('/')
.pop()
.split('.')
.slice(0, -1)
.join('.')
.replace('_', ' ')
.replace(/\d{4}\.\d{1,2}\.\d{2}\.\.\d{2}\.\d{2}\.\d{2}-/, '')
)
});
}
}
}
setLinkValue(event) {
this.setState({ linkValue: event.target.value });
this.setLinkValid(event.target.value);
}
setLinkTitle(event) {
this.setState({ linkTitle: event.target.value });
}
uploadSuccess(url) {
this.setState({ linkValue: url });
this.setLinkValid(url);
}
uploadError(error) {
// no-op for now
}
readyToUpload(): boolean {
return Boolean(this.s3Uploader.current && this.s3Uploader.current.inputRef.current);
}
onDragEnter() {
if (!this.readyToUpload()) {
return;
}
this.setState({ dragover: true });
}
onDrop(event: DragEvent) {
this.setState({ dragover: false });
if (!event.dataTransfer || !event.dataTransfer.files.length) {
return;
}
event.preventDefault();
this.uploadFiles(event.dataTransfer.files);
}
onPaste(event: ClipboardEvent) {
if (!event.clipboardData || !event.clipboardData.files.length) {
return;
}
event.preventDefault();
event.stopPropagation();
this.uploadFiles(event.clipboardData.files);
}
uploadFiles(files: FileList) {
if (!this.readyToUpload()) {
return;
}
this.s3Uploader.current.inputRef.current.files = files;
const fire = document.createEvent('HTMLEvents');
fire.initEvent('change', true, true);
this.s3Uploader.current?.inputRef.current?.dispatchEvent(fire);
}
render() {
const isS3Ready =
( this.props.s3.credentials.secretAccessKey &&
this.props.s3.credentials.endpoint &&
this.props.s3.credentials.accessKeyId
);
return (
<>
<Box
flexShrink='0'
position='relative'
border='1px solid'
borderColor={this.state.submitFocus ? 'black' : 'washedGray'}
width='100%'
borderRadius='2'
onDragEnter={this.onDragEnter.bind(this)}
onDragOver={(e) => {
e.preventDefault();
if (isS3Ready) {
this.setState({ dragover: true });
}
}}
onDragLeave={() => this.setState({ dragover: false })}
onDrop={this.onDrop}
>
{this.state.dragover ? <SubmitDragger /> : null}
<Box position='relative'>
{
( this.state.linkValue ||
this.state.urlFocus ||
this.state.disabled
) ? null : (
isS3Ready ? (
<Text gray position='absolute'
pl='2' pt='2'
pb='2' fontSize='0'
style={{ pointerEvents: 'none' }}
>
Drop or
<Text cursor='pointer' color='blue'
style={{ pointerEvents: 'all' }}
onClick={(event) => {
if (!this.readyToUpload()) {
return;
}
this.s3Uploader.current.inputRef.current.click();
}}
> upload </Text>
a file, or paste a link here
</Text>
) : (
<Text gray position='absolute'
pl='2' pt='2'
pb='2' fontSize='0'
style={{ pointerEvents: 'none' }}
>
Paste a link here
</Text>
)
)
}
{!this.state.disabled && isS3Ready ? <S3Upload
ref={this.s3Uploader}
configuration={this.props.s3.configuration}
credentials={this.props.s3.credentials}
uploadSuccess={this.uploadSuccess.bind(this)}
uploadError={this.uploadError.bind(this)}
className="dn absolute pt3 pb2 pl2 w-100"
></S3Upload> : null}
<BaseInput
type="url"
pl='2'
width='100%'
fontSize='0'
pt='2'
pb='2'
color='black'
backgroundColor='transparent'
onChange={this.setLinkValue}
onBlur={() => this.setState({ submitFocus: false, urlFocus: false })}
onFocus={() => this.setState({ submitFocus: true, urlFocus: true })}
spellCheck="false"
onPaste={this.onPaste}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.onClickPost();
}
}}
value={this.state.linkValue}
/>
</Box>
<BaseInput
pl='2'
backgroundColor='transparent'
width='100%'
fontSize='0'
color='black'
type="text"
className="linkTitle"
style={{
resize: 'none',
height: 40
}}
placeholder="Provide a title"
onChange={this.setLinkTitle}
onBlur={() => this.setState({ submitFocus: false })}
onFocus={() => this.setState({ submitFocus: true })}
spellCheck="false"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.onClickPost();
}
}}
value={this.state.linkTitle}
/>
</Box>
<Box mt='2' mb='4'>
<Button
flexShrink='0'
primary
disabled={!this.state.linkValid || this.state.disabled}
onClick={this.onClickPost.bind(this)}
>
Post link
</Button>
</Box>
</>
) ;
}
}
export default LinkSubmit;

View File

@ -39,6 +39,7 @@ export function PublishResource(props: PublishResourceProps) {
hideNicknames={props.hideNicknames} hideNicknames={props.hideNicknames}
remoteContentPolicy={props.remoteContentPolicy} remoteContentPolicy={props.remoteContentPolicy}
graphs={props.graphs} graphs={props.graphs}
s3={props.s3}
/> />
</Box> </Box>
); );

View File

@ -4,7 +4,7 @@ import { PostFormSchema, PostForm } from "./NoteForm";
import { FormikHelpers } from "formik"; import { FormikHelpers } from "formik";
import GlobalApi from "~/logic/api/global"; import GlobalApi from "~/logic/api/global";
import { RouteComponentProps, useLocation } from "react-router-dom"; import { RouteComponentProps, useLocation } from "react-router-dom";
import { GraphNode, TextContent, Association } from "~/types"; import { GraphNode, TextContent, Association, S3State } from "~/types";
import { getLatestRevision, editPost } from "~/logic/lib/publish"; import { getLatestRevision, editPost } from "~/logic/lib/publish";
import {useWaitForProps} from "~/logic/lib/useWaitForProps"; import {useWaitForProps} from "~/logic/lib/useWaitForProps";
interface EditPostProps { interface EditPostProps {
@ -13,10 +13,11 @@ interface EditPostProps {
note: GraphNode; note: GraphNode;
api: GlobalApi; api: GlobalApi;
book: string; book: string;
s3: S3State;
} }
export function EditPost(props: EditPostProps & RouteComponentProps) { export function EditPost(props: EditPostProps & RouteComponentProps) {
const { note, book, noteId, api, ship, history } = props; const { note, book, noteId, api, ship, history, s3 } = props;
const [revNum, title, body] = getLatestRevision(note); const [revNum, title, body] = getLatestRevision(note);
const location = useLocation(); const location = useLocation();
@ -53,6 +54,7 @@ export function EditPost(props: EditPostProps & RouteComponentProps) {
cancel cancel
history={history} history={history}
onSubmit={onSubmit} onSubmit={onSubmit}
s3={s3}
submitLabel="Update" submitLabel="Update"
loadingText="Updating..." loadingText="Updating..."
/> />

View File

@ -1,7 +1,8 @@
import React, { useCallback } from "react"; import React, { createRef, useCallback, useRef } from "react";
import { UnControlled as CodeEditor } from "react-codemirror2"; import { IUnControlledCodeMirror, UnControlled as CodeEditor } from "react-codemirror2";
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { Prompt } from 'react-router-dom'; import { Prompt } from 'react-router-dom';
import { Editor } from 'codemirror';
import { MOBILE_BROWSER_REGEX, usePreventWindowUnload } from "~/logic/lib/util"; import { MOBILE_BROWSER_REGEX, usePreventWindowUnload } from "~/logic/lib/util";
import { PropFunc } from "~/types/util"; import { PropFunc } from "~/types/util";
@ -12,6 +13,10 @@ import "codemirror/addon/display/placeholder";
import "codemirror/lib/codemirror.css"; import "codemirror/lib/codemirror.css";
import { Box } from "@tlon/indigo-react"; import { Box } from "@tlon/indigo-react";
import { useFileDrag } from "~/logic/lib/useDrag";
import SubmitDragger from "~/views/components/SubmitDragger";
import useS3 from "~/logic/lib/useS3";
import { S3State } from "~/types";
const MARKDOWN_CONFIG = { const MARKDOWN_CONFIG = {
name: "markdown", name: "markdown",
@ -22,6 +27,7 @@ interface MarkdownEditorProps {
value: string; value: string;
onChange: (s: string) => void; onChange: (s: string) => void;
onBlur?: (e: any) => void; onBlur?: (e: any) => void;
s3: S3State;
} }
const PromptIfDirty = () => { const PromptIfDirty = () => {
@ -50,6 +56,8 @@ export function MarkdownEditor(
placeholder: placeholder || "", placeholder: placeholder || "",
}; };
const editor: React.RefObject<any> = useRef();
const handleChange = useCallback( const handleChange = useCallback(
(_e, _d, v: string) => { (_e, _d, v: string) => {
onChange(v); onChange(v);
@ -64,10 +72,33 @@ export function MarkdownEditor(
[onBlur] [onBlur]
); );
const { uploadDefault, canUpload } = useS3(props.s3);
const onFileDrag = useCallback(
async (files: FileList | File[], e: DragEvent) => {
if (!canUpload || !editor.current) {
return;
}
const codeMirror: Editor = editor.current.editor;
const doc = codeMirror.getDoc();
Array.from(files).forEach(async file => {
const placeholder = `![Uploading ${file.name}](...)`;
doc.setValue(doc.getValue() + placeholder);
const url = await uploadDefault(file);
const markdown = `![${file.name}](${url})`;
doc.setValue(doc.getValue().replace(placeholder, markdown));
});
},
[uploadDefault, canUpload, value, onChange]
);
const { bind, dragging } = useFileDrag(onFileDrag);
return ( return (
<Box <Box
height="100%" height="100%"
position="static" position="relative"
className="publish" className="publish"
p={1} p={1}
border={1} border={1}
@ -78,12 +109,18 @@ export function MarkdownEditor(
> >
<PromptIfDirty /> <PromptIfDirty />
<CodeEditor <CodeEditor
ref={editor}
autoCursor={false} autoCursor={false}
onBlur={onBlur} onBlur={onBlur}
value={value} value={value}
options={options} options={options}
onChange={handleChange} onChange={handleChange}
onDragLeave={(editor, e) => bind.onDragLeave(e)}
onDragOver={(editor, e) => bind.onDragOver(e)}
onDrop={(editor, e) => bind.onDrop(e)}
onDragEnter={(editor, e) => bind.onDragEnter(e)}
/> />
{dragging && <SubmitDragger />}
</Box> </Box>
); );
} }

View File

@ -6,6 +6,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
export const MarkdownField = ({ export const MarkdownField = ({
id, id,
s3,
...rest ...rest
}: { id: string } & Parameters<typeof Box>[0]) => { }: { id: string } & Parameters<typeof Box>[0]) => {
const [{ value, onBlur }, { error, touched }, { setValue }] = useField(id); const [{ value, onBlur }, { error, touched }, { setValue }] = useField(id);
@ -13,7 +14,6 @@ export const MarkdownField = ({
const handleBlur = useCallback( const handleBlur = useCallback(
(e: any) => { (e: any) => {
_.set(e, "target.id", id); _.set(e, "target.id", id);
console.log(e);
onBlur && onBlur(e); onBlur && onBlur(e);
}, },
[onBlur, id] [onBlur, id]
@ -35,6 +35,7 @@ export const MarkdownField = ({
onBlur={handleBlur} onBlur={handleBlur}
value={value} value={value}
onChange={setValue} onChange={setValue}
s3={s3}
/> />
<ErrorLabel mt="2" hasError={!!(error && touched)}> <ErrorLabel mt="2" hasError={!!(error && touched)}>
{error} {error}

View File

@ -5,10 +5,11 @@ import {
Row, Row,
Col, Col,
Button Button
} from '@tlon/indigo-react'; } from "@tlon/indigo-react";
import { AsyncButton } from '../../../components/AsyncButton'; import { AsyncButton } from "../../../components/AsyncButton";
import { Formik, Form, FormikHelpers } from 'formik'; import { Formik, Form, FormikHelpers } from "formik";
import { MarkdownField } from './MarkdownField'; import { MarkdownField } from "./MarkdownField";
import { S3State } from "~/types";
interface PostFormProps { interface PostFormProps {
initial: PostFormSchema; initial: PostFormSchema;
@ -20,6 +21,7 @@ interface PostFormProps {
) => Promise<any>; ) => Promise<any>;
submitLabel: string; submitLabel: string;
loadingText: string; loadingText: string;
s3: S3State;
} }
const formSchema = Yup.object({ const formSchema = Yup.object({
@ -33,7 +35,7 @@ export interface PostFormSchema {
} }
export function PostForm(props: PostFormProps) { export function PostForm(props: PostFormProps) {
const { initial, onSubmit, cancel, submitLabel, loadingText, history } = props; const { initial, onSubmit, submitLabel, loadingText, s3, cancel, history } = props;
return ( return (
<Col width="100%" height="100%" p={[2, 4]}> <Col width="100%" height="100%" p={[2, 4]}>
@ -64,7 +66,7 @@ export function PostForm(props: PostFormProps) {
type="button">Cancel</Button>} type="button">Cancel</Button>}
</Row> </Row>
</Row> </Row>
<MarkdownField flexGrow={1} id="body" /> <MarkdownField flexGrow={1} id="body" s3={s3} />
</Form> </Form>
</Formik> </Formik>
</Col> </Col>

View File

@ -6,7 +6,7 @@ import { RouteComponentProps } from "react-router-dom";
import Note from "./Note"; import Note from "./Note";
import { EditPost } from "./EditPost"; import { EditPost } from "./EditPost";
import { GraphNode, Graph, Contacts, LocalUpdateRemoteContentPolicy, Group } from "~/types"; import { GraphNode, Graph, Contacts, LocalUpdateRemoteContentPolicy, Group, S3State } from "~/types";
interface NoteRoutesProps { interface NoteRoutesProps {
ship: string; ship: string;
@ -21,6 +21,7 @@ interface NoteRoutesProps {
hideAvatars: boolean; hideAvatars: boolean;
baseUrl?: string; baseUrl?: string;
rootUrl?: string; rootUrl?: string;
s3: S3State;
} }
export function NoteRoutes(props: NoteRoutesProps & RouteComponentProps) { export function NoteRoutes(props: NoteRoutesProps & RouteComponentProps) {

View File

@ -8,7 +8,8 @@ import {
Groups, Groups,
Contacts, Contacts,
Rolodex, Rolodex,
LocalUpdateRemoteContentPolicy LocalUpdateRemoteContentPolicy,
S3State
} from "~/types"; } from "~/types";
import { Center, LoadingSpinner } from "@tlon/indigo-react"; import { Center, LoadingSpinner } from "@tlon/indigo-react";
import { Notebook as INotebook } from "~/types/publish-update"; import { Notebook as INotebook } from "~/types/publish-update";
@ -35,6 +36,7 @@ interface NotebookRoutesProps {
association: Association; association: Association;
remoteContentPolicy: LocalUpdateRemoteContentPolicy; remoteContentPolicy: LocalUpdateRemoteContentPolicy;
associations: Associations; associations: Associations;
s3: S3State;
} }
export function NotebookRoutes( export function NotebookRoutes(
@ -79,6 +81,7 @@ export function NotebookRoutes(
association={props.association} association={props.association}
graph={graph} graph={graph}
baseUrl={baseUrl} baseUrl={baseUrl}
s3={props.s3}
/> />
)} )}
/> />
@ -110,6 +113,7 @@ export function NotebookRoutes(
hideAvatars={props.hideAvatars} hideAvatars={props.hideAvatars}
hideNicknames={props.hideNicknames} hideNicknames={props.hideNicknames}
remoteContentPolicy={props.remoteContentPolicy} remoteContentPolicy={props.remoteContentPolicy}
s3={props.s3}
{...routeProps} {...routeProps}
/> />
); );

View File

@ -6,7 +6,7 @@ import { RouteComponentProps } from "react-router-dom";
import { PostForm, PostFormSchema } from "./NoteForm"; import { PostForm, PostFormSchema } from "./NoteForm";
import {createPost} from "~/logic/api/graph"; import {createPost} from "~/logic/api/graph";
import {Graph} from "~/types/graph-update"; import {Graph} from "~/types/graph-update";
import {Association} from "~/types"; import {Association, S3State} from "~/types";
import {newPost} from "~/logic/lib/publish"; import {newPost} from "~/logic/lib/publish";
interface NewPostProps { interface NewPostProps {
@ -16,6 +16,7 @@ interface NewPostProps {
graph: Graph; graph: Graph;
association: Association; association: Association;
baseUrl: string; baseUrl: string;
s3: S3State;
} }
export default function NewPost(props: NewPostProps & RouteComponentProps) { export default function NewPost(props: NewPostProps & RouteComponentProps) {
@ -50,6 +51,7 @@ export default function NewPost(props: NewPostProps & RouteComponentProps) {
onSubmit={onSubmit} onSubmit={onSubmit}
submitLabel="Publish" submitLabel="Publish"
loadingText="Posting..." loadingText="Posting..."
s3={props.s3}
/> />
); );
} }

View File

@ -11,7 +11,7 @@ import {
} from "@tlon/indigo-react"; } from "@tlon/indigo-react";
import { useField } from "formik"; import { useField } from "formik";
import { S3State } from "~/types/s3-update"; import { S3State } from "~/types/s3-update";
import { useS3 } from "~/logic/lib/useS3"; import useS3 from "~/logic/lib/useS3";
type ImageInputProps = Parameters<typeof Box>[0] & { type ImageInputProps = Parameters<typeof Box>[0] & {
id: string; id: string;
@ -23,9 +23,7 @@ type ImageInputProps = Parameters<typeof Box>[0] & {
export function ImageInput(props: ImageInputProps) { export function ImageInput(props: ImageInputProps) {
const { id, label, s3, caption, placeholder, ...rest } = props; const { id, label, s3, caption, placeholder, ...rest } = props;
const { uploadDefault, canUpload } = useS3(s3); const { uploadDefault, canUpload, uploading } = useS3(s3);
const [uploading, setUploading] = useState(false);
const [field, meta, { setValue, setError }] = useField(id); const [field, meta, { setValue, setError }] = useField(id);
@ -38,14 +36,12 @@ export function ImageInput(props: ImageInputProps) {
return; return;
} }
try { try {
setUploading(true);
const url = await uploadDefault(file); const url = await uploadDefault(file);
setUploading(false);
setValue(url); setValue(url);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
} }
}, [ref.current, uploadDefault, canUpload, setUploading, setValue]); }, [ref.current, uploadDefault, canUpload, setValue]);
const onClick = useCallback(() => { const onClick = useCallback(() => {
ref.current?.click(); ref.current?.click();

View File

@ -0,0 +1,27 @@
import { BaseInput, Box, Icon, LoadingSpinner, Text } from "@tlon/indigo-react";
import React, { useCallback } from "react";
import useS3 from "~/logic/lib/useS3";
import { S3State } from "~/types";
const SubmitDragger = () => (
<Box
top='0'
bottom='0'
left='0'
right='0'
position='absolute'
backgroundColor='white'
height='100%'
width='100%'
display='flex'
alignItems='center'
justifyContent='center'
style={{ pointerEvents: 'none', zIndex: 999 }}
>
<Text fontSize='1' color='black'>
Drop a file to upload
</Text>
</Box>
);
export default SubmitDragger;

View File

@ -1,157 +0,0 @@
import React, { Component } from 'react'
import { BaseInput, Box, Text, Icon, LoadingSpinner } from "@tlon/indigo-react";
import S3Client from '~/logic/lib/s3';
import { S3Credentials, S3Configuration } from '~/types';
import { dateToDa, deSig } from '~/logic/lib/util';
export const SubmitDragger = () => (
<Box
top='0'
bottom='0'
left='0'
right='0'
position='absolute'
backgroundColor='white'
height='100%'
width='100%'
display='flex'
alignItems='center'
justifyContent='center'
style={{ pointerEvents: 'none', zIndex: 999 }}
>
<Text fontSize='1' color='black'>
Drop a file to upload
</Text>
</Box>
);
interface S3UploadProps {
credentials: S3Credentials;
configuration: S3Configuration;
uploadSuccess: Function;
uploadError: Function;
className?: string;
accept: string;
}
interface S3UploadState {
isUploading: boolean;
}
export class S3Upload extends Component<S3UploadProps, S3UploadState> {
private s3: S3Client;
public inputRef: React.RefObject<HTMLInputElement>;
constructor(props) {
super(props);
this.state = {
isUploading: false
};
this.s3 = new S3Client();
this.setCredentials(props.credentials, props.configuration);
this.inputRef = React.createRef();
}
isReady(creds, config): boolean {
return (
!!creds &&
'endpoint' in creds &&
'accessKeyId' in creds &&
'secretAccessKey' in creds &&
creds.endpoint !== '' &&
creds.accessKeyId !== '' &&
creds.secretAccessKey !== '' &&
!!config &&
'currentBucket' in config &&
config.currentBucket !== ''
);
}
componentDidUpdate(prevProps): void {
const { props } = this;
if (!props.credentials !== prevProps.credentials || props.configuration !== prevProps.configuration) {
this.setCredentials(props.credentials, props.configuration);
}
}
setCredentials(credentials, configuration): void {
if (!this.isReady(credentials, configuration)) { return; }
this.s3.setCredentials(
credentials.endpoint,
credentials.accessKeyId,
credentials.secretAccessKey
);
}
onChange(): void {
const { props } = this;
if (!this.inputRef.current) { return; }
let files = this.inputRef.current.files;
if (!files || files.length <= 0) { return; }
let file = files.item(0);
if (!file) { return; }
const fileParts = file.name.split('.');
const fileName = fileParts.slice(0, -1);
const fileExtension = fileParts.pop();
const timestamp = deSig(dateToDa(new Date()));
let bucket = props.configuration.currentBucket;
setTimeout(() => {
if (this.state.isUploading) return;
this.setState({ isUploading: true });
this.s3.upload(bucket, `${window.ship}/${timestamp}-${fileName}.${fileExtension}`, file)
.then((data) => {
if (!data || !('Location' in data)) {
return;
}
this.props.uploadSuccess(data.Location);
})
.catch((err) => {
console.error(err);
this.props.uploadError(err);
})
.finally(() => {
this.setState({ isUploading: false });
});
}, 200);
}
onClick() {
if (!this.inputRef.current) { return; }
this.inputRef.current.click();
}
render() {
const {
credentials,
configuration,
className = '',
accept = '*',
children = false
} = this.props;
if (!this.isReady(credentials, configuration)) {
return null;
}
const display = children || <Icon icon='ArrowNorth' />;
return (
<>
<BaseInput
display='none'
type="file"
id="fileElement"
ref={this.inputRef}
accept={accept}
onChange={this.onChange.bind(this)} />
{this.state.isUploading
? <LoadingSpinner background="gray" foreground="black" />
: <Text cursor='pointer' className={className} onClick={this.onClick.bind(this)}>{display}</Text>
}
</>
);
}
}

View File

@ -0,0 +1,12 @@
import React from "react";
import useS3 from "~/logic/lib/useS3";
const withS3 = (Component, params = {}) => {
return React.forwardRef((props: any, ref) => {
const s3 = useS3(props.s3, params);
return <Component ref={ref} {...s3} {...props} />;
});
};
export default withS3;