blowater/app/UI/editor.tsx

437 lines
18 KiB
TypeScript
Raw Permalink Normal View History

2023-06-30 14:05:57 +00:00
/** @jsx h */
import { createRef, h } from "preact";
import { emitFunc } from "../event-bus.ts";
2023-06-30 14:05:57 +00:00
2023-11-11 11:19:21 +00:00
import { ImageIcon } from "./icons/image-icon.tsx";
import { SendIcon } from "./icons/send-icon.tsx";
import { Component } from "preact";
2023-11-11 11:19:21 +00:00
import { RemoveIcon } from "./icons/remove-icon.tsx";
2024-04-12 09:04:58 +00:00
import { setState } from "./_helper.ts";
2024-03-23 08:15:56 +00:00
import { XCircleIcon } from "./icons/x-circle-icon.tsx";
import { func_GetProfileByPublicKey, func_GetProfilesByText } from "./search.tsx";
import { NoteID } from "@blowater/nostr-sdk";
2024-04-12 09:04:58 +00:00
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";
2023-06-30 14:05:57 +00:00
2024-05-24 09:54:27 +00:00
export type EditorEvent = SendMessage | UploadImage | EditorSelectProfile;
2023-06-30 14:05:57 +00:00
export type SendMessage = {
readonly type: "SendMessage";
readonly text: string;
readonly files: Blob[];
2024-03-27 07:38:12 +00:00
readonly reply_to_event_id?: string | NoteID;
};
2024-05-24 09:54:27 +00:00
export type UploadImage = {
readonly type: "UploadImage";
readonly file: File;
readonly callback: (uploaded: UploadFileResponse | Error) => void;
2023-06-30 14:05:57 +00:00
};
export type EditorSelectProfile = {
readonly type: "EditorSelectProfile";
readonly member: Profile_Nostr_Event;
};
type EditorProps = {
2023-06-30 14:05:57 +00:00
readonly placeholder: string;
readonly maxHeight: string;
readonly emit: emitFunc<EditorEvent>;
2024-04-12 09:04:58 +00:00
readonly sub: EventSubscriber<UI_Interaction_Event>;
2024-03-23 08:15:56 +00:00
readonly getters: {
2024-04-12 09:04:58 +00:00
getProfileByPublicKey: func_GetProfileByPublicKey;
getProfilesByText: func_GetProfilesByText;
2024-03-23 08:15:56 +00:00
};
2024-05-24 09:54:27 +00:00
readonly nip96?: boolean;
};
2023-06-30 14:05:57 +00:00
2023-12-22 06:55:15 +00:00
export type EditorState = {
text: string;
files: Blob[];
2024-04-12 09:04:58 +00:00
replyTo?: Parsed_Event;
matching?: string;
searchResults: Profile_Nostr_Event[];
2023-12-22 06:55:15 +00:00
};
export class Editor extends Component<EditorProps, EditorState> {
state: Readonly<EditorState> = {
text: "",
files: [],
searchResults: [],
2023-12-22 06:55:15 +00:00
};
2024-03-15 15:18:36 +00:00
textareaElement = createRef<HTMLTextAreaElement>();
2024-04-12 09:04:58 +00:00
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();
2024-04-12 09:04:58 +00:00
}
}
}
2023-06-30 14:05:57 +00:00
2024-06-17 07:42:51 +00:00
render(props: EditorProps, _state: EditorState) {
2023-12-22 06:55:15 +00:00
const uploadFileInput = createRef();
return (
<div class="relative flex flex-col p-2 justify-center bg-[#36393F]">
2024-04-12 09:04:58 +00:00
<div class="w-full flex items-end gap-2">
2024-03-23 08:15:56 +00:00
<button
2024-04-07 06:13:04 +00:00
class="flex items-center justify-center group
w-10 h-10 rounded-[50%]
hover:bg-[#3F3F46] focus:outline-none focus-visible:outline-none"
2024-03-23 08:15:56 +00:00
onClick={() => {
if (uploadFileInput.current) {
uploadFileInput.current.click();
}
}}
2024-03-23 08:15:56 +00:00
>
<ImageIcon
2024-04-07 06:13:04 +00:00
class="h-8 w-8 stroke-current text-[#FFFFFF4D] group-hover:text-[#FFFFFF]"
2024-03-23 08:15:56 +00:00
style={{
fill: "none",
}}
/>
</button>
<input
ref={uploadFileInput}
type="file"
accept="image/*"
2024-05-24 09:54:27 +00:00
onChange={this.uploadImage}
2024-04-07 06:13:04 +00:00
class="hidden bg-[#FFFFFF2C]"
2024-03-23 08:15:56 +00:00
/>
2024-04-07 06:13:04 +00:00
<div class="flex flex-col flex-1 overflow-hidden bg-[#FFFFFF2C] rounded-xl">
2024-04-12 09:04:58 +00:00
{ReplyIndicator({
getters: props.getters,
replyTo: this.state.replyTo,
cancelReply: () => {
setState(this, { replyTo: undefined });
},
})}
2024-03-23 08:15:56 +00:00
{this.state.files.length > 0
? (
2024-04-07 06:13:04 +00:00
<ul class="flex overflow-auto list-none py-2 w-full border-b border-[#FFFFFF99]">
2024-03-23 08:15:56 +00:00
{this.state.files.map((file, index) => {
return (
2024-04-07 06:13:04 +00:00
<li class="flex items-center justify-center relative mx-2 min-w-[10rem] w-[10rem] h-[10rem] p-2">
2024-03-23 08:15:56 +00:00
<button
2024-04-07 06:13:04 +00:00
class="flex items-center justify-center
w-[2rem] h-[2rem] absolute top-1 right-1 rounded-[50%]
hover:bg-[#3F3F46] focus:outline-none focus-visible:outline-none"
2024-03-23 08:15:56 +00:00
onClick={() => {
this.removeFile(index);
}}
2024-03-23 08:15:56 +00:00
>
<RemoveIcon
2024-04-07 06:13:04 +00:00
class="w-[1.3rem] h-[1.3rem]"
2024-03-23 08:15:56 +00:00
style={{
fill: "none",
2024-04-07 06:13:04 +00:00
stroke: "#FFF",
2024-03-23 08:15:56 +00:00
}}
/>
</button>
<img
2024-04-07 06:13:04 +00:00
class="max-w-full max-h-full"
2024-03-23 08:15:56 +00:00
src={URL.createObjectURL(file)}
alt=""
/>
2024-03-23 08:15:56 +00:00
</li>
);
})}
</ul>
)
: undefined}
2023-06-30 14:05:57 +00:00
2024-04-07 06:13:04 +00:00
<div class="flex flex-1">
<textarea
ref={this.textareaElement}
style={{
maxHeight: this.props.maxHeight,
}}
value={this.state.text}
rows={1}
class="flex-1 px-4 py-[0.5rem] bg-transparent focus-visible:outline-none placeholder-[#FFFFFF4D] text-[#FFFFFF99] whitespace-nowrap resize-none overflow-x-hidden overflow-y-auto"
placeholder={this.props.placeholder}
onInput={this.handleMessageInput}
2024-04-07 06:13:04 +00:00
onKeyDown={async (e) => {
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/metaKey
if (e.code === "Enter" && (e.ctrlKey || e.metaKey)) {
await this.sendMessage();
}
}}
onPaste={async (_) => {
let clipboardData: ClipboardItems = [];
2024-03-23 08:15:56 +00:00
try {
2024-04-07 06:13:04 +00:00
clipboardData = await window.navigator.clipboard.read();
2024-03-23 08:15:56 +00:00
} catch (e) {
2024-04-07 06:13:04 +00:00
console.error(e.message);
return;
2024-03-23 08:15:56 +00:00
}
2024-04-07 06:13:04 +00:00
for (const item of clipboardData) {
try {
const image = await item.getType(
"image/png",
);
await setState(this, {
files: this.state.files.concat([image]),
});
} catch (e) {
console.error(e);
}
}
}}
>
</textarea>
2024-04-12 09:04:58 +00:00
<div class="flex justify-cente items-start hidden md:block cursor-default select-none">
<div class="flex justify-center items-center text-[#FFFFFF99] text-sm p-1 m-1 mt-[0.325rem] rounded-[0.625rem] ">
2024-04-07 06:13:04 +00:00
Ctrl + Enter
</div>
</div>
</div>
2024-03-23 08:15:56 +00:00
</div>
<button
2024-04-07 06:13:04 +00:00
class="inline-flex h-10 w-20 p-2 justify-center items-center gap-[0.5rem] shrink-0 rounded-[1rem] border-[0.125rem] border-solid border-[#FF762C]
hover:bg-gradient-to-r hover:from-[#FF762C] hover:via-[#FF3A5E] hover:to-[#FF01A9]"
2024-03-23 08:15:56 +00:00
onClick={async () => {
await this.sendMessage();
this.textareaElement.current?.focus();
}}
>
2024-03-23 08:15:56 +00:00
<SendIcon
2024-04-07 06:13:04 +00:00
class="h-4 w-4"
2024-03-23 08:15:56 +00:00
style={{
2024-04-07 06:13:04 +00:00
stroke: "#FFF",
2024-03-23 08:15:56 +00:00
fill: "none",
}}
/>
2024-04-07 06:13:04 +00:00
<span class="text-[#FFFFFF] font-700 leading-[1.25rem]">Send</span>
2024-03-23 08:15:56 +00:00
</button>
</div>
{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: [] });
},
})}
2023-07-03 13:42:11 +00:00
</div>
);
}
2024-04-12 09:04:58 +00:00
handleMessageInput = async (e: h.JSX.TargetedEvent<HTMLTextAreaElement>) => {
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 });
};
2024-05-24 09:54:27 +00:00
uploadImage = (e: h.JSX.TargetedEvent<HTMLInputElement>) => {
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,
});
}
};
2024-04-12 09:04:58 +00:00
sendMessage = async () => {
2024-05-24 09:54:27 +00:00
this.props.emit({
2024-04-12 09:04:58 +00:00
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,
});
};
}
2024-03-23 08:15:56 +00:00
function MatchingBar(props: {
matching?: string;
searchResults: Profile_Nostr_Event[];
selectProfile: (member: Profile_Nostr_Event) => void;
close: () => void;
}) {
if (!props.matching) return undefined;
return (
<div
class="absolute z-10"
style={{
left: `3.5rem`,
width: `calc(100% - 9.5rem)`,
bottom: `calc(100% + 0.5rem)`,
}}
>
<div class="w-full p-2 rounded-lg bg-[#2B2D31] shadow-lg">
<div class="flex justify-between item-center">
<div class="text-[#B6BAC0]">
Profiles matching <span class="text-white">@{props.matching}</span>
</div>
<button
class="h-6 w-6 flex justify-center items-center shrink-0 group"
onClick={() => {
props.close();
}}
>
<XCircleIcon class="h-4 w-4 text-[#B6BAC0] group-hover:text-[#FFFFFF]" />
</button>
</div>
<ol>
{props.searchResults.map((profile) => {
return (
<li
class="flex items-center justify-start p-1 m-1 hover:bg-[#36373C] rounded-lg cursor-pointer"
onClick={() => {
props.selectProfile(profile);
}}
>
<Avatar
class={`h-8 w-8 mr-2 flex-shrink-0`}
picture={profile.profile?.picture || robohash(profile.pubkey)}
/>
<div class="truncate text-white">
{profile.profile?.name || profile.profile?.display_name ||
profile.pubkey}
</div>
</li>
);
})}
</ol>
</div>
</div>
);
}
2024-03-23 08:15:56 +00:00
function ReplyIndicator(props: {
2024-04-12 09:04:58 +00:00
replyTo?: Parsed_Event;
cancelReply: () => void;
2024-03-23 08:15:56 +00:00
getters: {
2024-04-12 09:04:58 +00:00
getProfileByPublicKey: func_GetProfileByPublicKey;
2024-03-23 08:15:56 +00:00
};
}) {
2024-04-12 09:04:58 +00:00
if (!props.replyTo) {
2024-03-23 08:15:56 +00:00
return undefined;
}
2024-04-12 09:04:58 +00:00
const authorPubkey = props.replyTo.publicKey;
const profile = props.getters.getProfileByPublicKey(authorPubkey, undefined)?.profile;
2024-03-23 08:15:56 +00:00
let replyToAuthor = profile?.name || profile?.display_name;
if (!replyToAuthor) {
2024-04-12 09:04:58 +00:00
replyToAuthor = authorPubkey.bech32();
2024-03-23 08:15:56 +00:00
} else {
replyToAuthor = `@${replyToAuthor}`;
}
return (
<div class="h-10 w-full flex flex-row justify-between items-center text-[#B6BAC0] bg-[#2B2D31] px-4 rounded-t-lg">
2024-04-12 09:04:58 +00:00
<div class="w-3/4 cursor-default select-none">
2024-03-23 08:15:56 +00:00
<div class="text-left overflow-hidden whitespace-nowrap truncate">
{`Replying to `}
<span class="font-bold">
{replyToAuthor}
</span>
</div>
2024-04-12 09:04:58 +00:00
</div>
2024-03-27 07:38:12 +00:00
<button
2024-04-12 09:04:58 +00:00
class="h-6 w-6 flex justify-center items-center shrink-0 group"
2024-03-27 07:38:12 +00:00
onClick={() => {
2024-04-12 09:04:58 +00:00
props.cancelReply();
2024-03-27 07:38:12 +00:00
}}
>
2024-04-12 09:04:58 +00:00
<XCircleIcon class="h-4 w-4 group-hover:text-[#FFFFFF]" />
2024-03-23 08:15:56 +00:00
</button>
</div>
);
}