snort/packages/app/src/Element/NoteCreator.tsx

440 lines
13 KiB
TypeScript
Raw Normal View History

2023-01-16 17:48:25 +00:00
import "./NoteCreator.css";
2023-03-27 22:58:29 +00:00
import { FormattedMessage, useIntl } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
import { encodeTLV, EventKind, NostrPrefix, TaggedRawEvent } from "@snort/nostr";
2023-02-08 21:10:26 +00:00
2023-03-02 17:47:02 +00:00
import Icon from "Icons/Icon";
2023-01-20 11:11:50 +00:00
import useEventPublisher from "Feed/EventPublisher";
import { openFile } from "Util";
import Textarea from "Element/Textarea";
2023-01-25 18:08:53 +00:00
import Modal from "Element/Modal";
2023-02-01 22:14:30 +00:00
import ProfileImage from "Element/ProfileImage";
2023-01-31 19:08:11 +00:00
import useFileUpload from "Upload";
2023-03-31 22:43:07 +00:00
import Note from "Element/Note";
import {
setShow,
setNote,
setError,
setActive,
setPreview,
setShowAdvanced,
2023-05-04 13:16:58 +00:00
setSelectedCustomRelays,
setZapForward,
setSensitive,
reset,
2023-04-10 14:55:25 +00:00
setPollOptions,
setOtherEvents,
} from "State/NoteCreator";
import type { RootState } from "State/Store";
2023-03-27 22:58:29 +00:00
import { LNURL } from "LNURL";
2023-01-16 17:48:25 +00:00
2023-02-08 21:10:26 +00:00
import messages from "./messages";
2023-04-12 11:17:59 +00:00
import { ClipboardEventHandler, useState } from "react";
import Spinner from "Icons/Spinner";
2023-04-14 15:02:15 +00:00
import { EventBuilder } from "System";
2023-04-19 12:10:41 +00:00
import { Menu, MenuItem } from "@szhsin/react-menu";
import { LoginStore } from "Login";
import { getCurrentSubscription } from "Subscription";
2023-05-04 13:16:58 +00:00
import useLogin from "Hooks/useLogin";
2023-02-08 21:10:26 +00:00
2023-02-01 22:14:30 +00:00
interface NotePreviewProps {
2023-03-28 14:34:01 +00:00
note: TaggedRawEvent;
2023-02-01 22:14:30 +00:00
}
function NotePreview({ note }: NotePreviewProps) {
return (
<div className="note-preview">
2023-03-28 14:34:01 +00:00
<ProfileImage pubkey={note.pubkey} />
2023-02-01 22:14:30 +00:00
<div className="note-preview-body">
2023-03-28 14:34:01 +00:00
{note.content.slice(0, 136)}
{note.content.length > 140 && "..."}
2023-02-01 22:14:30 +00:00
</div>
</div>
);
2023-02-01 22:14:30 +00:00
}
export function NoteCreator() {
2023-03-27 22:58:29 +00:00
const { formatMessage } = useIntl();
2023-02-05 12:32:34 +00:00
const publisher = useEventPublisher();
const uploader = useFileUpload();
2023-05-04 13:16:58 +00:00
const {
note,
zapForward,
sensitive,
pollOptions,
replyTo,
otherEvents,
preview,
active,
show,
showAdvanced,
selectedCustomRelays,
error,
} = useSelector((s: RootState) => s.noteCreator);
2023-04-12 11:17:59 +00:00
const [uploadInProgress, setUploadInProgress] = useState(false);
const dispatch = useDispatch();
2023-04-19 12:10:41 +00:00
const sub = getCurrentSubscription(LoginStore.allSubscriptions());
2023-05-04 13:16:58 +00:00
const login = useLogin();
const relays = login.relays;
2023-01-16 17:48:25 +00:00
2023-02-05 12:32:34 +00:00
async function sendNote() {
2023-04-14 15:02:15 +00:00
if (note && publisher) {
2023-03-27 22:58:29 +00:00
let extraTags: Array<Array<string>> | undefined;
if (zapForward) {
try {
const svc = new LNURL(zapForward);
await svc.load();
2023-04-05 10:58:26 +00:00
extraTags = [svc.getZapTag()];
2023-03-27 22:58:29 +00:00
} catch {
dispatch(
setError(
formatMessage({
defaultMessage: "Invalid LNURL",
})
)
2023-03-27 22:58:29 +00:00
);
return;
}
}
2023-04-06 22:12:51 +00:00
if (sensitive) {
extraTags ??= [];
extraTags.push(["content-warning", sensitive]);
}
2023-04-10 14:55:25 +00:00
const kind = pollOptions ? EventKind.Polls : EventKind.TextNote;
if (pollOptions) {
extraTags ??= [];
extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
}
2023-04-14 15:02:15 +00:00
const hk = (eb: EventBuilder) => {
extraTags?.forEach(t => eb.tag(t));
eb.kind(kind);
return eb;
};
const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk);
2023-05-04 13:16:58 +00:00
if (selectedCustomRelays) publisher.broadcastAll(ev, selectedCustomRelays);
else publisher.broadcast(ev);
dispatch(reset());
for (const oe of otherEvents) {
2023-05-04 13:16:58 +00:00
if (selectedCustomRelays) publisher.broadcastAll(oe, selectedCustomRelays);
else publisher.broadcast(oe);
}
dispatch(reset());
2023-01-16 17:48:25 +00:00
}
2023-02-05 12:32:34 +00:00
}
2023-01-16 17:48:25 +00:00
2023-02-05 12:32:34 +00:00
async function attachFile() {
try {
2023-02-07 19:47:57 +00:00
const file = await openFile();
2023-04-12 11:17:59 +00:00
if (file) {
uploadFile(file);
}
} catch (error: unknown) {
if (error instanceof Error) {
dispatch(setError(error?.message));
}
}
}
2023-04-13 14:08:00 +00:00
async function uploadFile(file: File | Blob) {
2023-04-12 11:17:59 +00:00
setUploadInProgress(true);
try {
2023-02-05 12:32:34 +00:00
if (file) {
2023-02-07 19:47:57 +00:00
const rx = await uploader.upload(file, file.name);
if (rx.header) {
const link = `nostr:${encodeTLV(rx.header.id, NostrPrefix.Event, undefined, rx.header.kind)}`;
dispatch(setNote(`${note ? `${note}\n` : ""}${link}`));
dispatch(setOtherEvents([...otherEvents, rx.header]));
} else if (rx.url) {
dispatch(setNote(`${note ? `${note}\n` : ""}${rx.url}`));
2023-02-05 12:32:34 +00:00
} else if (rx?.error) {
dispatch(setError(rx.error));
2023-01-16 17:48:25 +00:00
}
2023-02-05 12:32:34 +00:00
}
2023-02-07 19:47:57 +00:00
} catch (error: unknown) {
if (error instanceof Error) {
dispatch(setError(error?.message));
2023-02-07 19:47:57 +00:00
}
2023-04-13 14:08:00 +00:00
} finally {
2023-04-12 11:17:59 +00:00
setUploadInProgress(false);
2023-01-16 17:48:25 +00:00
}
2023-02-05 12:32:34 +00:00
}
2023-01-16 17:48:25 +00:00
2023-02-07 19:47:57 +00:00
function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) {
const { value } = ev.target;
dispatch(setNote(value));
2023-02-05 12:32:34 +00:00
if (value) {
dispatch(setActive(true));
2023-02-05 12:32:34 +00:00
} else {
dispatch(setActive(false));
2023-01-16 17:48:25 +00:00
}
2023-02-05 12:32:34 +00:00
}
2023-01-16 17:48:25 +00:00
2023-02-07 19:47:57 +00:00
function cancel() {
dispatch(reset());
2023-02-05 12:32:34 +00:00
}
2023-01-25 18:08:53 +00:00
2023-02-05 12:32:34 +00:00
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
ev.stopPropagation();
sendNote().catch(console.warn);
}
2023-01-16 17:48:25 +00:00
2023-03-31 22:43:07 +00:00
async function loadPreview() {
if (preview) {
2023-04-10 14:55:25 +00:00
dispatch(setPreview(undefined));
2023-04-14 15:02:15 +00:00
} else if (publisher) {
2023-03-31 22:43:07 +00:00
const tmpNote = await publisher.note(note);
if (tmpNote) {
dispatch(setPreview(tmpNote));
2023-03-31 22:43:07 +00:00
}
}
}
function getPreviewNote() {
if (preview) {
return (
<Note
data={preview as TaggedRawEvent}
related={[]}
options={{
showFooter: false,
canClick: false,
}}
/>
);
}
}
2023-04-10 14:55:25 +00:00
function renderPollOptions() {
if (pollOptions) {
return (
<>
<h4>
<FormattedMessage defaultMessage="Poll Options" />
</h4>
{pollOptions?.map((a, i) => (
<div className="form-group w-max" key={`po-${i}`}>
<div>
<FormattedMessage defaultMessage="Option: {n}" values={{ n: i + 1 }} />
</div>
<div>
<input type="text" value={a} onChange={e => changePollOption(i, e.target.value)} />
{i > 1 && (
<button onClick={() => removePollOption(i)} className="ml5">
<Icon name="close" size={14} />
</button>
)}
</div>
</div>
))}
<button onClick={() => dispatch(setPollOptions([...pollOptions, ""]))}>
<Icon name="plus" size={14} />
</button>
</>
);
}
}
function changePollOption(i: number, v: string) {
if (pollOptions) {
const copy = [...pollOptions];
copy[i] = v;
dispatch(setPollOptions(copy));
}
}
function removePollOption(i: number) {
if (pollOptions) {
const copy = [...pollOptions];
copy.splice(i, 1);
dispatch(setPollOptions(copy));
}
}
2023-05-04 13:16:58 +00:00
function renderRelayCustomisation() {
return (
<div>
{Object.keys(relays.item || {})
.filter(el => relays.item[el].write)
.map((r, i, a) => (
<div className="card flex">
<div className="flex f-col f-grow">
<div>{r}</div>
</div>
<div>
<input
type="checkbox"
checked={!selectedCustomRelays || selectedCustomRelays.includes(r)}
onChange={e =>
dispatch(
setSelectedCustomRelays(
// set false if all relays selected
e.target.checked && selectedCustomRelays && selectedCustomRelays.length == a.length - 1
? false
: // otherwise return selectedCustomRelays with target relay added / removed
a.filter(el =>
el === r ? e.target.checked : !selectedCustomRelays || selectedCustomRelays.includes(el)
)
)
)
}
/>
</div>
</div>
))}
</div>
);
}
2023-04-19 12:10:41 +00:00
function listAccounts() {
return LoginStore.getSessions().map(a => (
<MenuItem
onClick={ev => {
ev.stopPropagation = true;
LoginStore.switchAccount(a);
}}>
2023-05-10 12:29:07 +00:00
<ProfileImage pubkey={a} link={""} />
2023-04-19 12:10:41 +00:00
</MenuItem>
));
}
2023-04-12 11:17:59 +00:00
const handlePaste: ClipboardEventHandler<HTMLDivElement> = evt => {
if (evt.clipboardData) {
const clipboardItems = evt.clipboardData.items;
const items: DataTransferItem[] = Array.from(clipboardItems).filter(function (item: DataTransferItem) {
// Filter the image items only
return /^image\//.test(item.type);
});
if (items.length === 0) {
return;
}
const item = items[0];
const blob = item.getAsFile();
if (blob) {
2023-04-13 14:08:00 +00:00
uploadFile(blob);
2023-04-12 11:17:59 +00:00
}
}
};
2023-02-05 12:32:34 +00:00
return (
<>
{show && (
<Modal className="note-creator-modal" onClose={() => dispatch(setShow(false))}>
{replyTo && <NotePreview note={replyTo} />}
2023-03-31 22:43:07 +00:00
{preview && getPreviewNote()}
{!preview && (
2023-04-12 11:17:59 +00:00
<div
onPaste={handlePaste}
className={`flex note-creator${replyTo ? " note-reply" : ""}${pollOptions ? " poll" : ""}`}>
2023-04-10 14:55:25 +00:00
<div className="flex f-col f-grow">
2023-03-31 22:43:07 +00:00
<Textarea
autoFocus
2023-03-31 22:43:07 +00:00
className={`textarea ${active ? "textarea--focused" : ""}`}
onChange={onChange}
value={note}
onFocus={() => dispatch(setActive(true))}
2023-04-10 09:59:38 +00:00
onKeyDown={e => {
if (e.key === "Enter" && e.metaKey) {
sendNote().catch(console.warn);
}
}}
2023-03-31 22:43:07 +00:00
/>
2023-04-10 14:55:25 +00:00
{renderPollOptions()}
<div className="insert">
2023-04-19 12:10:41 +00:00
{sub && (
<Menu
menuButton={
<button>
<Icon name="code-circle" />
</button>
}
menuClassName="ctx-menu">
{listAccounts()}
</Menu>
)}
2023-04-10 14:55:25 +00:00
{pollOptions === undefined && !replyTo && (
2023-04-19 12:10:41 +00:00
<button onClick={() => dispatch(setPollOptions(["A", "B"]))}>
2023-04-10 14:55:25 +00:00
<Icon name="pie-chart" />
</button>
)}
2023-04-19 12:10:41 +00:00
<button onClick={attachFile}>
2023-04-10 14:55:25 +00:00
<Icon name="attachment" />
</button>
</div>
2023-03-31 22:43:07 +00:00
</div>
{error && <span className="error">{error}</span>}
2023-01-16 17:48:25 +00:00
</div>
2023-03-31 22:43:07 +00:00
)}
2023-02-05 12:32:34 +00:00
<div className="note-creator-actions">
2023-04-12 11:17:59 +00:00
{uploadInProgress && <Spinner />}
2023-04-19 12:10:41 +00:00
<button className="secondary" onClick={() => dispatch(setShowAdvanced(!showAdvanced))}>
2023-03-27 22:58:29 +00:00
<FormattedMessage defaultMessage="Advanced" />
</button>
2023-04-19 12:10:41 +00:00
<button className="secondary" onClick={cancel}>
2023-02-08 21:10:26 +00:00
<FormattedMessage {...messages.Cancel} />
2023-02-05 12:32:34 +00:00
</button>
2023-04-19 12:10:41 +00:00
<button onClick={onSubmit}>
2023-02-09 12:26:54 +00:00
{replyTo ? <FormattedMessage {...messages.Reply} /> : <FormattedMessage {...messages.Send} />}
2023-02-05 12:32:34 +00:00
</button>
</div>
2023-03-27 22:58:29 +00:00
{showAdvanced && (
2023-04-05 11:06:07 +00:00
<div>
2023-04-19 12:10:41 +00:00
<button className="secondary" onClick={loadPreview}>
2023-04-05 11:06:07 +00:00
<FormattedMessage defaultMessage="Toggle Preview" />
</button>
2023-05-04 13:16:58 +00:00
<h4>
<FormattedMessage defaultMessage="Custom Relays" />
</h4>
<p>
<FormattedMessage defaultMessage="Send note to a subset of your write relays" />
</p>
{renderRelayCustomisation()}
2023-03-27 22:58:29 +00:00
<h4>
<FormattedMessage defaultMessage="Forward Zaps" />
</h4>
<p>
<FormattedMessage defaultMessage="All zaps sent to this note will be received by the following LNURL" />
</p>
2023-04-06 22:12:51 +00:00
<b className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
</b>
2023-03-27 22:58:29 +00:00
<input
type="text"
className="w-max"
placeholder={formatMessage({
defaultMessage: "LNURL to forward zaps to",
})}
value={zapForward}
onChange={e => dispatch(setZapForward(e.target.value))}
2023-03-27 22:58:29 +00:00
/>
2023-04-06 22:12:51 +00:00
<h4>
<FormattedMessage defaultMessage="Sensitive Content" />
</h4>
<p>
<FormattedMessage defaultMessage="Users must accept the content warning to show the content of your note." />
</p>
<b className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
</b>
<div className="flex">
<input
className="w-max"
type="text"
value={sensitive}
onChange={e => dispatch(setSensitive(e.target.value))}
2023-04-06 22:12:51 +00:00
maxLength={50}
minLength={1}
placeholder={formatMessage({
defaultMessage: "Reason",
})}
/>
</div>
2023-04-05 11:06:07 +00:00
</div>
2023-03-27 22:58:29 +00:00
)}
2023-02-05 12:32:34 +00:00
</Modal>
)}
</>
);
2023-01-18 23:31:34 +00:00
}