msg form refactoring

This commit is contained in:
Martti Malmi 2023-08-11 10:05:05 +03:00
parent c82221a4c1
commit 138ff67daa
5 changed files with 294 additions and 300 deletions

View File

@ -114,11 +114,14 @@ export default class OnboardingNotification extends Component {
<p>{t('no_followers_yet')}</p>
<div className="flex gap-2 my-2">
<Copy
className="btn btn-neutral"
className="btn btn-sm btn-neutral"
text={t('copy_link')}
copyStr={Helpers.getMyProfileLink()}
/>
<button className="btn btn-neutral" onClick={() => this.setState({ showQrModal: true })}>
<button
className="btn btn-sm btn-neutral"
onClick={() => this.setState({ showQrModal: true })}
>
{t('show_qr_code')}
</button>
</div>

View File

@ -1,251 +1,157 @@
import $ from 'jquery';
import { useRef } from 'react';
import { useCallback, useState } from 'preact/hooks';
import Component from '@/BaseComponent';
import FileAttachment from '@/components/create/FileAttachment.tsx';
import TextArea from '@/components/create/TextArea';
import { IProps, IState } from '@/components/create/types';
import { sendNostr } from '@/components/create/util.ts';
import Show from '@/components/helpers/Show.tsx';
import SearchBox from '@/components/SearchBox';
import { sendNostr } from '@/components/create/util';
import Show from '@/components/helpers/Show';
import Helpers from '@/Helpers';
import Icons from '@/Icons';
import localState from '@/LocalState';
import Key from '@/nostr/Key';
import { translate as t } from '@/translations/Translation.mjs';
import { uploadFile } from '@/utils/uploadFile';
import AttachmentPreview from './AttachmentPreview';
const mentionRegex = /\B@[\u00BF-\u1FFF\u2C00-\uD7FF\w]*$/;
type CreateNoteFormProps = {
replyingTo?: string;
onSubmit?: (text: string) => void;
placeholder?: string;
class?: string;
waitForFocus?: boolean;
autofocus?: boolean;
forceAutoFocusMobile?: boolean;
};
class CreateNoteForm extends Component<IProps, IState> {
componentDidMount() {
const textEl = $(this.base).find('textarea');
if (
(!Helpers.isMobile || this.props.forceAutofocusMobile == true) &&
this.props.autofocus !== false
) {
textEl.focus();
}
if (!this.props.replyingTo) {
localState
.get('channels')
.get('public')
.get('msgDraft')
.once((text) => this.setState({ text }));
} else {
const currentHistoryState = window.history.state;
if (currentHistoryState && currentHistoryState['replyTo' + this.props.replyingTo]) {
textEl.val(currentHistoryState['replyTo' + this.props.replyingTo]);
}
}
}
function CreateNoteForm({
replyingTo,
onSubmit: onFormSubmit,
placeholder = 'type_a_message',
class: className,
waitForFocus,
autofocus,
forceAutoFocusMobile,
}: CreateNoteFormProps) {
const [text, setText] = useState('');
const [attachments, setAttachments] = useState<any[]>([]);
const [torrentId, setTorrentId] = useState('');
const [focused, setFocused] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
onMsgFormSubmit(event) {
const onMsgFormSubmit = useCallback(
(event) => {
event.preventDefault();
this.submit();
}
submit();
},
[text, attachments, torrentId],
);
async submit() {
if (!this.props.replyingTo) {
const submit = useCallback(async () => {
if (!replyingTo) {
localState.get('channels').get('public').get('msgDraft').put(null);
}
const text = this.state.text;
if (!text.length) {
return;
}
if (!text.length) return;
const msg: any = { text };
if (this.props.replyingTo) {
msg.replyingTo = this.props.replyingTo;
}
if (this.state.attachments) {
msg.attachments = this.state.attachments;
}
if (replyingTo) msg.replyingTo = replyingTo;
if (attachments.length) msg.attachments = attachments;
await sendNostr(msg);
this.props.onSubmit && this.props.onSubmit(msg);
this.setState({ attachments: undefined, torrentId: undefined, text: '' });
$(this.base).find('textarea').height('');
this.saveDraftToHistory();
}
onFormSubmit?.(msg);
setTextareaHeight(textarea) {
textarea.style.height = '';
textarea.style.height = `${textarea.scrollHeight}px`;
}
setText('');
setAttachments([]);
setTorrentId('');
}, [text, attachments, torrentId, replyingTo, onFormSubmit]);
onMsgTextPaste(event) {
const clipboardData = event.clipboardData || window.clipboardData;
// Handling magnet links
const pasted = clipboardData.getData('text');
const magnetRegex = /(magnet:\?xt=urn:btih:.*)/gi;
const match = magnetRegex.exec(pasted);
console.log('magnet match', match);
if (match) {
this.setState({ torrentId: match[0] });
}
if (clipboardData.items) {
const items = clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const blob = items[i].getAsFile();
uploadFile(
blob,
(url) => {
const currentVal = this.state.text;
if (currentVal) {
this.setState({ text: currentVal + '\n\n' + url });
} else {
this.setState({ text: url });
}
},
(errorMsg) => {
console.error(errorMsg);
},
);
}
}
}
}
onKeyUp(e) {
if ([37, 38, 39, 40].indexOf(e.keyCode) != -1) {
this.checkMention(e);
}
}
onKeyDown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
this.submit();
}
}
saveDraftToHistory() {
const text = this.state.text;
const currentHistoryState = window.history.state;
const newHistoryState = {
...currentHistoryState,
};
newHistoryState['replyTo' + this.props.replyingTo] = text;
window.history.replaceState(newHistoryState, '');
}
onMsgTextInput(event) {
this.setTextareaHeight(event.target);
if (!this.props.replyingTo) {
localState.get('channels').get('public').get('msgDraft').put($(event.target).val());
}
this.checkMention(event);
this.saveDraftToHistory();
this.setState({ text: $(event.target).val() });
}
attachFileClicked(event) {
const attachFileClicked = useCallback((event) => {
event.stopPropagation();
event.preventDefault();
$(this.base).find('.attachment-input').click();
// Use the ref to simulate a click on the file input
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
const handleFileAttachments = useCallback(
(files) => {
if (!files) return;
handleFileAttachments(files) {
if (files) {
for (let i = 0; i < files.length; i++) {
const formData = new FormData();
formData.append('fileToUpload', files[i]);
const file = files[i];
const a = this.state.attachments || [];
a[i] = a[i] || {
type: files[i].type,
};
// Initialize or use existing attachments array
const currentAttachments = [...attachments];
currentAttachments[i] = currentAttachments[i] || { type: file.type };
Helpers.getBase64(files[i]).then((base64) => {
a[i].data = base64;
this.setState({ attachments: a });
// Get the base64 representation of the file
Helpers.getBase64(file).then((base64) => {
currentAttachments[i].data = base64;
setAttachments(currentAttachments);
});
const formData = new FormData();
formData.append('fileToUpload', file);
fetch('https://nostr.build/api/upload/iris.php', {
method: 'POST',
body: formData,
})
.then(async (response) => {
const url = await response.json();
console.log('upload response', url);
if (url) {
a[i].url = url;
this.setState({ attachments: a });
const currentVal = this.state.text;
if (currentVal) {
this.setState({ text: currentVal + '\n\n' + url });
} else {
this.setState({ text: url });
}
currentAttachments[i].url = url;
setAttachments(currentAttachments);
setText((prevText) => (prevText ? `${prevText}\n\n${url}` : url));
}
})
.catch((error) => {
console.error('upload error', error);
a[i].error = 'upload failed';
this.setState({ attachments: a });
currentAttachments[i].error = 'upload failed';
setAttachments(currentAttachments);
});
}
$(this.base).find('textarea').focus();
}
}
},
[attachments, text],
);
attachmentsChanged(event) {
// TODO use Upload btn
const attachmentsChanged = useCallback(
(event) => {
event.preventDefault();
const files = event.target.files || event.dataTransfer.files;
this.handleFileAttachments(files);
}
onSelectMention(item) {
const textarea = $(this.base).find('textarea').get(0);
const pos = textarea.selectionStart;
const join = [
textarea.value.slice(0, pos).replace(mentionRegex, 'nostr:'),
item.key,
textarea.value.slice(pos),
].join('');
textarea.value = `${join} `;
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = pos + item.key.length;
}
render() {
const textareaPlaceholder =
this.props.placeholder ||
(this.props.index === 'media' ? 'type_a_message_or_paste_a_magnet_link' : 'type_a_message');
handleFileAttachments(files);
},
[handleFileAttachments],
);
return (
<form
autoComplete="off"
className={`message-form ${this.props.class || ''} public`}
onSubmit={(e) => this.onMsgFormSubmit(e)}
className={`message-form ${className || ''} public`}
onSubmit={(e) => onMsgFormSubmit(e)}
>
<FileAttachment onFilesChanged={(files) => this.handleFileAttachments(files)} />
<input
name="attachment-input"
type="file"
className="hidden attachment-input"
accept="image/*, video/*, audio/*"
multiple
onChange={attachmentsChanged}
ref={fileInputRef}
/>
<TextArea
onMsgTextPaste={(e) => this.onMsgTextPaste(e)}
onKeyUp={(e) => this.onKeyUp(e)}
onKeyDown={(e) => this.onKeyDown(e)}
onMsgTextInput={(e) => this.onMsgTextInput(e)}
attachmentsChanged={(e) => this.attachmentsChanged(e)}
placeholder={textareaPlaceholder}
value={this.state.text}
onFocus={() => setFocused(true)}
setTorrentId={setTorrentId}
submit={submit}
setValue={setText}
value={text}
attachmentsChanged={attachmentsChanged}
placeholder={placeholder}
autofocus={autofocus}
forceAutoFocusMobile={forceAutoFocusMobile}
/>
<Show when={this.state.mentioning}>
<SearchBox
resultsOnly
query={this.state.mentioning}
onSelect={(item) => this.onSelectMention(item)}
/>
</Show>
<Show when={!(this.props.waitForFocus && !this.state.focused)}>
<Show when={!waitForFocus || focused}>
<div className="flex items-center justify-between mt-4">
<button
type="button"
className="attach-file-btn btn"
onClick={(e) => this.attachFileClicked(e)}
>
<button type="button" className="attach-file-btn btn" onClick={attachFileClicked}>
{Icons.attach}
</button>
<button type="submit" className="btn btn-primary">
@ -254,26 +160,12 @@ class CreateNoteForm extends Component<IProps, IState> {
</div>
</Show>
<AttachmentPreview
attachments={this.state.attachments}
torrentId={this.state.torrentId}
removeAttachments={() => this.setState({ attachments: undefined })}
attachments={attachments}
torrentId={torrentId}
removeAttachments={() => setAttachments([])}
/>
</form>
);
}
checkMention(event: any) {
const val = event.target.value.slice(0, event.target.selectionStart);
const matches = val.match(mentionRegex);
if (matches) {
const match = matches[0].slice(1);
if (!Key.toNostrHexAddress(match)) {
this.setState({ mentioning: match });
}
} else if (this.state.mentioning) {
this.setState({ mentioning: undefined });
}
}
}
export default CreateNoteForm;

View File

@ -1,31 +0,0 @@
import $ from 'jquery';
import { FunctionalComponent } from 'preact';
type Props = {
onFilesChanged: (files: File[]) => void;
};
const FileAttachment: FunctionalComponent<Props> = ({ onFilesChanged }) => {
const attachmentsChanged = (event) => {
const files = event.target.files || event.dataTransfer.files;
if (files) {
onFilesChanged(Array.from(files));
}
$(event.target).val(null);
// If you want to focus the main textarea, you'd need to lift the ref to the parent component
// or pass a ref/callback to set the focus.
};
return (
<input
name="attachment-input"
type="file"
className="hidden attachment-input"
accept="image/*, video/*, audio/*"
multiple
onChange={attachmentsChanged}
/>
);
};
export default FileAttachment;

View File

@ -1,34 +1,158 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import Show from '@/components/helpers/Show';
import SearchBox from '@/components/SearchBox';
import Helpers from '@/Helpers.tsx';
import localState from '@/LocalState';
import Key from '@/nostr/Key';
import { translate as t } from '@/translations/Translation.mjs';
import { uploadFile } from '@/utils/uploadFile';
const mentionRegex = /\B@[\u00BF-\u1FFF\u2C00-\uD7FF\w]*$/;
interface TextAreaProps {
onMsgTextPaste: (event: any) => void;
onKeyUp: (e: any) => void;
onKeyDown: (e: any) => void;
onMsgTextInput: (event: any) => void;
attachmentsChanged: (e: any) => void;
setTorrentId: (value: string) => void;
submit: () => void;
attachmentsChanged: (event) => void;
placeholder: string;
replyingTo?: string;
autofocus?: boolean;
forceAutoFocusMobile?: boolean;
onFocus?: () => void;
value: string;
setValue: (value: string) => void;
}
const TextArea = ({
onMsgTextPaste,
onKeyUp,
onKeyDown,
onMsgTextInput,
const TextArea: React.FC<TextAreaProps> = ({
setTorrentId,
submit,
attachmentsChanged,
placeholder,
replyingTo,
autofocus,
forceAutoFocusMobile,
onFocus,
value,
}: TextAreaProps) => (
setValue,
}) => {
const [mentioning, setMentioning] = useState<string | null>(null);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (el) {
el.style.height = `${el.scrollHeight}px`;
}
}, [value]);
useEffect(() => {
if (!replyingTo) {
localState
.get('channels')
.get('public')
.get('msgDraft')
.once((text) => setValue(text));
} else {
const currentHistoryState = window.history.state;
if (currentHistoryState && currentHistoryState['replyTo' + replyingTo]) {
setValue(currentHistoryState['replyTo' + replyingTo]);
}
}
}, []);
useEffect(() => {
if ((!Helpers.isMobile || forceAutoFocusMobile == true) && autofocus !== false) {
ref?.current?.focus();
}
}, [ref.current]);
const onPaste = useCallback((event) => {
const clipboardData = event.clipboardData || window.clipboardData;
const pasted = clipboardData.getData('text');
const magnetRegex = /(magnet:\?xt=urn:btih:.*)/gi;
const match = magnetRegex.exec(pasted);
if (match) setTorrentId(match[0]);
if (clipboardData.items) {
const items = clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const blob = items[i].getAsFile();
uploadFile(
blob,
(url) => setValue(value ? `${value}\n\n${url}` : url),
(errorMsg) => console.error(errorMsg),
);
}
}
}
}, []);
const onKeyDown = useCallback(
(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submit();
}
},
[submit],
);
const onInput = (event) => {
const val = event.target.value;
setValue(val);
checkMention(event);
if (!replyingTo) {
localState.get('channels').get('public').get('msgDraft').put(val);
}
};
const onKeyUp = (e) => {
if ([37, 38, 39, 40].includes(e.keyCode)) {
checkMention(e);
}
};
const onSelectMention = (item: any) => {
const textarea = ref.current;
if (!textarea) return;
const pos = textarea.selectionStart;
const newValue = [
textarea.value.slice(0, pos).replace(mentionRegex, 'nostr:'),
item.key,
textarea.value.slice(pos),
].join('');
setValue(newValue);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = pos + item.key.length + 1;
};
const checkMention = (event) => {
const val = event.target.value.slice(0, event.target.selectionStart);
const matches = val.match(mentionRegex);
if (matches) {
const match = matches[0].slice(1);
if (!Key.toNostrHexAddress(match)) {
setMentioning(match);
}
} else {
setMentioning(null);
}
};
return (
<>
<textarea
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onFocus={onFocus}
onDrop={attachmentsChanged}
onKeyUp={onKeyUp}
onKeyDown={onKeyDown}
onPaste={onMsgTextPaste}
onInput={onMsgTextInput}
onPaste={onPaste}
onInput={onInput}
className="p-2 mt-1 w-full h-12 bg-black focus:ring-blue-500 focus:border-blue-500 block w-full text-lg border-gray-700 rounded-md text-white"
type="text"
placeholder={t(placeholder)}
@ -37,7 +161,13 @@ const TextArea = ({
autoCapitalize="sentences"
spellCheck={false}
value={value}
ref={ref}
/>
);
<Show when={mentioning}>
<SearchBox resultsOnly query={mentioning || ''} onSelect={onSelectMention} />
</Show>
</>
);
};
export default TextArea;

View File

@ -35,7 +35,7 @@ class Note extends View {
<div className="m-2">
<CreateNoteForm
placeholder={t('whats_on_your_mind')}
forceAutofocusMobile={true}
forceAutoFocusMobile={true}
autofocus={true}
onSubmit={() => route('/')}
/>