mirror of
https://github.com/irislib/iris-messenger.git
synced 2024-10-18 06:03:22 +00:00
wip refactor publicmessageform
This commit is contained in:
parent
6da28a95f2
commit
9a4048a18d
@ -15,6 +15,8 @@ import {
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { Link } from 'preact-router';
|
||||
|
||||
import CreateNoteForm from '@/components/create/CreateNoteForm';
|
||||
|
||||
import Icons from '../Icons';
|
||||
import localState from '../LocalState';
|
||||
import Key from '../nostr/Key';
|
||||
@ -24,7 +26,6 @@ import Show from './helpers/Show';
|
||||
import Modal from './modal/Modal';
|
||||
import Avatar from './user/Avatar';
|
||||
import Name from './user/Name';
|
||||
import PublicMessageForm from './PublicMessageForm';
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{ url: '/', text: 'home', icon: HomeIcon, activeIcon: HomeIconFull },
|
||||
@ -80,7 +81,7 @@ export default function Menu() {
|
||||
|
||||
const renderNewPostModal = () => (
|
||||
<Modal centerVertically={true} showContainer={true} onClose={() => setShowNewPostModal(false)}>
|
||||
<PublicMessageForm
|
||||
<CreateNoteForm
|
||||
onSubmit={() => setShowNewPostModal(false)}
|
||||
placeholder={t('whats_on_your_mind')}
|
||||
autofocus={true}
|
||||
|
@ -1,436 +0,0 @@
|
||||
import { html } from 'htm/preact';
|
||||
import $ from 'jquery';
|
||||
import { Event } from 'nostr-tools';
|
||||
import { createRef } from 'preact';
|
||||
|
||||
import { uploadFile } from '@/utils/uploadFile';
|
||||
|
||||
import Component from '../BaseComponent';
|
||||
import Helpers from '../Helpers';
|
||||
import Icons from '../Icons';
|
||||
import localState from '../LocalState';
|
||||
import Events from '../nostr/Events';
|
||||
import Key from '../nostr/Key';
|
||||
import { translate as t } from '../translations/Translation.mjs';
|
||||
|
||||
import SafeImg from './SafeImg';
|
||||
import SearchBox from './SearchBox';
|
||||
import Torrent from './Torrent';
|
||||
|
||||
const mentionRegex = /\B@[\u00BF-\u1FFF\u2C00-\uD7FF\w]*$/;
|
||||
|
||||
interface IProps {
|
||||
replyingTo?: string;
|
||||
forceAutofocusMobile?: boolean;
|
||||
autofocus?: boolean;
|
||||
onSubmit?: (msg: any) => void;
|
||||
waitForFocus?: boolean;
|
||||
class?: string;
|
||||
index?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
attachments?: any[];
|
||||
torrentId?: string;
|
||||
mentioning?: boolean;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
class PublicMessageForm extends Component<IProps, IState> {
|
||||
newMsgRef = createRef();
|
||||
|
||||
componentDidMount() {
|
||||
const textEl = $(this.newMsgRef.current);
|
||||
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((t) => !textEl.val() && textEl.val(t));
|
||||
} else {
|
||||
const currentHistoryState = window.history.state;
|
||||
if (currentHistoryState && currentHistoryState['replyTo' + this.props.replyingTo]) {
|
||||
textEl.val(currentHistoryState['replyTo' + this.props.replyingTo]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMsgFormSubmit(event) {
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (!this.props.replyingTo) {
|
||||
localState.get('channels').get('public').get('msgDraft').put(null);
|
||||
}
|
||||
const textEl = $(this.newMsgRef.current);
|
||||
const text = textEl.val();
|
||||
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;
|
||||
}
|
||||
await this.sendNostr(msg);
|
||||
this.props.onSubmit && this.props.onSubmit(msg);
|
||||
this.setState({ attachments: undefined, torrentId: undefined });
|
||||
textEl.val('');
|
||||
textEl.height('');
|
||||
this.saveDraftToHistory();
|
||||
}
|
||||
|
||||
setTextareaHeight(textarea) {
|
||||
textarea.style.height = '';
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}
|
||||
|
||||
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 textEl = $(this.newMsgRef.current);
|
||||
const currentVal = textEl.val();
|
||||
if (currentVal) {
|
||||
textEl.val(currentVal + '\n\n' + url);
|
||||
} else {
|
||||
textEl.val(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.newMsgRef.current).val();
|
||||
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();
|
||||
}
|
||||
|
||||
attachFileClicked(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
$(this.base).find('.attachment-input').click();
|
||||
}
|
||||
|
||||
attachmentsChanged(event) {
|
||||
// TODO use Upload btn
|
||||
const files = event.target.files || event.dataTransfer.files;
|
||||
if (files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const formData = new FormData();
|
||||
formData.append('fileToUpload', files[i]);
|
||||
|
||||
const a = this.state.attachments || [];
|
||||
a[i] = a[i] || {
|
||||
type: files[i].type,
|
||||
};
|
||||
|
||||
Helpers.getBase64(files[i]).then((base64) => {
|
||||
a[i].data = base64;
|
||||
this.setState({ attachments: a });
|
||||
});
|
||||
|
||||
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 textEl = $(this.newMsgRef.current);
|
||||
const currentVal = textEl.val();
|
||||
if (currentVal) {
|
||||
textEl.val(currentVal + '\n\n' + url);
|
||||
} else {
|
||||
textEl.val(url);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('upload error', error);
|
||||
a[i].error = 'upload failed';
|
||||
this.setState({ attachments: a });
|
||||
});
|
||||
}
|
||||
$(event.target).val(null);
|
||||
$(this.newMsgRef.current).focus();
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
return html`<form
|
||||
autocomplete="off"
|
||||
class="message-form ${this.props.class || ''} public"
|
||||
onSubmit=${(e) => this.onMsgFormSubmit(e)}
|
||||
>
|
||||
<input
|
||||
name="attachment-input"
|
||||
type="file"
|
||||
class="hidden attachment-input"
|
||||
accept="image/*, video/*, audio/*"
|
||||
multiple
|
||||
onChange=${(e) => this.attachmentsChanged(e)}
|
||||
/>
|
||||
<textarea
|
||||
onDragOver=${(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop=${(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.attachmentsChanged(e);
|
||||
}}
|
||||
onKeyUp=${(e) => this.onKeyUp(e)}
|
||||
onKeyDown=${(e) => this.onKeyDown(e)}
|
||||
onPaste=${(e) => this.onMsgTextPaste(e)}
|
||||
onInput=${(e) => this.onMsgTextInput(e)}
|
||||
onFocus=${() => this.setState({ focused: true })}
|
||||
ref=${this.newMsgRef}
|
||||
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(textareaPlaceholder)}"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="sentences"
|
||||
spellcheck="off"
|
||||
/>
|
||||
${this.state.mentioning
|
||||
? html`
|
||||
<${SearchBox}
|
||||
resultsOnly=${true}
|
||||
query=${this.state.mentioning}
|
||||
onSelect=${(item) => this.onSelectMention(item)}
|
||||
/>
|
||||
`
|
||||
: ''}
|
||||
${this.props.waitForFocus && !this.state.focused
|
||||
? ''
|
||||
: html`
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="attach-file-btn btn"
|
||||
onClick=${(e) => this.attachFileClicked(e)}
|
||||
>
|
||||
${Icons.attach}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">${t('post')}</button>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="attachment-preview">
|
||||
${this.state.torrentId
|
||||
? html` <${Torrent} preview=${true} torrentId=${this.state.torrentId} /> `
|
||||
: ''}
|
||||
${this.state.attachments && this.state.attachments.length
|
||||
? html`
|
||||
<p>
|
||||
<a
|
||||
href=""
|
||||
onClick=${(e) => {
|
||||
e.preventDefault();
|
||||
this.setState({ attachments: undefined });
|
||||
}}
|
||||
>${t('remove_attachment')}</a
|
||||
>
|
||||
</p>
|
||||
`
|
||||
: ''}
|
||||
${this.state.attachments &&
|
||||
this.state.attachments.map((a) => {
|
||||
const status = html` ${a.error
|
||||
? html`<span class="error">${a.error}</span>`
|
||||
: a.url || 'uploading...'}`;
|
||||
|
||||
// if a.url matches audio regex
|
||||
if (a.type?.startsWith('audio')) {
|
||||
return html`
|
||||
${status}
|
||||
<audio controls>
|
||||
<source src=${a.data} />
|
||||
</audio>
|
||||
`;
|
||||
}
|
||||
// if a.url matches video regex
|
||||
if (a.type?.startsWith('video')) {
|
||||
return html`
|
||||
${status}
|
||||
<video controls loop=${true} autoplay=${true} muted=${true}>
|
||||
<source src=${a.data} />
|
||||
</video>
|
||||
`;
|
||||
}
|
||||
|
||||
// image regex
|
||||
if (a.type?.startsWith('image')) {
|
||||
return html`${status} <${SafeImg} src=${a.data} /> `;
|
||||
}
|
||||
|
||||
return 'unknown attachment type';
|
||||
})}
|
||||
</div>
|
||||
</form>`;
|
||||
}
|
||||
|
||||
async sendNostr(msg: { text: string; replyingTo?: string }) {
|
||||
const event = {
|
||||
kind: 1,
|
||||
content: msg.text,
|
||||
} as any;
|
||||
|
||||
if (msg.replyingTo) {
|
||||
const id = Key.toNostrHexAddress(msg.replyingTo);
|
||||
if (!id) {
|
||||
throw new Error('invalid replyingTo');
|
||||
}
|
||||
const replyingTo: Event = await new Promise((resolve) => {
|
||||
Events.getEventById(id, true, (e) => resolve(e));
|
||||
});
|
||||
event.tags = replyingTo.tags.filter((tag) => tag[0] === 'p');
|
||||
let rootTag = replyingTo.tags?.find((t) => t[0] === 'e' && t[3] === 'root');
|
||||
if (!rootTag) {
|
||||
rootTag = replyingTo.tags?.find((t) => t[0] === 'e');
|
||||
}
|
||||
if (rootTag) {
|
||||
event.tags.unshift(['e', id, '', 'reply']);
|
||||
event.tags.unshift(['e', rootTag[1], '', 'root']);
|
||||
} else {
|
||||
event.tags.unshift(['e', id, '', 'root']);
|
||||
}
|
||||
if (!event.tags?.find((t) => t[0] === 'p' && t[1] === replyingTo.pubkey)) {
|
||||
event.tags.push(['p', replyingTo.pubkey]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTagged(regex, tagType) {
|
||||
const taggedItems = [...msg.text.matchAll(regex)]
|
||||
.map((m) => m[0])
|
||||
.filter((m, i, a) => a.indexOf(m) === i);
|
||||
|
||||
if (taggedItems) {
|
||||
event.tags = event.tags || [];
|
||||
for (const tag of taggedItems) {
|
||||
const match = tag.match(/npub[a-zA-Z0-9]{59,60}/)?.[0];
|
||||
const hexTag = match && Key.toNostrHexAddress(match);
|
||||
if (!hexTag) {
|
||||
continue;
|
||||
}
|
||||
const newTag = [tagType, hexTag, '', 'mention'];
|
||||
// add if not already present
|
||||
if (!event.tags?.find((t) => t[0] === newTag[0] && t[1] === newTag[1])) {
|
||||
event.tags.push(newTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTagged(Helpers.pubKeyRegex, 'p');
|
||||
handleTagged(Helpers.noteRegex, 'e');
|
||||
|
||||
const hashtags = [...msg.text.matchAll(Helpers.hashtagRegex)].map((m) => m[0].slice(1));
|
||||
if (hashtags.length) {
|
||||
event.tags = event.tags || [];
|
||||
for (const hashtag of hashtags) {
|
||||
if (!event.tags?.find((t) => t[0] === 't' && t[1] === hashtag)) {
|
||||
event.tags.push(['t', hashtag]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('sending event', event);
|
||||
return Events.publish(event);
|
||||
}
|
||||
|
||||
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 PublicMessageForm;
|
63
src/js/components/create/AttachmentPreview.tsx
Normal file
63
src/js/components/create/AttachmentPreview.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import Show from '@/components/helpers/Show';
|
||||
import SafeImg from '@/components/SafeImg';
|
||||
import Torrent from '@/components/Torrent';
|
||||
|
||||
const AttachmentPreview = ({ attachments, torrentId, removeAttachments }) => {
|
||||
return (
|
||||
<>
|
||||
<Show when={torrentId}>
|
||||
<Torrent preview={true} torrentId={torrentId} />
|
||||
</Show>
|
||||
<Show when={attachments && attachments.length}>
|
||||
<p>
|
||||
<a
|
||||
href=""
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
removeAttachments();
|
||||
}}
|
||||
>
|
||||
Remove Attachment
|
||||
</a>
|
||||
</p>
|
||||
</Show>
|
||||
{attachments &&
|
||||
attachments.map((a) => {
|
||||
const status = a.error ? <span class="error">{a.error}</span> : a.url || 'uploading...';
|
||||
|
||||
if (a.type?.startsWith('audio')) {
|
||||
return (
|
||||
<>
|
||||
{status}
|
||||
<audio controls>
|
||||
<source src={a.data} />
|
||||
</audio>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (a.type?.startsWith('video')) {
|
||||
return (
|
||||
<>
|
||||
{status}
|
||||
<video controls loop={true} autoPlay={true} muted={true}>
|
||||
<source src={a.data} />
|
||||
</video>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (a.type?.startsWith('image')) {
|
||||
return (
|
||||
<>
|
||||
{status}
|
||||
<SafeImg src={a.data} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return 'unknown attachment type';
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentPreview;
|
279
src/js/components/create/CreateNoteForm.tsx
Normal file
279
src/js/components/create/CreateNoteForm.tsx
Normal file
@ -0,0 +1,279 @@
|
||||
import $ from 'jquery';
|
||||
|
||||
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 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]*$/;
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMsgFormSubmit(event) {
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (!this.props.replyingTo) {
|
||||
localState.get('channels').get('public').get('msgDraft').put(null);
|
||||
}
|
||||
const text = this.state.text;
|
||||
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;
|
||||
}
|
||||
await sendNostr(msg);
|
||||
this.props.onSubmit && this.props.onSubmit(msg);
|
||||
this.setState({ attachments: undefined, torrentId: undefined, text: '' });
|
||||
$(this.base).find('textarea').height('');
|
||||
this.saveDraftToHistory();
|
||||
}
|
||||
|
||||
setTextareaHeight(textarea) {
|
||||
textarea.style.height = '';
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}
|
||||
|
||||
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) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
$(this.base).find('.attachment-input').click();
|
||||
}
|
||||
|
||||
handleFileAttachments(files) {
|
||||
if (files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const formData = new FormData();
|
||||
formData.append('fileToUpload', files[i]);
|
||||
|
||||
const a = this.state.attachments || [];
|
||||
a[i] = a[i] || {
|
||||
type: files[i].type,
|
||||
};
|
||||
|
||||
Helpers.getBase64(files[i]).then((base64) => {
|
||||
a[i].data = base64;
|
||||
this.setState({ attachments: a });
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('upload error', error);
|
||||
a[i].error = 'upload failed';
|
||||
this.setState({ attachments: a });
|
||||
});
|
||||
}
|
||||
$(this.base).find('textarea').focus();
|
||||
}
|
||||
}
|
||||
|
||||
attachmentsChanged(event) {
|
||||
// TODO use Upload btn
|
||||
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');
|
||||
|
||||
return (
|
||||
<form
|
||||
autoComplete="off"
|
||||
className={`message-form ${this.props.class || ''} public`}
|
||||
onSubmit={(e) => this.onMsgFormSubmit(e)}
|
||||
>
|
||||
<FileAttachment onFilesChanged={(files) => this.handleFileAttachments(files)} />
|
||||
<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}
|
||||
/>
|
||||
<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)}>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="attach-file-btn btn"
|
||||
onClick={(e) => this.attachFileClicked(e)}
|
||||
>
|
||||
{Icons.attach}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t('post')}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<AttachmentPreview
|
||||
attachments={this.state.attachments}
|
||||
torrentId={this.state.torrentId}
|
||||
removeAttachments={() => this.setState({ attachments: undefined })}
|
||||
/>
|
||||
</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;
|
31
src/js/components/create/FileAttachment.tsx
Normal file
31
src/js/components/create/FileAttachment.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
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;
|
43
src/js/components/create/TextArea.tsx
Normal file
43
src/js/components/create/TextArea.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { translate as t } from '@/translations/Translation.mjs';
|
||||
|
||||
interface TextAreaProps {
|
||||
onMsgTextPaste: (event: any) => void;
|
||||
onKeyUp: (e: any) => void;
|
||||
onKeyDown: (e: any) => void;
|
||||
onMsgTextInput: (event: any) => void;
|
||||
attachmentsChanged: (e: any) => void;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const TextArea = ({
|
||||
onMsgTextPaste,
|
||||
onKeyUp,
|
||||
onKeyDown,
|
||||
onMsgTextInput,
|
||||
attachmentsChanged,
|
||||
placeholder,
|
||||
value,
|
||||
}: TextAreaProps) => (
|
||||
<textarea
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={attachmentsChanged}
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onMsgTextPaste}
|
||||
onInput={onMsgTextInput}
|
||||
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)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="sentences"
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
|
||||
export default TextArea;
|
18
src/js/components/create/types.ts
Normal file
18
src/js/components/create/types.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export interface IProps {
|
||||
replyingTo?: string;
|
||||
forceAutofocusMobile?: boolean;
|
||||
autofocus?: boolean;
|
||||
onSubmit?: (msg: any) => void;
|
||||
waitForFocus?: boolean;
|
||||
class?: string;
|
||||
index?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
attachments?: any[];
|
||||
torrentId?: string;
|
||||
mentioning?: string;
|
||||
focused?: boolean;
|
||||
text: string;
|
||||
}
|
79
src/js/components/create/util.ts
Normal file
79
src/js/components/create/util.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { Event } from 'nostr-tools';
|
||||
|
||||
import Helpers from '@/Helpers';
|
||||
|
||||
import Events from '../../nostr/Events.js';
|
||||
import Key from '../../nostr/Key.js';
|
||||
|
||||
function handleTags(tags = [] as any[], text) {
|
||||
function handleTagged(regex, tagType) {
|
||||
const taggedItems = [...text.matchAll(regex)]
|
||||
.map((m) => m[0])
|
||||
.filter((m, i, a) => a.indexOf(m) === i);
|
||||
|
||||
if (taggedItems) {
|
||||
for (const tag of taggedItems) {
|
||||
const match = tag.match(/npub[a-zA-Z0-9]{59,60}/)?.[0];
|
||||
const hexTag = match && Key.toNostrHexAddress(match);
|
||||
if (!hexTag) {
|
||||
continue;
|
||||
}
|
||||
const newTag = [tagType, hexTag, '', 'mention'];
|
||||
// add if not already present
|
||||
if (!tags?.find((t) => t[0] === newTag[0] && t[1] === newTag[1])) {
|
||||
tags.push(newTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTagged(Helpers.pubKeyRegex, 'p');
|
||||
handleTagged(Helpers.noteRegex, 'e');
|
||||
|
||||
const hashtags = [...text.matchAll(Helpers.hashtagRegex)].map((m) => m[0].slice(1));
|
||||
if (hashtags.length) {
|
||||
for (const hashtag of hashtags) {
|
||||
if (!tags?.find((t) => t[0] === 't' && t[1] === hashtag)) {
|
||||
tags.push(['t', hashtag]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
export async function sendNostr(msg: { text: string; replyingTo?: string }) {
|
||||
const event = {
|
||||
kind: 1,
|
||||
content: msg.text,
|
||||
} as any;
|
||||
|
||||
if (msg.replyingTo) {
|
||||
const id = Key.toNostrHexAddress(msg.replyingTo);
|
||||
if (!id) {
|
||||
throw new Error('invalid replyingTo');
|
||||
}
|
||||
const replyingTo: Event = await new Promise((resolve) => {
|
||||
Events.getEventById(id, true, (e) => resolve(e));
|
||||
});
|
||||
event.tags = replyingTo.tags.filter((tag) => tag[0] === 'p');
|
||||
let rootTag = replyingTo.tags?.find((t) => t[0] === 'e' && t[3] === 'root');
|
||||
if (!rootTag) {
|
||||
rootTag = replyingTo.tags?.find((t) => t[0] === 'e');
|
||||
}
|
||||
if (rootTag) {
|
||||
event.tags.unshift(['e', id, '', 'reply']);
|
||||
event.tags.unshift(['e', rootTag[1], '', 'root']);
|
||||
} else {
|
||||
event.tags.unshift(['e', id, '', 'root']);
|
||||
}
|
||||
if (!event.tags?.find((t) => t[0] === 'p' && t[1] === replyingTo.pubkey)) {
|
||||
event.tags.push(['p', replyingTo.pubkey]);
|
||||
}
|
||||
}
|
||||
|
||||
event.tags = handleTags(event.tags, msg.text);
|
||||
|
||||
console.log('sending event', event);
|
||||
return Events.publish(event);
|
||||
}
|
@ -1,15 +1,15 @@
|
||||
import { memo } from 'react';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import Helpers from '../../../Helpers';
|
||||
import localState from '../../../LocalState';
|
||||
import SocialNetwork from '../../../nostr/SocialNetwork';
|
||||
import { translate as t } from '../../../translations/Translation.mjs';
|
||||
import Show from '../../helpers/Show';
|
||||
import HyperText from '../../HyperText';
|
||||
import PublicMessageForm from '../../PublicMessageForm';
|
||||
import Torrent from '../../Torrent';
|
||||
import Reactions from '../buttons/ReactionButtons';
|
||||
import CreateNoteForm from '@/components/create/CreateNoteForm';
|
||||
import Reactions from '@/components/events/buttons/ReactionButtons';
|
||||
import Show from '@/components/helpers/Show';
|
||||
import HyperText from '@/components/HyperText';
|
||||
import Torrent from '@/components/Torrent';
|
||||
import Helpers from '@/Helpers';
|
||||
import localState from '@/LocalState';
|
||||
import SocialNetwork from '@/nostr/SocialNetwork';
|
||||
import { translate as t } from '@/translations/Translation.mjs';
|
||||
|
||||
import Author from './Author';
|
||||
import Helmet from './Helmet';
|
||||
@ -134,7 +134,7 @@ const Content = ({ standalone, isQuote, fullWidth, asInlineQuote, event, meta })
|
||||
</Show>
|
||||
<Show when={standalone}>
|
||||
<hr className="-mx-2 opacity-10 my-2" />
|
||||
<PublicMessageForm
|
||||
<CreateNoteForm
|
||||
waitForFocus={true}
|
||||
autofocus={!standalone}
|
||||
replyingTo={event.id}
|
||||
|
@ -258,6 +258,7 @@ export default {
|
||||
getFollowedByUser: function (
|
||||
user: string,
|
||||
cb?: (followedUsers: Set<string>) => void,
|
||||
includeSelf = false,
|
||||
): Unsubscribe {
|
||||
const userId = ID(user);
|
||||
const callback = () => {
|
||||
@ -266,10 +267,15 @@ export default {
|
||||
for (const id of this.followedByUser.get(userId) || []) {
|
||||
set.add(PUB(id));
|
||||
}
|
||||
if (includeSelf) {
|
||||
set.add(user);
|
||||
}
|
||||
cb(set);
|
||||
}
|
||||
};
|
||||
this.followedByUser.has(userId) && callback();
|
||||
if (this.followedByUser.has(userId) || includeSelf) {
|
||||
callback();
|
||||
}
|
||||
return PubSub.subscribe({ kinds: [3], authors: [user] }, callback);
|
||||
},
|
||||
getFollowersByUser: function (
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { route } from 'preact-router';
|
||||
|
||||
import CreateNoteForm from '../components/create/CreateNoteForm';
|
||||
import EventComponent from '../components/events/EventComponent';
|
||||
import PublicMessageForm from '../components/PublicMessageForm';
|
||||
import Key from '../nostr/Key';
|
||||
import { translate as t } from '../translations/Translation.mjs';
|
||||
|
||||
@ -33,7 +33,7 @@ class Note extends View {
|
||||
if (this.props.id === 'new') {
|
||||
content = (
|
||||
<div className="m-2">
|
||||
<PublicMessageForm
|
||||
<CreateNoteForm
|
||||
placeholder={t('whats_on_your_mind')}
|
||||
forceAutofocusMobile={true}
|
||||
autofocus={true}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import CreateNoteForm from '@/components/create/CreateNoteForm';
|
||||
import FeedComponent from '@/components/feed/Feed';
|
||||
import OnboardingNotification from '@/components/OnboardingNotification';
|
||||
import PublicMessageForm from '@/components/PublicMessageForm';
|
||||
import Events from '@/nostr/Events';
|
||||
import { translate as t } from '@/translations/Translation.mjs';
|
||||
|
||||
@ -24,7 +24,7 @@ class Feed extends View {
|
||||
<div className="flex flex-col w-full">
|
||||
<OnboardingNotification />
|
||||
<div className="hidden md:block px-4">
|
||||
<PublicMessageForm autofocus={false} placeholder={t('whats_on_your_mind')} />
|
||||
<CreateNoteForm autofocus={false} placeholder={t('whats_on_your_mind')} />
|
||||
</div>
|
||||
<FeedComponent
|
||||
filterOptions={[
|
||||
@ -36,7 +36,7 @@ class Feed extends View {
|
||||
},
|
||||
{
|
||||
name: t('posts_and_replies'),
|
||||
filter: { kinds: [1], authors: this.state.followedUsers },
|
||||
filter: { kinds: [1] },
|
||||
eventProps: { showRepliedMsg: true, fullWidth: false },
|
||||
},
|
||||
]}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import CreateNoteForm from '@/components/create/CreateNoteForm';
|
||||
import FeedComponent from '@/components/feed/Feed';
|
||||
import Show from '@/components/helpers/Show';
|
||||
import OnboardingNotification from '@/components/OnboardingNotification';
|
||||
import PublicMessageForm from '@/components/PublicMessageForm';
|
||||
import Events from '@/nostr/Events';
|
||||
import Key from '@/nostr/Key';
|
||||
import { Unsubscribe } from '@/nostr/PubSub';
|
||||
@ -28,9 +28,14 @@ class Feed extends View {
|
||||
|
||||
componentDidMount() {
|
||||
this.restoreScrollPosition();
|
||||
this.unsub = SocialNetwork.getFollowedByUser(Key.getPubKey(), (followedUsers) => {
|
||||
this.setState({ followedUsers: Array.from(followedUsers) });
|
||||
});
|
||||
this.unsub = SocialNetwork.getFollowedByUser(
|
||||
Key.getPubKey(),
|
||||
(followedUsers) => {
|
||||
console.log('followedUsers', followedUsers);
|
||||
this.setState({ followedUsers: Array.from(followedUsers) });
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@ -44,7 +49,7 @@ class Feed extends View {
|
||||
<div className="flex flex-col w-full">
|
||||
<OnboardingNotification />
|
||||
<div className="hidden md:block px-4">
|
||||
<PublicMessageForm autofocus={false} placeholder={t('whats_on_your_mind')} />
|
||||
<CreateNoteForm autofocus={false} placeholder={t('whats_on_your_mind')} />
|
||||
</div>
|
||||
<Show when={this.state.followedUsers.length}>
|
||||
<FeedComponent
|
||||
|
Loading…
Reference in New Issue
Block a user