mirror of
https://github.com/irislib/iris-messenger.git
synced 2024-10-18 14:13:21 +00:00
msg form refactoring
This commit is contained in:
parent
c82221a4c1
commit
138ff67daa
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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;
|
@ -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;
|
||||
|
@ -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('/')}
|
||||
/>
|
||||
|
Loading…
Reference in New Issue
Block a user