/** @jsx h */ import { createRef, h } from "preact"; import { emitFunc } from "../event-bus.ts"; import { ImageIcon } from "./icons/image-icon.tsx"; import { SendIcon } from "./icons/send-icon.tsx"; import { Component } from "preact"; import { RemoveIcon } from "./icons/remove-icon.tsx"; import { setState } from "./_helper.ts"; import { XCircleIcon } from "./icons/x-circle-icon.tsx"; import { func_GetProfileByPublicKey, func_GetProfilesByText } from "./search.tsx"; import { NoteID } from "@blowater/nostr-sdk"; import { EventSubscriber } from "../event-bus.ts"; import { UI_Interaction_Event } from "./app_update.tsx"; import { Parsed_Event } from "../nostr.ts"; import { Profile_Nostr_Event } from "../nostr.ts"; import { Avatar } from "./components/avatar.tsx"; import { UploadFileResponse } from "@blowater/nostr-sdk"; import { robohash } from "@blowater/nostr-sdk"; export type EditorEvent = SendMessage | UploadImage | EditorSelectProfile; export type SendMessage = { readonly type: "SendMessage"; readonly text: string; readonly files: Blob[]; readonly reply_to_event_id?: string | NoteID; }; export type UploadImage = { readonly type: "UploadImage"; readonly file: File; readonly callback: (uploaded: UploadFileResponse | Error) => void; }; export type EditorSelectProfile = { readonly type: "EditorSelectProfile"; readonly member: Profile_Nostr_Event; }; type EditorProps = { readonly placeholder: string; readonly maxHeight: string; readonly emit: emitFunc; readonly sub: EventSubscriber; readonly getters: { getProfileByPublicKey: func_GetProfileByPublicKey; getProfilesByText: func_GetProfilesByText; }; readonly nip96?: boolean; }; export type EditorState = { text: string; files: Blob[]; replyTo?: Parsed_Event; matching?: string; searchResults: Profile_Nostr_Event[]; }; export class Editor extends Component { state: Readonly = { text: "", files: [], searchResults: [], }; textareaElement = createRef(); async componentDidMount() { for await (const event of this.props.sub.onChange()) { if (event.type == "ReplyToMessage") { await setState(this, { replyTo: event.event, }); } else if (event.type == "EditorSelectProfile") { const regex = /@\w+$/; const text = this.state.text.replace( regex, `nostr:${event.member.publicKey.bech32()} `, ); await setState(this, { text, matching: undefined, searchResults: [], }); this.textareaElement.current?.focus(); } } } render(props: EditorProps, _state: EditorState) { const uploadFileInput = createRef(); return (
{ReplyIndicator({ getters: props.getters, replyTo: this.state.replyTo, cancelReply: () => { setState(this, { replyTo: undefined }); }, })} {this.state.files.length > 0 ? (
    {this.state.files.map((file, index) => { return (
  • ); })}
) : undefined}
{MatchingBar({ matching: this.state.matching, searchResults: this.state.searchResults, selectProfile: (member: Profile_Nostr_Event) => { props.emit({ type: "EditorSelectProfile", member, }); }, close: () => { setState(this, { matching: undefined, searchResults: [] }); }, })}
); } handleMessageInput = async (e: h.JSX.TargetedEvent) => { const text = e.currentTarget.value; const regex = /@\w+$/; const matched = regex.exec(text); const matching = matched ? matched[0].slice(1) : undefined; const searchResults = matched // todo: pass space url ? this.props.getters.getProfilesByText(matched[0].slice(1), undefined).splice(0, 10) : []; const lines = text.split("\n"); e.currentTarget.setAttribute( "rows", `${lines.length}`, ); setState(this, { text, matching, searchResults }); }; uploadImage = (e: h.JSX.TargetedEvent) => { const { props, state } = this; const selectedFiles = e.currentTarget.files; if (!selectedFiles) { return; } if (props.nip96) { props.emit({ type: "UploadImage", file: selectedFiles[0], callback: (uploaded) => { console.log("uploaded", uploaded); if (uploaded instanceof Error) { console.error(uploaded); return; } if (uploaded.status === "error") { console.error(uploaded.message); return; } if (!uploaded.nip94_event) { console.error("No NIP-94 event found", uploaded); return; } const image_url = uploaded.nip94_event.tags[0][1]; const { text: previousText } = this.state; if (previousText.length > 0) { setState(this, { text: previousText + ` ${image_url} `, }); } else { setState(this, { text: `${image_url} `, }); } }, }); } else { let previousFiles = state.files; for (let i = 0; i < selectedFiles.length; i++) { const file = selectedFiles[i]; if (!file) { continue; } previousFiles = previousFiles.concat([file]); } setState(this, { files: previousFiles, }); } }; sendMessage = async () => { this.props.emit({ type: "SendMessage", files: this.state.files, text: this.state.text, reply_to_event_id: this.state.replyTo?.id, }); this.textareaElement.current?.setAttribute( "rows", "1", ); await setState(this, { text: "", files: [], replyTo: undefined }); }; removeFile = (index: number) => { const files = this.state.files; const newFiles = files.slice(0, index).concat(files.slice(index + 1)); this.setState({ files: newFiles, }); }; } function MatchingBar(props: { matching?: string; searchResults: Profile_Nostr_Event[]; selectProfile: (member: Profile_Nostr_Event) => void; close: () => void; }) { if (!props.matching) return undefined; return (
Profiles matching @{props.matching}
    {props.searchResults.map((profile) => { return (
  1. { props.selectProfile(profile); }} >
    {profile.profile?.name || profile.profile?.display_name || profile.pubkey}
  2. ); })}
); } function ReplyIndicator(props: { replyTo?: Parsed_Event; cancelReply: () => void; getters: { getProfileByPublicKey: func_GetProfileByPublicKey; }; }) { if (!props.replyTo) { return undefined; } const authorPubkey = props.replyTo.publicKey; const profile = props.getters.getProfileByPublicKey(authorPubkey, undefined)?.profile; let replyToAuthor = profile?.name || profile?.display_name; if (!replyToAuthor) { replyToAuthor = authorPubkey.bech32(); } else { replyToAuthor = `@${replyToAuthor}`; } return (
{`Replying to `} {replyToAuthor}
); }