mirror of
https://github.com/urbit/shrub.git
synced 2024-12-25 13:04:17 +03:00
Merge pull request #5069 from urbit/hm/1002-reply-only-once
interface: fixing chat reply only firing once
This commit is contained in:
commit
7fcfe28920
@ -2,5 +2,11 @@ module.exports = {
|
||||
extends: '@urbit',
|
||||
env: {
|
||||
'jest': true
|
||||
},
|
||||
rules: {
|
||||
// Because we use styled system, and use
|
||||
// the convention of each prop on a new line
|
||||
// we probably shouldn't keep this on
|
||||
'max-lines-per-function': ['off', {}]
|
||||
}
|
||||
};
|
||||
|
77
pkg/interface/src/logic/lib/useFileUpload.ts
Normal file
77
pkg/interface/src/logic/lib/useFileUpload.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import _ from 'lodash';
|
||||
import { useState, ClipboardEvent } from 'react';
|
||||
import { useFileDrag } from './useDrag';
|
||||
import useStorage, { IuseStorage } from './useStorage';
|
||||
|
||||
export type FileUploadSource = 'drag' | 'paste' | 'direct';
|
||||
|
||||
interface FileUploadEventHandlers {
|
||||
onSuccess: (url: string, source: FileUploadSource) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface FileUploadHandler {
|
||||
onFiles: (
|
||||
files: FileList | File[],
|
||||
storage?: IuseStorage,
|
||||
uploadSource?: FileUploadSource
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function isFileUploadHandler(obj: any): obj is FileUploadHandler {
|
||||
return typeof obj.onFiles === 'function';
|
||||
}
|
||||
|
||||
type useFileUploadParams = {
|
||||
multiple?: boolean;
|
||||
} & (FileUploadEventHandlers | FileUploadHandler)
|
||||
|
||||
export function useFileUpload({ multiple = true, ...params }: useFileUploadParams) {
|
||||
const storage = useStorage();
|
||||
const {
|
||||
canUpload, uploadDefault
|
||||
} = storage;
|
||||
const [source, setSource] = useState<FileUploadSource>('paste');
|
||||
const drag = useFileDrag(f => uploadFiles(f, 'drag'));
|
||||
|
||||
function onPaste(event: ClipboardEvent) {
|
||||
if (!event.clipboardData || !event.clipboardData.files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
uploadFiles(event.clipboardData.files, 'paste');
|
||||
}
|
||||
|
||||
function uploadFiles(files: FileList | File[], uploadSource: FileUploadSource) {
|
||||
if (isFileUploadHandler(params)) {
|
||||
return params.onFiles(files, storage, uploadSource);
|
||||
}
|
||||
|
||||
if (!canUpload) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSource(uploadSource);
|
||||
|
||||
const { onSuccess, onError } = params as FileUploadEventHandlers;
|
||||
const fileArray = Array.from(files);
|
||||
const toUpload = multiple ? fileArray : _.take(fileArray);
|
||||
toUpload.forEach((file) => {
|
||||
uploadDefault(file)
|
||||
.then(url => onSuccess(url, source))
|
||||
.catch((err: Error) => {
|
||||
console.log(err);
|
||||
onError && onError(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...storage,
|
||||
onPaste,
|
||||
drag
|
||||
};
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
|
||||
function retrieve<T>(key: string, initial: T): T {
|
||||
export function retrieve<T>(key: string, initial: T): T {
|
||||
const s = localStorage.getItem(key);
|
||||
if (s) {
|
||||
try {
|
||||
|
50
pkg/interface/src/views/apps/chat/components/ChatAvatar.tsx
Normal file
50
pkg/interface/src/views/apps/chat/components/ChatAvatar.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { BaseImage, Box } from '@tlon/indigo-react';
|
||||
import { Contact } from '@urbit/api';
|
||||
import React from 'react';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
|
||||
interface ChatAvatar {
|
||||
hideAvatars: boolean;
|
||||
contact?: Contact;
|
||||
}
|
||||
|
||||
export function ChatAvatar({ contact, hideAvatars }) {
|
||||
const color = contact ? uxToHex(contact.color) : '000000';
|
||||
const sigilClass = contact ? '' : 'mix-blend-diff';
|
||||
|
||||
if (contact && contact?.avatar && !hideAvatars) {
|
||||
return (
|
||||
<BaseImage
|
||||
flexShrink={0}
|
||||
src={contact.avatar}
|
||||
height={24}
|
||||
width={24}
|
||||
style={{ objectFit: 'cover' }}
|
||||
borderRadius={1}
|
||||
display='inline-block'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
width={24}
|
||||
height={24}
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
backgroundColor={`#${color}`}
|
||||
borderRadius={1}
|
||||
>
|
||||
<Sigil
|
||||
ship={window.ship}
|
||||
size={16}
|
||||
color={`#${color}`}
|
||||
classes={sigilClass}
|
||||
icon
|
||||
padding={2}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -4,11 +4,14 @@ import 'codemirror/addon/display/placeholder';
|
||||
import 'codemirror/addon/hint/show-hint';
|
||||
import 'codemirror/lib/codemirror.css';
|
||||
import 'codemirror/mode/markdown/markdown';
|
||||
import React, { Component } from 'react';
|
||||
import React, { useRef, ClipboardEvent, useEffect, useImperativeHandle } from 'react';
|
||||
import { UnControlled as CodeEditor } from 'react-codemirror2';
|
||||
import styled from 'styled-components';
|
||||
import { MOBILE_BROWSER_REGEX } from '~/logic/lib/util';
|
||||
import '../css/custom.css';
|
||||
import { useChatStore } from './ChatPane';
|
||||
|
||||
const isMobile = Boolean(MOBILE_BROWSER_REGEX.test(navigator.userAgent));
|
||||
|
||||
const MARKDOWN_CONFIG = {
|
||||
name: 'markdown',
|
||||
@ -30,6 +33,18 @@ const MARKDOWN_CONFIG = {
|
||||
}
|
||||
};
|
||||
|
||||
const defaultOptions = {
|
||||
mode: MARKDOWN_CONFIG,
|
||||
lineNumbers: false,
|
||||
lineWrapping: true,
|
||||
scrollbarStyle: 'native',
|
||||
cursorHeight: 0.85,
|
||||
// The below will ony work once codemirror's bug is fixed
|
||||
spellcheck: isMobile,
|
||||
autocorrect: isMobile,
|
||||
autocapitalize: isMobile
|
||||
};
|
||||
|
||||
// Until CodeMirror supports options.inputStyle = 'textarea' on mobile,
|
||||
// we need to hack this into a regular input that has some funny behaviors
|
||||
const inputProxy = input => new Proxy(input, {
|
||||
@ -95,20 +110,13 @@ const MobileBox = styled(Box)`
|
||||
`;
|
||||
|
||||
interface ChatEditorProps {
|
||||
message: string;
|
||||
inCodeMode: boolean;
|
||||
submit: (message: string) => void;
|
||||
onUnmount: (message: string) => void;
|
||||
onPaste: () => void;
|
||||
changeEvent: (message: string) => void;
|
||||
placeholder: string;
|
||||
submit: (message: string) => void;
|
||||
onPaste: (codemirrorInstance, event: ClipboardEvent) => void;
|
||||
}
|
||||
|
||||
interface ChatEditorState {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface CodeMirrorShim {
|
||||
export interface CodeMirrorShim {
|
||||
setValue: (string) => void;
|
||||
setOption: (option: string, property: any) => void;
|
||||
focus: () => void;
|
||||
@ -118,131 +126,81 @@ interface CodeMirrorShim {
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
export default class ChatEditor extends Component<ChatEditorProps, ChatEditorState> {
|
||||
editor: CodeMirrorShim;
|
||||
constructor(props: ChatEditorProps) {
|
||||
super(props);
|
||||
const ChatEditor = React.forwardRef<CodeMirrorShim, ChatEditorProps>(({ inCodeMode, placeholder, submit, onPaste }, ref) => {
|
||||
const editorRef = useRef<CodeMirrorShim>(null);
|
||||
useImperativeHandle(ref, () => editorRef.current);
|
||||
const editor = editorRef.current;
|
||||
|
||||
this.state = {
|
||||
message: props.message
|
||||
};
|
||||
const {
|
||||
message,
|
||||
setMessage
|
||||
} = useChatStore();
|
||||
|
||||
this.editor = null;
|
||||
this.onKeyPress = this.onKeyPress.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this.onKeyPress);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.props.onUnmount(this.state.message);
|
||||
document.removeEventListener('keydown', this.onKeyPress);
|
||||
}
|
||||
|
||||
onKeyPress(e) {
|
||||
function onKeyPress(e) {
|
||||
const focusedTag = document.activeElement?.nodeName?.toLowerCase();
|
||||
const shouldCapture = !(focusedTag === 'textarea' || focusedTag === 'input' || e.metaKey || e.ctrlKey);
|
||||
if(/^[a-z]|[A-Z]$/.test(e.key) && shouldCapture) {
|
||||
this.editor.focus();
|
||||
editor.focus();
|
||||
}
|
||||
if(e.key === 'Escape') {
|
||||
this.editor.getInputField().blur();
|
||||
editor.getInputField().blur();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: ChatEditorProps): void {
|
||||
const { props } = this;
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', onKeyPress);
|
||||
|
||||
if (prevProps.message !== props.message && this.editor) {
|
||||
this.editor.setValue(props.message);
|
||||
this.editor.setOption('mode', MARKDOWN_CONFIG);
|
||||
//this.editor?.focus();
|
||||
//this.editor.execCommand('goDocEnd');
|
||||
//this.editor?.focus();
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyPress);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.inCodeMode) {
|
||||
this.editor.setOption('mode', MARKDOWN_CONFIG);
|
||||
this.editor.setOption('placeholder', this.props.placeholder);
|
||||
if (inCodeMode) {
|
||||
editor.setOption('mode', null);
|
||||
editor.setOption('placeholder', 'Code...');
|
||||
} else {
|
||||
this.editor.setOption('mode', null);
|
||||
this.editor.setOption('placeholder', 'Code...');
|
||||
editor.setOption('mode', MARKDOWN_CONFIG);
|
||||
editor.setOption('placeholder', placeholder);
|
||||
}
|
||||
const value = this.editor.getValue();
|
||||
|
||||
// Force redraw of placeholder
|
||||
const value = editor.getValue();
|
||||
if(value.length === 0) {
|
||||
this.editor.setValue(' ');
|
||||
this.editor.setValue('');
|
||||
}
|
||||
editor.setValue(' ');
|
||||
editor.setValue('');
|
||||
}
|
||||
}, [inCodeMode, placeholder]);
|
||||
|
||||
submit() {
|
||||
if(!this.editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorMessage = this.editor.getValue();
|
||||
if (editorMessage === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ message: '' });
|
||||
this.props.submit(editorMessage);
|
||||
this.editor.setValue('');
|
||||
}
|
||||
|
||||
messageChange(editor, data, value) {
|
||||
function messageChange(editor, data, value) {
|
||||
if(value.endsWith('/')) {
|
||||
editor.showHint(['test', 'foo']);
|
||||
}
|
||||
if (this.state.message !== '' && value == '') {
|
||||
this.props.changeEvent(value);
|
||||
this.setState({
|
||||
message: value
|
||||
});
|
||||
if (message !== '' && value == '') {
|
||||
setMessage(value);
|
||||
}
|
||||
if (value == this.props.message || value == '' || value == ' ') {
|
||||
if (value == message || value == '' || value == ' ') {
|
||||
return;
|
||||
}
|
||||
this.props.changeEvent(value);
|
||||
this.setState({
|
||||
message: value
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
inCodeMode,
|
||||
placeholder,
|
||||
message,
|
||||
...props
|
||||
} = this.props;
|
||||
setMessage(value);
|
||||
}
|
||||
|
||||
const codeTheme = inCodeMode ? ' code' : '';
|
||||
|
||||
const options = {
|
||||
mode: MARKDOWN_CONFIG,
|
||||
...defaultOptions,
|
||||
theme: 'tlon' + codeTheme,
|
||||
lineNumbers: false,
|
||||
lineWrapping: true,
|
||||
scrollbarStyle: 'native',
|
||||
cursorHeight: 0.85,
|
||||
placeholder: inCodeMode ? 'Code...' : placeholder,
|
||||
extraKeys: {
|
||||
'Enter': () => {
|
||||
this.submit();
|
||||
},
|
||||
'Enter': submit,
|
||||
'Esc': () => {
|
||||
this.editor?.getInputField().blur();
|
||||
editor?.getInputField().blur();
|
||||
}
|
||||
}
|
||||
},
|
||||
// The below will ony work once codemirror's bug is fixed
|
||||
spellcheck: Boolean(MOBILE_BROWSER_REGEX.test(navigator.userAgent)),
|
||||
autocorrect: Boolean(MOBILE_BROWSER_REGEX.test(navigator.userAgent)),
|
||||
autocapitalize: Boolean(MOBILE_BROWSER_REGEX.test(navigator.userAgent))
|
||||
};
|
||||
|
||||
return (
|
||||
@ -259,14 +217,14 @@ export default class ChatEditor extends Component<ChatEditorProps, ChatEditorSta
|
||||
color="black"
|
||||
overflow='auto'
|
||||
>
|
||||
{MOBILE_BROWSER_REGEX.test(navigator.userAgent)
|
||||
{isMobile
|
||||
? <MobileBox
|
||||
data-value={this.state.message}
|
||||
data-value={message}
|
||||
fontSize={1}
|
||||
lineHeight="tall"
|
||||
onClick={(event) => {
|
||||
if (this.editor) {
|
||||
this.editor.element.focus();
|
||||
if (editor) {
|
||||
editor.element.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -278,32 +236,33 @@ export default class ChatEditor extends Component<ChatEditorProps, ChatEditorSta
|
||||
style={{ width: '100%', background: 'transparent', color: 'currentColor' }}
|
||||
placeholder={inCodeMode ? 'Code...' : 'Message...'}
|
||||
onChange={event =>
|
||||
this.messageChange(null, null, event.target.value)
|
||||
messageChange(null, null, event.target.value)
|
||||
}
|
||||
onKeyDown={event =>
|
||||
this.messageChange(null, null, (event.target as any).value)
|
||||
messageChange(null, null, (event.target as any).value)
|
||||
}
|
||||
onPaste={e => onPaste(null, e)}
|
||||
ref={(input) => {
|
||||
if (!input)
|
||||
return;
|
||||
this.editor = inputProxy(input);
|
||||
return;
|
||||
editorRef.current = inputProxy(input);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</MobileBox>
|
||||
: <CodeEditor
|
||||
className="lh-copy"
|
||||
value={message}
|
||||
options={options}
|
||||
onChange={(e, d, v) => this.messageChange(e, d, v)}
|
||||
editorDidMount={(editor) => {
|
||||
this.editor = editor;
|
||||
onChange={(e, d, v) => messageChange(e, d, v)}
|
||||
editorDidMount={(codeEditor) => {
|
||||
editorRef.current = codeEditor;
|
||||
}}
|
||||
{...props}
|
||||
onPaste={onPaste as any}
|
||||
/>
|
||||
}
|
||||
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default ChatEditor;
|
||||
|
@ -1,158 +1,25 @@
|
||||
import { BaseImage, Box, Icon, LoadingSpinner, Row } from '@tlon/indigo-react';
|
||||
import { Box, Icon, LoadingSpinner, Row } from '@tlon/indigo-react';
|
||||
import { Contact, Content, evalCord } from '@urbit/api';
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import React, { FC, PropsWithChildren, useRef, useState } from 'react';
|
||||
import tokenizeMessage from '~/logic/lib/tokenizeMessage';
|
||||
import { IuseStorage } from '~/logic/lib/useStorage';
|
||||
import { MOBILE_BROWSER_REGEX, uxToHex } from '~/logic/lib/util';
|
||||
import { MOBILE_BROWSER_REGEX } from '~/logic/lib/util';
|
||||
import { withLocalState } from '~/logic/state/local';
|
||||
import withStorage from '~/views/components/withStorage';
|
||||
import ChatEditor from './ChatEditor';
|
||||
import ChatEditor, { CodeMirrorShim } from './ChatEditor';
|
||||
import airlock from '~/logic/api';
|
||||
import { ChatAvatar } from './ChatAvatar';
|
||||
import { useChatStore } from './ChatPane';
|
||||
import { useImperativeHandle } from 'react';
|
||||
import { FileUploadSource, useFileUpload } from '~/logic/lib/useFileUpload';
|
||||
|
||||
type ChatInputProps = IuseStorage & {
|
||||
ourContact?: Contact;
|
||||
onUnmount(msg: string): void;
|
||||
placeholder: string;
|
||||
message: string;
|
||||
deleteMessage(): void;
|
||||
type ChatInputProps = PropsWithChildren<IuseStorage & {
|
||||
hideAvatars: boolean;
|
||||
ourContact?: Contact;
|
||||
placeholder: string;
|
||||
onSubmit: (contents: Content[]) => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
}>;
|
||||
|
||||
interface ChatInputState {
|
||||
inCodeMode: boolean;
|
||||
submitFocus: boolean;
|
||||
uploadingPaste: boolean;
|
||||
currentInput: string;
|
||||
}
|
||||
|
||||
export class ChatInput extends Component<ChatInputProps, ChatInputState> {
|
||||
private chatEditor: React.RefObject<ChatEditor>;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
inCodeMode: false,
|
||||
submitFocus: false,
|
||||
uploadingPaste: false,
|
||||
currentInput: props.message
|
||||
};
|
||||
|
||||
this.chatEditor = React.createRef();
|
||||
|
||||
this.submit = this.submit.bind(this);
|
||||
this.toggleCode = this.toggleCode.bind(this);
|
||||
this.uploadSuccess = this.uploadSuccess.bind(this);
|
||||
this.uploadError = this.uploadError.bind(this);
|
||||
this.eventHandler = this.eventHandler.bind(this);
|
||||
}
|
||||
|
||||
toggleCode() {
|
||||
this.setState({
|
||||
inCodeMode: !this.state.inCodeMode
|
||||
});
|
||||
}
|
||||
|
||||
async submit(text) {
|
||||
const { props, state } = this;
|
||||
const { onSubmit } = this.props;
|
||||
this.setState({
|
||||
inCodeMode: false
|
||||
});
|
||||
props.deleteMessage();
|
||||
if(state.inCodeMode) {
|
||||
const output = await airlock.thread<string[]>(evalCord(text));
|
||||
onSubmit([{ code: { output, expression: text } }]);
|
||||
} else {
|
||||
onSubmit(tokenizeMessage(text));
|
||||
}
|
||||
this.chatEditor.current.editor.focus();
|
||||
this.setState({ currentInput: '' });
|
||||
}
|
||||
|
||||
uploadSuccess(url: string) {
|
||||
const { props } = this;
|
||||
if (this.state.uploadingPaste) {
|
||||
this.chatEditor.current.editor.setValue(url);
|
||||
this.setState({ uploadingPaste: false });
|
||||
} else {
|
||||
props.onSubmit([{ url }]);
|
||||
}
|
||||
}
|
||||
|
||||
uploadError(error) {
|
||||
// no-op for now
|
||||
}
|
||||
|
||||
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 | File[]) {
|
||||
if (!this.props.canUpload) {
|
||||
return;
|
||||
}
|
||||
Array.from(files).forEach((file) => {
|
||||
this.props
|
||||
.uploadDefault(file)
|
||||
.then(this.uploadSuccess)
|
||||
.catch(this.uploadError);
|
||||
});
|
||||
}
|
||||
|
||||
eventHandler(value) {
|
||||
this.setState({ currentInput: value });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const color = props.ourContact ? uxToHex(props.ourContact.color) : '000000';
|
||||
|
||||
const sigilClass = props.ourContact ? '' : 'mix-blend-diff';
|
||||
|
||||
const avatar =
|
||||
props.ourContact && props.ourContact?.avatar && !props.hideAvatars ? (
|
||||
<BaseImage
|
||||
flexShrink={0}
|
||||
src={props.ourContact.avatar}
|
||||
height={24}
|
||||
width={24}
|
||||
style={{ objectFit: 'cover' }}
|
||||
borderRadius={1}
|
||||
display='inline-block'
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
width={24}
|
||||
height={24}
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
backgroundColor={`#${color}`}
|
||||
borderRadius={1}
|
||||
>
|
||||
<Sigil
|
||||
ship={window.ship}
|
||||
size={16}
|
||||
color={`#${color}`}
|
||||
classes={sigilClass}
|
||||
icon
|
||||
padding={2}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
const InputBox: FC = ({ children }) => (
|
||||
<Row
|
||||
alignItems='center'
|
||||
position='relative'
|
||||
@ -164,35 +31,11 @@ export class ChatInput extends Component<ChatInputProps, ChatInputState> {
|
||||
className='cf'
|
||||
zIndex={0}
|
||||
>
|
||||
<Row p='12px 4px 12px 12px' flexShrink={0} alignItems='center'>
|
||||
{avatar}
|
||||
{ children }
|
||||
</Row>
|
||||
<ChatEditor
|
||||
ref={this.chatEditor}
|
||||
inCodeMode={state.inCodeMode}
|
||||
submit={this.submit}
|
||||
onUnmount={props.onUnmount}
|
||||
message={props.message}
|
||||
onPaste={this.onPaste.bind(this)}
|
||||
changeEvent={this.eventHandler}
|
||||
placeholder='Message...'
|
||||
/>
|
||||
<Box
|
||||
mx='12px'
|
||||
mr={this.props.canUpload ? '12px' : 3}
|
||||
flexShrink={0}
|
||||
height='16px'
|
||||
width='16px'
|
||||
flexBasis='16px'
|
||||
>
|
||||
<Icon
|
||||
icon='Dojo'
|
||||
cursor='pointer'
|
||||
onClick={this.toggleCode}
|
||||
color={state.inCodeMode ? 'blue' : 'black'}
|
||||
/>
|
||||
</Box>
|
||||
{this.props.canUpload ? (
|
||||
);
|
||||
|
||||
const IconBox = ({ children, ...props }) => (
|
||||
<Box
|
||||
ml='12px'
|
||||
mr={3}
|
||||
@ -200,23 +43,13 @@ export class ChatInput extends Component<ChatInputProps, ChatInputState> {
|
||||
height='16px'
|
||||
width='16px'
|
||||
flexBasis='16px'
|
||||
{...props}
|
||||
>
|
||||
{this.props.uploading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<Icon
|
||||
icon='Attachment'
|
||||
cursor='pointer'
|
||||
width='16'
|
||||
height='16'
|
||||
onClick={() =>
|
||||
this.props.promptUpload().then(this.uploadSuccess)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{ children }
|
||||
</Box>
|
||||
) : null}
|
||||
{MOBILE_BROWSER_REGEX.test(navigator.userAgent) ?
|
||||
);
|
||||
|
||||
const MobileSubmitButton = ({ enabled, onSubmit }) => (
|
||||
<Box
|
||||
ml={2}
|
||||
mr="12px"
|
||||
@ -227,21 +60,107 @@ export class ChatInput extends Component<ChatInputProps, ChatInputState> {
|
||||
width="24px"
|
||||
height="24px"
|
||||
borderRadius="50%"
|
||||
backgroundColor={state.currentInput !== '' ? 'blue' : 'gray'}
|
||||
cursor={state.currentInput !== '' ? 'pointer' : 'default'}
|
||||
onClick={() => this.chatEditor.current.submit()}
|
||||
backgroundColor={enabled ? 'blue' : 'gray'}
|
||||
cursor={enabled !== '' ? 'pointer' : 'default'}
|
||||
onClick={() => onSubmit()}
|
||||
>
|
||||
<Icon icon="ArrowEast" color="white" />
|
||||
</Box>
|
||||
: null}
|
||||
</Row>
|
||||
);
|
||||
);
|
||||
|
||||
export const ChatInput = React.forwardRef(({ ourContact, hideAvatars, placeholder, onSubmit }: ChatInputProps, ref) => {
|
||||
const chatEditor = useRef<CodeMirrorShim>(null);
|
||||
useImperativeHandle(ref, () => chatEditor.current);
|
||||
const [inCodeMode, setInCodeMode] = useState(false);
|
||||
|
||||
const {
|
||||
message,
|
||||
setMessage
|
||||
} = useChatStore();
|
||||
const { canUpload, uploading, promptUpload, onPaste } = useFileUpload({
|
||||
onSuccess: uploadSuccess
|
||||
});
|
||||
|
||||
function uploadSuccess(url: string, source: FileUploadSource) {
|
||||
if (source === 'paste') {
|
||||
setMessage(url);
|
||||
} else {
|
||||
onSubmit([{ url }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCode() {
|
||||
setInCodeMode(!inCodeMode);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const text = chatEditor.current?.getValue() || '';
|
||||
|
||||
if (text === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inCodeMode) {
|
||||
const output = await airlock.thread<string[]>(evalCord(text));
|
||||
onSubmit([{ code: { output, expression: text } }]);
|
||||
} else {
|
||||
onSubmit(tokenizeMessage(text));
|
||||
}
|
||||
|
||||
setInCodeMode(false);
|
||||
setMessage('');
|
||||
chatEditor.current.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<InputBox>
|
||||
<Row p='12px 4px 12px 12px' flexShrink={0} alignItems='center'>
|
||||
<ChatAvatar contact={ourContact} hideAvatars={hideAvatars} />
|
||||
</Row>
|
||||
<ChatEditor
|
||||
ref={chatEditor}
|
||||
inCodeMode={inCodeMode}
|
||||
submit={submit}
|
||||
onPaste={(cm, e) => onPaste(e)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<IconBox mr={canUpload ? '12px' : 3}>
|
||||
<Icon
|
||||
icon='Dojo'
|
||||
cursor='pointer'
|
||||
onClick={toggleCode}
|
||||
color={inCodeMode ? 'blue' : 'black'}
|
||||
/>
|
||||
</IconBox>
|
||||
{canUpload && (
|
||||
<IconBox>
|
||||
{uploading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<Icon
|
||||
icon='Attachment'
|
||||
cursor='pointer'
|
||||
width='16'
|
||||
height='16'
|
||||
onClick={() =>
|
||||
promptUpload().then(url => uploadSuccess(url, 'direct'))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</IconBox>
|
||||
)}
|
||||
{MOBILE_BROWSER_REGEX.test(navigator.userAgent) && (
|
||||
<MobileSubmitButton
|
||||
enabled={message !== ''}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
)}
|
||||
</InputBox>
|
||||
);
|
||||
});
|
||||
|
||||
// @ts-ignore withLocalState prop passing weirdness
|
||||
export default withLocalState<Omit<ChatInputProps, keyof IuseStorage>, 'hideAvatars', ChatInput>(
|
||||
// @ts-ignore withLocalState prop passing weirdness
|
||||
withStorage<ChatInputProps, ChatInput>(ChatInput, { accept: 'image/*' }),
|
||||
ChatInput,
|
||||
['hideAvatars']
|
||||
);
|
||||
|
@ -1,18 +1,50 @@
|
||||
import { Col } from '@tlon/indigo-react';
|
||||
import { Content, Graph, Post } from '@urbit/api';
|
||||
import bigInt, { BigInteger } from 'big-integer';
|
||||
import _ from 'lodash';
|
||||
import React, { ReactElement, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useFileDrag } from '~/logic/lib/useDrag';
|
||||
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
|
||||
import create from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { useFileUpload } from '~/logic/lib/useFileUpload';
|
||||
import { useOurContact } from '~/logic/state/contact';
|
||||
import { useGraphTimesent } from '~/logic/state/graph';
|
||||
import ShareProfile from '~/views/apps/chat/components/ShareProfile';
|
||||
import { Loading } from '~/views/components/Loading';
|
||||
import SubmitDragger from '~/views/components/SubmitDragger';
|
||||
import ChatInput, { ChatInput as NakedChatInput } from './ChatInput';
|
||||
import ChatInput from './ChatInput';
|
||||
import ChatWindow from './ChatWindow';
|
||||
|
||||
interface useChatStoreType {
|
||||
id: string;
|
||||
message: string;
|
||||
messageStore: Record<string, string>;
|
||||
restore: (id: string) => void;
|
||||
setMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
const unsentKey = 'chat-unsent';
|
||||
export const useChatStore = create<useChatStoreType>(persist((set, get) => ({
|
||||
id: '',
|
||||
message: '',
|
||||
messageStore: {},
|
||||
restore: (id: string) => {
|
||||
const store = get().messageStore;
|
||||
set({
|
||||
id,
|
||||
messageStore: store,
|
||||
message: store[id] || ''
|
||||
});
|
||||
},
|
||||
setMessage: (message: string) => {
|
||||
const store = get().messageStore;
|
||||
store[get().id] = message;
|
||||
|
||||
set({ message, messageStore: store });
|
||||
}
|
||||
}), {
|
||||
name: unsentKey,
|
||||
whitelist: ['messageStore']
|
||||
}));
|
||||
|
||||
interface ChatPaneProps {
|
||||
/**
|
||||
* A key to uniquely identify a ChatPane instance. Should be either the
|
||||
@ -79,37 +111,13 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
|
||||
} = props;
|
||||
const graphTimesentMap = useGraphTimesent(id);
|
||||
const ourContact = useOurContact();
|
||||
const chatInput = useRef<NakedChatInput>();
|
||||
|
||||
const onFileDrag = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
if (!chatInput.current) {
|
||||
return;
|
||||
}
|
||||
(chatInput.current as NakedChatInput)?.uploadFiles(files);
|
||||
},
|
||||
[chatInput]
|
||||
);
|
||||
|
||||
const { bind, dragging } = useFileDrag(onFileDrag);
|
||||
|
||||
const [unsent, setUnsent] = useLocalStorageState<Record<string, string>>(
|
||||
'chat-unsent',
|
||||
{}
|
||||
);
|
||||
|
||||
const appendUnsent = useCallback(
|
||||
(u: string) => setUnsent(s => ({ ...s, [id]: u })),
|
||||
[id]
|
||||
);
|
||||
|
||||
const clearUnsent = useCallback(() => {
|
||||
setUnsent((s) => {
|
||||
if (id in s) {
|
||||
return _.omit(s, id);
|
||||
}
|
||||
return s;
|
||||
const { restore, setMessage } = useChatStore(s => ({ setMessage: s.setMessage, restore: s.restore }));
|
||||
const { canUpload, drag } = useFileUpload({
|
||||
onSuccess: url => onSubmit([{ url }])
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
restore(id);
|
||||
}, [id]);
|
||||
|
||||
const scrollTo = new URLSearchParams(location.search).get('msg');
|
||||
@ -123,7 +131,7 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
|
||||
const onReply = useCallback(
|
||||
(msg: Post) => {
|
||||
const message = props.onReply(msg);
|
||||
setUnsent(s => ({ ...s, [id]: message }));
|
||||
setMessage(message);
|
||||
},
|
||||
[id, props.onReply]
|
||||
);
|
||||
@ -134,13 +142,13 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
|
||||
|
||||
return (
|
||||
// @ts-ignore bind typings
|
||||
<Col {...bind} height="100%" overflow="hidden" position="relative">
|
||||
<Col {...drag.bind} height="100%" overflow="hidden" position="relative">
|
||||
<ShareProfile
|
||||
our={ourContact}
|
||||
recipients={showBanner ? promptShare : []}
|
||||
onShare={() => setShowBanner(false)}
|
||||
/>
|
||||
{dragging && <SubmitDragger />}
|
||||
{canUpload && drag.dragging && <SubmitDragger />}
|
||||
<ChatWindow
|
||||
key={id}
|
||||
graph={graph}
|
||||
@ -158,13 +166,9 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
|
||||
/>
|
||||
{canWrite && (
|
||||
<ChatInput
|
||||
ref={chatInput}
|
||||
onSubmit={onSubmit}
|
||||
ourContact={(promptShare.length === 0 && ourContact) || undefined}
|
||||
onUnmount={appendUnsent}
|
||||
placeholder="Message..."
|
||||
message={unsent[id] || ''}
|
||||
deleteMessage={clearUnsent}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, LoadingSpinner, Action, Row } from '@tlon/indigo-react';
|
||||
|
||||
import useStorage from '~/logic/lib/useStorage';
|
||||
import { StatelessUrlInput } from '~/views/components/StatelessUrlInput';
|
||||
import SubmitDragger from '~/views/components/SubmitDragger';
|
||||
import { Association, resourceFromPath, createPost } from '@urbit/api';
|
||||
import { parsePermalink, permalinkToReference } from '~/logic/lib/permalinks';
|
||||
import useGraphState, { GraphState } from '~/logic/state/graph';
|
||||
import { useFileUpload } from '~/logic/lib/useFileUpload';
|
||||
|
||||
interface LinkBlockInputProps {
|
||||
size: string;
|
||||
@ -22,7 +23,6 @@ export function LinkBlockInput(props: LinkBlockInputProps) {
|
||||
const [focussed, setFocussed] = useState(false);
|
||||
|
||||
const addPost = useGraphState(selGraph);
|
||||
const { uploading, canUpload, promptUpload } = useStorage();
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
setFocussed(true);
|
||||
@ -41,6 +41,10 @@ export function LinkBlockInput(props: LinkBlockInputProps) {
|
||||
setValid(URLparser.test(val) || Boolean(parsePermalink(val)));
|
||||
}, []);
|
||||
|
||||
const { uploading, canUpload, promptUpload, drag } = useFileUpload({
|
||||
onSuccess: handleChange
|
||||
});
|
||||
|
||||
const doPost = () => {
|
||||
const text = '';
|
||||
const { ship, name } = resourceFromPath(association.resource);
|
||||
@ -83,7 +87,9 @@ export function LinkBlockInput(props: LinkBlockInputProps) {
|
||||
p="2"
|
||||
position="relative"
|
||||
backgroundColor="washedGray"
|
||||
{...drag.bind}
|
||||
>
|
||||
{drag.dragging && <SubmitDragger />}
|
||||
{uploading ? (
|
||||
<Box
|
||||
display="flex"
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { BaseInput, Box, Button, LoadingSpinner } from '@tlon/indigo-react';
|
||||
import { hasProvider } from 'oembed-parser';
|
||||
import React, { useCallback, useState, DragEvent, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { parsePermalink, permalinkToReference } from '~/logic/lib/permalinks';
|
||||
import { useFileDrag } from '~/logic/lib/useDrag';
|
||||
import useStorage from '~/logic/lib/useStorage';
|
||||
import { StatelessUrlInput } from '~/views/components/StatelessUrlInput';
|
||||
import SubmitDragger from '~/views/components/SubmitDragger';
|
||||
import useGraphState from '~/logic/state/graph';
|
||||
import { createPost } from '@urbit/api';
|
||||
import { useFileUpload } from '~/logic/lib/useFileUpload';
|
||||
|
||||
interface LinkSubmitProps {
|
||||
name: string;
|
||||
@ -16,8 +15,6 @@ interface LinkSubmitProps {
|
||||
}
|
||||
|
||||
const LinkSubmit = (props: LinkSubmitProps) => {
|
||||
const { canUpload, uploadDefault, uploading, promptUpload } =
|
||||
useStorage();
|
||||
const addPost = useGraphState(s => s.addPost);
|
||||
|
||||
const [submitFocused, setSubmitFocused] = useState(false);
|
||||
@ -26,6 +23,17 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [linkValid, setLinkValid] = useState(false);
|
||||
|
||||
const {
|
||||
canUpload,
|
||||
uploading,
|
||||
promptUpload,
|
||||
drag,
|
||||
onPaste
|
||||
} = useFileUpload({
|
||||
onSuccess: setLinkValue,
|
||||
multiple: false
|
||||
});
|
||||
|
||||
const doPost = () => {
|
||||
const url = linkValue;
|
||||
const text = linkTitle ? linkTitle : linkValue;
|
||||
@ -98,18 +106,6 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
||||
setLinkValid(validateLink(linkValue));
|
||||
}, [linkValue]);
|
||||
|
||||
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 = () => {
|
||||
const link = validateLink(linkValue);
|
||||
setLinkValid(link);
|
||||
@ -117,17 +113,6 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
||||
|
||||
useEffect(onLinkChange, [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();
|
||||
@ -145,7 +130,7 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
||||
borderColor={submitFocused ? 'black' : 'lightGray'}
|
||||
width='100%'
|
||||
borderRadius={2}
|
||||
{...bind}
|
||||
{...drag.bind}
|
||||
>
|
||||
{uploading && <Box
|
||||
display="flex"
|
||||
@ -161,7 +146,7 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
||||
>
|
||||
<LoadingSpinner />
|
||||
</Box>}
|
||||
{dragging && <SubmitDragger />}
|
||||
{drag.dragging && <SubmitDragger />}
|
||||
<StatelessUrlInput
|
||||
value={linkValue}
|
||||
promptUpload={promptUpload}
|
||||
|
@ -5,11 +5,11 @@ import 'codemirror/addon/edit/continuelist';
|
||||
import 'codemirror/lib/codemirror.css';
|
||||
import 'codemirror/mode/markdown/markdown';
|
||||
import { useFormikContext } from 'formik';
|
||||
import React, { useCallback, useRef, DragEvent } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { UnControlled as CodeEditor } from 'react-codemirror2';
|
||||
import { Prompt } from 'react-router-dom';
|
||||
import { useFileDrag } from '~/logic/lib/useDrag';
|
||||
import useStorage from '~/logic/lib/useStorage';
|
||||
import { useFileUpload } from '~/logic/lib/useFileUpload';
|
||||
import { IuseStorage } from '~/logic/lib/useStorage';
|
||||
import { usePreventWindowUnload } from '~/logic/lib/util';
|
||||
import { PropFunc } from '~/types/util';
|
||||
import SubmitDragger from '~/views/components/SubmitDragger';
|
||||
@ -61,10 +61,8 @@ export function MarkdownEditor(
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const { uploadDefault, canUpload } = useStorage();
|
||||
|
||||
const onFileDrag = useCallback(
|
||||
async (files: FileList | File[], e: DragEvent) => {
|
||||
const onFileUpload = useCallback(
|
||||
async (files: FileList | File[], { canUpload, uploadDefault }: IuseStorage) => {
|
||||
if (!canUpload || !editor.current) {
|
||||
return;
|
||||
}
|
||||
@ -79,10 +77,14 @@ export function MarkdownEditor(
|
||||
doc.setValue(doc.getValue().replace(placeholder, markdown));
|
||||
});
|
||||
},
|
||||
[uploadDefault, canUpload, value, onChange]
|
||||
[value, onChange]
|
||||
);
|
||||
|
||||
const { bind, dragging } = useFileDrag(onFileDrag);
|
||||
const {
|
||||
drag: { bind, dragging }
|
||||
} = useFileUpload({
|
||||
onFiles: onFileUpload
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
@ -1,19 +1,23 @@
|
||||
import {
|
||||
Box,
|
||||
Button, ManagedForm as Form, ManagedTextInputField as Input,
|
||||
|
||||
Button,
|
||||
ManagedForm as Form,
|
||||
ManagedTextInputField as Input,
|
||||
Menu,
|
||||
MenuButton,
|
||||
|
||||
MenuItem, MenuList,
|
||||
|
||||
Row, Text
|
||||
} from '@tlon/indigo-react';
|
||||
import { addBucket, removeBucket, setCurrentBucket } from '@urbit/api';
|
||||
import { Formik, FormikHelpers } from 'formik';
|
||||
import React, { ReactElement, useCallback, useState } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import airlock from '~/logic/api';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
newBucket: Yup.string().required('Required')
|
||||
});
|
||||
|
||||
export function BucketList({
|
||||
buckets,
|
||||
selected
|
||||
@ -29,8 +33,9 @@ export function BucketList({
|
||||
(values: { newBucket: string }, actions: FormikHelpers<any>) => {
|
||||
airlock.poke(addBucket(values.newBucket));
|
||||
actions.resetForm({ values: { newBucket: '' } });
|
||||
setAdding(false);
|
||||
},
|
||||
[]
|
||||
[setAdding]
|
||||
);
|
||||
|
||||
const onSelect = useCallback(
|
||||
@ -52,7 +57,7 @@ export function BucketList({
|
||||
);
|
||||
|
||||
return (
|
||||
<Formik initialValues={{ newBucket: '' }} onSubmit={onSubmit}>
|
||||
<Formik validationSchema={validationSchema} initialValues={{ newBucket: '' }} onSubmit={onSubmit}>
|
||||
<Form
|
||||
display="grid"
|
||||
gridTemplateColumns="100%"
|
||||
@ -103,14 +108,25 @@ export function BucketList({
|
||||
<Button type="button" onClick={() => setAdding(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!adding &&
|
||||
<Button
|
||||
width="fit-content"
|
||||
primary
|
||||
type={adding ? 'submit' : 'button'}
|
||||
onClick={() => setAdding(s => !s)}
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
{adding ? 'Submit' : 'Add new bucket'}
|
||||
Add new bucket
|
||||
</Button>
|
||||
}
|
||||
{adding &&
|
||||
<Button
|
||||
width="fit-content"
|
||||
primary
|
||||
type="submit"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
}
|
||||
</Row>
|
||||
</Form>
|
||||
</Formik>
|
||||
|
Loading…
Reference in New Issue
Block a user