mirror of
https://github.com/urbit/shrub.git
synced 2025-01-06 21:18:42 +03:00
Merge pull request #3432 from tylershuster/link-drag
links, chat: add drag and drop
This commit is contained in:
commit
e07509fa11
@ -15,6 +15,8 @@ import GlobalApi from "~/logic/api/global";
|
|||||||
import { Association } from "~/types/metadata-update";
|
import { Association } from "~/types/metadata-update";
|
||||||
import {Group} from "~/types/group-update";
|
import {Group} from "~/types/group-update";
|
||||||
import { LocalUpdateRemoteContentPolicy } from "~/types";
|
import { LocalUpdateRemoteContentPolicy } from "~/types";
|
||||||
|
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload';
|
||||||
|
import { IUnControlledCodeMirror } from "react-codemirror2";
|
||||||
|
|
||||||
|
|
||||||
type ChatScreenProps = RouteComponentProps<{
|
type ChatScreenProps = RouteComponentProps<{
|
||||||
@ -43,9 +45,11 @@ type ChatScreenProps = RouteComponentProps<{
|
|||||||
|
|
||||||
interface ChatScreenState {
|
interface ChatScreenState {
|
||||||
messages: Map<string, string>;
|
messages: Map<string, string>;
|
||||||
|
dragover: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
||||||
|
private chatInput: React.RefObject<ChatInput>;
|
||||||
lastNumPending = 0;
|
lastNumPending = 0;
|
||||||
activityTimeout: NodeJS.Timeout | null = null;
|
activityTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
@ -54,8 +58,11 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
|||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
messages: new Map(),
|
messages: new Map(),
|
||||||
|
dragover: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.chatInput = React.createRef();
|
||||||
|
|
||||||
moment.updateLocale("en", {
|
moment.updateLocale("en", {
|
||||||
calendar: {
|
calendar: {
|
||||||
sameDay: "[Today]",
|
sameDay: "[Today]",
|
||||||
@ -68,6 +75,26 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readyToUpload(): boolean {
|
||||||
|
return Boolean(this.chatInput.current?.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.chatInput.current?.uploadFiles(event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { props, state } = this;
|
const { props, state } = this;
|
||||||
|
|
||||||
@ -104,7 +131,18 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={props.station}
|
key={props.station}
|
||||||
className="h-100 w-100 overflow-hidden flex flex-column relative">
|
className="h-100 w-100 overflow-hidden flex flex-column relative"
|
||||||
|
onDragEnter={this.onDragEnter.bind(this)}
|
||||||
|
onDragOver={event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!this.state.dragover) {
|
||||||
|
this.setState({ dragover: true });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={() => this.setState({ dragover: false })}
|
||||||
|
onDrop={this.onDrop.bind(this)}
|
||||||
|
>
|
||||||
|
{this.state.dragover ? <SubmitDragger /> : null}
|
||||||
<ChatHeader {...props} />
|
<ChatHeader {...props} />
|
||||||
<ChatWindow
|
<ChatWindow
|
||||||
isChatMissing={isChatMissing}
|
isChatMissing={isChatMissing}
|
||||||
@ -116,6 +154,7 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
|||||||
ship={props.match.params.ship}
|
ship={props.match.params.ship}
|
||||||
{...props} />
|
{...props} />
|
||||||
<ChatInput
|
<ChatInput
|
||||||
|
ref={this.chatInput}
|
||||||
api={props.api}
|
api={props.api}
|
||||||
numMsgs={lastMsgNum}
|
numMsgs={lastMsgNum}
|
||||||
station={props.station}
|
station={props.station}
|
||||||
|
@ -101,9 +101,14 @@ export default class ChatEditor extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { props } = this;
|
const {
|
||||||
|
inCodeMode,
|
||||||
|
placeholder,
|
||||||
|
message,
|
||||||
|
...props
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
const codeTheme = props.inCodeMode ? ' code' : '';
|
const codeTheme = inCodeMode ? ' code' : '';
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
mode: MARKDOWN_CONFIG,
|
mode: MARKDOWN_CONFIG,
|
||||||
@ -112,7 +117,7 @@ export default class ChatEditor extends Component {
|
|||||||
lineWrapping: true,
|
lineWrapping: true,
|
||||||
scrollbarStyle: 'native',
|
scrollbarStyle: 'native',
|
||||||
cursorHeight: 0.85,
|
cursorHeight: 0.85,
|
||||||
placeholder: props.inCodeMode ? 'Code...' : props.placeholder,
|
placeholder: inCodeMode ? 'Code...' : placeholder,
|
||||||
extraKeys: {
|
extraKeys: {
|
||||||
'Enter': () => {
|
'Enter': () => {
|
||||||
this.submit();
|
this.submit();
|
||||||
@ -127,11 +132,11 @@ export default class ChatEditor extends Component {
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'chat fr h-100 flex bg-gray0-d lh-copy pl2 w-100 items-center' +
|
'chat fr h-100 flex bg-gray0-d lh-copy pl2 w-100 items-center' +
|
||||||
(props.inCodeMode ? ' code' : '')
|
(inCodeMode ? ' code' : '')
|
||||||
}
|
}
|
||||||
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 72px)' }}>
|
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 72px)' }}>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
value={props.message}
|
value={message}
|
||||||
options={options}
|
options={options}
|
||||||
onChange={(e, d, v) => this.messageChange(e, d, v)}
|
onChange={(e, d, v) => this.messageChange(e, d, v)}
|
||||||
editorDidMount={(editor) => {
|
editorDidMount={(editor) => {
|
||||||
@ -140,6 +145,7 @@ export default class ChatEditor extends Component {
|
|||||||
editor.focus();
|
editor.focus();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,38 +1,57 @@
|
|||||||
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 { S3Upload, SubmitDragger } from '~/views/components/s3-upload'
|
||||||
;
|
;
|
||||||
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 { Envelope } from '~/types/chat-update';
|
||||||
|
import { Contacts, S3Configuration } from '~/types';
|
||||||
|
|
||||||
|
interface ChatInputProps {
|
||||||
|
api: GlobalApi;
|
||||||
|
numMsgs: number;
|
||||||
|
station: any;
|
||||||
|
owner: string;
|
||||||
|
ownerContact: any;
|
||||||
|
envelopes: Envelope[];
|
||||||
|
contacts: Contacts;
|
||||||
|
onUnmount(msg: string): void;
|
||||||
|
s3: any;
|
||||||
|
placeholder: string;
|
||||||
|
message: string;
|
||||||
|
deleteMessage(): void;
|
||||||
|
hideAvatars: boolean;
|
||||||
|
onPaste?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatInputState {
|
||||||
|
inCodeMode: boolean;
|
||||||
|
submitFocus: boolean;
|
||||||
|
uploadingPaste: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class ChatInput extends Component<ChatInputProps, ChatInputState> {
|
||||||
|
public s3Uploader: React.RefObject<S3Upload>;
|
||||||
|
private chatEditor: React.RefObject<ChatEditor>;
|
||||||
|
|
||||||
|
|
||||||
export class ChatInput extends Component {
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
inCodeMode: false,
|
inCodeMode: false,
|
||||||
|
submitFocus: false,
|
||||||
|
uploadingPaste: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.s3Uploader = 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);
|
||||||
}
|
|
||||||
|
|
||||||
uploadSuccess(url) {
|
|
||||||
const { props } = this;
|
|
||||||
props.api.chat.message(
|
|
||||||
props.station,
|
|
||||||
`~${window.ship}`,
|
|
||||||
Date.now(),
|
|
||||||
{ url }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadError(error) {
|
|
||||||
// no-op for now
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleCode() {
|
toggleCode() {
|
||||||
@ -105,6 +124,10 @@ export class ChatInput extends Component {
|
|||||||
|
|
||||||
uploadSuccess(url) {
|
uploadSuccess(url) {
|
||||||
const { props } = this;
|
const { props } = this;
|
||||||
|
if (this.state.uploadingPaste) {
|
||||||
|
this.chatEditor.current.editor.setValue(url);
|
||||||
|
this.setState({ uploadingPaste: false });
|
||||||
|
} else {
|
||||||
props.api.chat.message(
|
props.api.chat.message(
|
||||||
props.station,
|
props.station,
|
||||||
`~${window.ship}`,
|
`~${window.ship}`,
|
||||||
@ -113,10 +136,36 @@ export class ChatInput extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
uploadError(error) {
|
uploadError(error) {
|
||||||
// no-op for now
|
// no-op for now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readyToUpload(): boolean {
|
||||||
|
return Boolean(this.s3Uploader.current?.inputRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPaste(codemirrorInstance, event: ClipboardEvent) {
|
||||||
|
if (!event.clipboardData || !event.clipboardData.files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({ uploadingPaste: true });
|
||||||
|
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() {
|
render() {
|
||||||
const { props, state } = this;
|
const { props, state } = this;
|
||||||
|
|
||||||
@ -143,7 +192,8 @@ export class ChatInput extends Component {
|
|||||||
"pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white " +
|
"pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white " +
|
||||||
"bg-gray0-d relative"
|
"bg-gray0-d relative"
|
||||||
}
|
}
|
||||||
style={{ flexGrow: 1 }}>
|
style={{ flexGrow: 1 }}
|
||||||
|
>
|
||||||
<div className="fl"
|
<div className="fl"
|
||||||
style={{
|
style={{
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
@ -153,11 +203,14 @@ export class ChatInput extends Component {
|
|||||||
{avatar}
|
{avatar}
|
||||||
</div>
|
</div>
|
||||||
<ChatEditor
|
<ChatEditor
|
||||||
|
ref={this.chatEditor}
|
||||||
inCodeMode={state.inCodeMode}
|
inCodeMode={state.inCodeMode}
|
||||||
submit={this.submit}
|
submit={this.submit}
|
||||||
onUnmount={props.onUnmount}
|
onUnmount={props.onUnmount}
|
||||||
message={props.message}
|
message={props.message}
|
||||||
placeholder='Message...' />
|
onPaste={this.onPaste.bind(this)}
|
||||||
|
placeholder='Message...'
|
||||||
|
/>
|
||||||
<div className="ml2 mr2"
|
<div className="ml2 mr2"
|
||||||
style={{
|
style={{
|
||||||
height: '16px',
|
height: '16px',
|
||||||
@ -166,11 +219,12 @@ export class ChatInput extends Component {
|
|||||||
marginTop: 10
|
marginTop: 10
|
||||||
}}>
|
}}>
|
||||||
<S3Upload
|
<S3Upload
|
||||||
|
ref={this.s3Uploader}
|
||||||
configuration={props.s3.configuration}
|
configuration={props.s3.configuration}
|
||||||
credentials={props.s3.credentials}
|
credentials={props.s3.credentials}
|
||||||
uploadSuccess={this.uploadSuccess.bind(this)}
|
uploadSuccess={this.uploadSuccess.bind(this)}
|
||||||
uploadError={this.uploadError.bind(this)}
|
uploadError={this.uploadError.bind(this)}
|
||||||
accept="image/*"
|
accept="*"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
className="invert-d"
|
className="invert-d"
|
||||||
@ -187,7 +241,7 @@ export class ChatInput extends Component {
|
|||||||
marginTop: 10
|
marginTop: 10
|
||||||
}}>
|
}}>
|
||||||
<img style={{
|
<img style={{
|
||||||
filter: state.inCodeMode && 'invert(100%)',
|
filter: state.inCodeMode ? 'invert(100%)' : '',
|
||||||
height: '14px',
|
height: '14px',
|
||||||
width: '14px',
|
width: '14px',
|
||||||
}}
|
}}
|
@ -1,153 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
|
|
||||||
import { S3Upload } from '~/views/components/s3-upload';
|
|
||||||
import { Spinner } from '~/views/components/Spinner';
|
|
||||||
import { Icon } from "@tlon/indigo-react";
|
|
||||||
|
|
||||||
export class LinkSubmit extends Component {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.state = {
|
|
||||||
linkValue: '',
|
|
||||||
linkTitle: '',
|
|
||||||
linkValid: false,
|
|
||||||
submitFocus: false,
|
|
||||||
disabled: false
|
|
||||||
};
|
|
||||||
this.setLinkValue = this.setLinkValue.bind(this);
|
|
||||||
this.setLinkTitle = this.setLinkTitle.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
onClickPost() {
|
|
||||||
const link = this.state.linkValue;
|
|
||||||
const title = this.state.linkTitle
|
|
||||||
? this.state.linkTitle
|
|
||||||
: this.state.linkValue;
|
|
||||||
this.setState({ disabled: true });
|
|
||||||
this.props.api.links.postLink(this.props.resourcePath, link, title).then((r) => {
|
|
||||||
this.setState({
|
|
||||||
disabled: false,
|
|
||||||
linkValue: '',
|
|
||||||
linkTitle: '',
|
|
||||||
linkValid: false
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setLinkValid(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}/
|
|
||||||
);
|
|
||||||
|
|
||||||
const validURL = URLparser.exec(link);
|
|
||||||
|
|
||||||
if (!validURL) {
|
|
||||||
const checkProtocol = URLparser.exec('http://' + link);
|
|
||||||
if (checkProtocol) {
|
|
||||||
this.setState({ linkValid: true });
|
|
||||||
this.setState({ linkValue: 'http://' + link });
|
|
||||||
} else {
|
|
||||||
this.setState({ linkValid: false });
|
|
||||||
}
|
|
||||||
} else if (validURL) {
|
|
||||||
this.setState({ linkValid: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
console.log('s3', this.props.s3);
|
|
||||||
const activeClasses = (this.state.linkValid && !this.state.disabled)
|
|
||||||
? 'green2 pointer' : 'gray2';
|
|
||||||
|
|
||||||
const focus = (this.state.submitFocus)
|
|
||||||
? 'b--black b--white-d'
|
|
||||||
: 'b--gray4 b--gray2-d';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={'relative ba br1 w-100 mb6 ' + focus}>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
className="pl2 bg-gray0-d white-d w-100 f8 pt2"
|
|
||||||
placeholder="Paste link here"
|
|
||||||
onChange={this.setLinkValue}
|
|
||||||
onBlur={() => this.setState({ submitFocus: false })}
|
|
||||||
onFocus={() => this.setState({ submitFocus: true })}
|
|
||||||
spellCheck="false"
|
|
||||||
rows={1}
|
|
||||||
onKeyPress={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.onClickPost();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={this.state.linkValue}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-auto mt2 pt2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="pl2 bg-gray0-d white-d w-100 f8"
|
|
||||||
style={{
|
|
||||||
resize: 'none',
|
|
||||||
height: 40
|
|
||||||
}}
|
|
||||||
placeholder="Enter title"
|
|
||||||
onChange={this.setLinkTitle}
|
|
||||||
onBlur={() => this.setState({ submitFocus: false })}
|
|
||||||
onFocus={() => this.setState({ submitFocus: true })}
|
|
||||||
spellCheck="false"
|
|
||||||
rows={1}
|
|
||||||
onKeyPress={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.onClickPost();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={this.state.linkTitle}
|
|
||||||
/>
|
|
||||||
{!this.state.disabled ? <S3Upload
|
|
||||||
configuration={this.props.s3.configuration}
|
|
||||||
credentials={this.props.s3.credentials}
|
|
||||||
uploadSuccess={this.uploadSuccess.bind(this)}
|
|
||||||
uploadError={this.uploadError.bind(this)}
|
|
||||||
className="nowrap flex items-center pr2 h-100"
|
|
||||||
><span className="green2 f8">Upload File</span></S3Upload> : null}
|
|
||||||
{!this.state.disabled ? <button
|
|
||||||
className={
|
|
||||||
'bg-gray0-d f8 flex-shrink-0 pr2 ' + activeClasses
|
|
||||||
}
|
|
||||||
disabled={!this.state.linkValid || this.state.disabled}
|
|
||||||
onClick={this.onClickPost.bind(this)}
|
|
||||||
style={{
|
|
||||||
bottom: 12,
|
|
||||||
right: 8
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Post
|
|
||||||
</button> : null}
|
|
||||||
<Spinner awaiting={this.state.disabled} classes="nowrap flex items-center pr2" style={{flex: '1 1 14rem'}} text="Posting to collection..." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
) ;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LinkSubmit;
|
|
@ -0,0 +1,255 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import { hasProvider } from 'oembed-parser';
|
||||||
|
|
||||||
|
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload';
|
||||||
|
import { Spinner } from '~/views/components/Spinner';
|
||||||
|
import { Icon } from "@tlon/indigo-react";
|
||||||
|
import GlobalApi from '~/logic/api/global';
|
||||||
|
import { S3State } from '~/types';
|
||||||
|
|
||||||
|
interface LinkSubmitProps {
|
||||||
|
api: GlobalApi;
|
||||||
|
resourcePath: string;
|
||||||
|
s3: S3State;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
this.props.api.links.postLink(this.props.resourcePath, link, title).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.setState({ linkTitle: result.title });
|
||||||
|
}
|
||||||
|
}).catch((error) => {/*noop*/});
|
||||||
|
} else {
|
||||||
|
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 activeClasses = (this.state.linkValid && !this.state.disabled)
|
||||||
|
? 'green2 pointer' : 'gray2';
|
||||||
|
|
||||||
|
const focus = (this.state.submitFocus)
|
||||||
|
? 'b--black b--white-d'
|
||||||
|
: 'b--gray4 b--gray2-d';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative ba br1 w-100 mb6 ${focus}`}
|
||||||
|
onDragEnter={this.onDragEnter.bind(this)}
|
||||||
|
onDragOver={e => {e.preventDefault();this.setState({ dragover: true})}}
|
||||||
|
onDragLeave={() => this.setState({ dragover: false })}
|
||||||
|
onDrop={this.onDrop}
|
||||||
|
>
|
||||||
|
{this.state.dragover ? <SubmitDragger /> : null}
|
||||||
|
<div className="relative">
|
||||||
|
{(this.state.linkValue || this.state.urlFocus || this.state.disabled) ? null : <span className="gray2 absolute pl2 pt3 pb2 f8" style={{pointerEvents: 'none'}}>
|
||||||
|
Drop or <span className="pointer green2" style={{pointerEvents: 'all'}} onClick={(event) => {
|
||||||
|
if (!this.readyToUpload()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.s3Uploader.current.inputRef.current.click();
|
||||||
|
}}>upload</span> a file, or paste a link here
|
||||||
|
</span>}
|
||||||
|
{!this.state.disabled ? <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}
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="pl2 w-100 f8 pt3 pb2 white-d bg-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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="pl2 bg-transparent w-100 f8 white-d"
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
{!this.state.disabled ? <button
|
||||||
|
className={
|
||||||
|
'bg-transparent f8 flex-shrink-0 pr2 pl2 pt2 pb3 ' + activeClasses
|
||||||
|
}
|
||||||
|
disabled={!this.state.linkValid || this.state.disabled}
|
||||||
|
onClick={this.onClickPost.bind(this)}
|
||||||
|
style={{
|
||||||
|
bottom: 12,
|
||||||
|
right: 8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Post link
|
||||||
|
</button> : null}
|
||||||
|
<Spinner awaiting={this.state.disabled} classes="nowrap flex items-center pr2 pl2 pt2 pb4" style={{flex: '1 1 14rem'}} text="Posting to collection..." />
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkSubmit;
|
@ -1,13 +1,10 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
export class Spinner extends Component {
|
const Spinner = ({
|
||||||
render() {
|
classes = '',
|
||||||
const classes = this.props.classes ? this.props.classes : '';
|
text = '',
|
||||||
const text = this.props.text ? this.props.text : '';
|
awaiting = false
|
||||||
const awaiting = this.props.awaiting ? this.props.awaiting : false;
|
}) => awaiting ? (
|
||||||
|
|
||||||
if (awaiting) {
|
|
||||||
return (
|
|
||||||
<div className={classes + ' z-2 bg-white bg-gray0-d white-d flex'}>
|
<div className={classes + ' z-2 bg-white bg-gray0-d white-d flex'}>
|
||||||
<img className="invert-d spin-active v-mid"
|
<img className="invert-d spin-active v-mid"
|
||||||
src="/~landscape/img/Spinner.png"
|
src="/~landscape/img/Spinner.png"
|
||||||
@ -16,9 +13,6 @@ export class Spinner extends Component {
|
|||||||
/>
|
/>
|
||||||
<p className="dib f9 ml2 v-mid inter">{text}</p>
|
<p className="dib f9 ml2 v-mid inter">{text}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
) : null;
|
||||||
} else {
|
|
||||||
return null;
|
export { Spinner as default, Spinner };
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,118 +0,0 @@
|
|||||||
import React, { Component } from 'react'
|
|
||||||
import { Icon } from "@tlon/indigo-react";
|
|
||||||
|
|
||||||
import S3Client from '~/logic/lib/s3';
|
|
||||||
import { Spinner } from './Spinner';
|
|
||||||
|
|
||||||
export class S3Upload extends Component {
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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) {
|
|
||||||
const { props } = this;
|
|
||||||
this.setCredentials(props.credentials, props.configuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCredentials(credentials, configuration) {
|
|
||||||
if (!this.isReady(credentials, configuration)) { return; }
|
|
||||||
this.s3.setCredentials(
|
|
||||||
credentials.endpoint,
|
|
||||||
credentials.accessKeyId,
|
|
||||||
credentials.secretAccessKey
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getFileUrl(endpoint, filename) {
|
|
||||||
return endpoint + '/' + filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange() {
|
|
||||||
const { props } = this;
|
|
||||||
if (!this.inputRef.current) { return; }
|
|
||||||
let files = this.inputRef.current.files;
|
|
||||||
if (files.length <= 0) { return; }
|
|
||||||
|
|
||||||
let file = files.item(0);
|
|
||||||
let bucket = props.configuration.currentBucket;
|
|
||||||
|
|
||||||
this.setState({ isUploading: true });
|
|
||||||
|
|
||||||
this.s3.upload(bucket, file.name, 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 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 <div></div>;
|
|
||||||
} else {
|
|
||||||
let classes = !!className
|
|
||||||
? "pointer " + className
|
|
||||||
: "pointer";
|
|
||||||
const display = children || <Icon icon='ArrowNorth' />;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
className="dn"
|
|
||||||
type="file"
|
|
||||||
id="fileElement"
|
|
||||||
ref={this.inputRef}
|
|
||||||
accept={accept}
|
|
||||||
onChange={this.onChange.bind(this)} />
|
|
||||||
{this.state.isUploading
|
|
||||||
? <Spinner awaiting={true} classes={className} />
|
|
||||||
: <span className={`pointer ${className}`} onClick={this.onClick.bind(this)}>{display}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
142
pkg/interface/src/views/components/s3-upload.tsx
Normal file
142
pkg/interface/src/views/components/s3-upload.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import React, { Component } from 'react'
|
||||||
|
import { Icon } from "@tlon/indigo-react";
|
||||||
|
|
||||||
|
import S3Client from '~/logic/lib/s3';
|
||||||
|
import { Spinner } from './Spinner';
|
||||||
|
import { S3Credentials, S3Configuration } from '~/types';
|
||||||
|
import { dateToDa, deSig } from '~/logic/lib/util';
|
||||||
|
|
||||||
|
export const SubmitDragger = () => (
|
||||||
|
<div
|
||||||
|
className="top-0 bottom-0 left-0 right-0 absolute bg-gray5 h-100 w-100 flex items-center justify-center z-999"
|
||||||
|
style={{pointerEvents: 'none'}}
|
||||||
|
>Drop a file to upload</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="dn"
|
||||||
|
type="file"
|
||||||
|
id="fileElement"
|
||||||
|
ref={this.inputRef}
|
||||||
|
accept={accept}
|
||||||
|
onChange={this.onChange.bind(this)} />
|
||||||
|
{this.state.isUploading
|
||||||
|
? <Spinner awaiting={true} classes={className} />
|
||||||
|
: <span className={`pointer ${className}`} onClick={this.onClick.bind(this)}>{display}</span>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user