mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-26 17:52:18 +01:00
Add emoji autocomplete when writing notes
This commit is contained in:
parent
7f28a3b83f
commit
c10a17eee7
5
.changeset/tame-masks-listen.md
Normal file
5
.changeset/tame-masks-listen.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add emoji autocomplete when writing notes
|
@ -18,10 +18,12 @@
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@getalby/bitcoin-connect-react": "^1.0.0",
|
||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||
"bech32": "^2.0.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"dayjs": "^1.11.9",
|
||||
"debug": "^4.3.4",
|
||||
"emojilib": "2",
|
||||
"framer-motion": "^10.16.0",
|
||||
"hls.js": "^1.4.10",
|
||||
"idb": "^7.1.1",
|
||||
@ -29,6 +31,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.locatecontrol": "^0.79.0",
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
"match-sorter": "^6.3.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"ngeohash": "^0.6.3",
|
||||
"noble-secp256k1": "^1.2.14",
|
||||
@ -55,6 +58,7 @@
|
||||
"@types/ngeohash": "^0.6.4",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"cypress": "^12.17.4",
|
||||
"prettier": "^3.0.2",
|
||||
|
27
src/app.tsx
27
src/app.tsx
@ -40,17 +40,44 @@ import UserListsTab from "./views/user/lists";
|
||||
|
||||
import "./services/emoji-packs";
|
||||
import BrowseListView from "./views/lists/browse";
|
||||
import { css, Global } from "@emotion/react";
|
||||
|
||||
const StreamsView = React.lazy(() => import("./views/streams"));
|
||||
const StreamView = React.lazy(() => import("./views/streams/stream"));
|
||||
const SearchView = React.lazy(() => import("./views/search"));
|
||||
const MapView = React.lazy(() => import("./views/map"));
|
||||
|
||||
const overrideReactTextareaAutocompleteStyles = css`
|
||||
.rta__autocomplete {
|
||||
z-index: var(--chakra-zIndices-popover);
|
||||
font-size: var(--chakra-fontSizes-md);
|
||||
}
|
||||
.rta__list {
|
||||
background: var(--chakra-colors-chakra-subtle-bg);
|
||||
color: var(--chakra-colors-chakra-body-text);
|
||||
border: var(--chakra-borders-1px) var(--chakra-colors-chakra-border-color);
|
||||
border-radius: var(--chakra-sizes-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rta__entity {
|
||||
background: none;
|
||||
color: inherit;
|
||||
padding: var(--chakra-sizes-1) var(--chakra-sizes-2);
|
||||
}
|
||||
.rta__entity--selected {
|
||||
background: var(--chakra-ring-color);
|
||||
}
|
||||
.rta__item:not(:last-child) {
|
||||
border-bottom: var(--chakra-borders-1px) var(--chakra-colors-chakra-border-color);
|
||||
}
|
||||
`;
|
||||
|
||||
const RootPage = () => {
|
||||
useSetColorMode();
|
||||
|
||||
return (
|
||||
<PageProviders>
|
||||
<Global styles={overrideReactTextareaAutocompleteStyles} />
|
||||
<Layout>
|
||||
<ScrollRestoration />
|
||||
<Suspense fallback={<Spinner />}>
|
||||
|
@ -1,17 +1,16 @@
|
||||
import { Image } from "@chakra-ui/react";
|
||||
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
import { DraftNostrEvent, NostrEvent, isEmojiTag } from "../../types/nostr-event";
|
||||
import { getMatchEmoji } from "../../helpers/regexp";
|
||||
|
||||
export function embedEmoji(content: EmbedableContent, note: NostrEvent | DraftNostrEvent) {
|
||||
return embedJSX(content, {
|
||||
regexp: /:([a-zA-Z0-9_]+):/gi,
|
||||
regexp: getMatchEmoji(),
|
||||
render: (match) => {
|
||||
const emojiTag = note.tags.find(
|
||||
(tag) => tag[0] === "emoji" && tag[1].toLowerCase() === match[1].toLowerCase() && tag[2],
|
||||
);
|
||||
const emojiTag = note.tags.filter(isEmojiTag).find((t) => t[1].toLowerCase() === match[1].toLowerCase());
|
||||
if (emojiTag) {
|
||||
return (
|
||||
<Image src={emojiTag[2]} height="1.5em" display="inline-block" verticalAlign="middle" title={match[1]} />
|
||||
<Image src={emojiTag[2]} h="1.2em" w="1.2em" display="inline-block" verticalAlign="middle" title={match[1]} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
47
src/components/magic-textarea.tsx
Normal file
47
src/components/magic-textarea.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { Image, Textarea, TextareaProps } from "@chakra-ui/react";
|
||||
import ReactTextareaAutocomplete, {
|
||||
ItemComponentProps,
|
||||
TextareaProps as ReactTextareaAutocompleteProps,
|
||||
} from "@webscopeio/react-textarea-autocomplete";
|
||||
import "@webscopeio/react-textarea-autocomplete/style.css";
|
||||
|
||||
import { matchSorter } from "match-sorter/dist/match-sorter.esm.js";
|
||||
import { Emoji, useContextEmojis } from "../providers/emoji-provider";
|
||||
|
||||
const Item = ({ entity: { name, char, url } }: ItemComponentProps<Emoji>) => {
|
||||
if (url)
|
||||
return (
|
||||
<span>
|
||||
{name}: <Image src={url} h="1.2em" w="1.2em" display="inline-block" verticalAlign="middle" title={name} />
|
||||
</span>
|
||||
);
|
||||
else return <span>{`${name}: ${char}`}</span>;
|
||||
};
|
||||
const Loading: ReactTextareaAutocompleteProps<
|
||||
Emoji,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>["loadingComponent"] = ({ data }) => <div>Loading</div>;
|
||||
|
||||
export default function MagicTextArea({ ...props }: TextareaProps) {
|
||||
const emojis = useContextEmojis();
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
{...props}
|
||||
as={ReactTextareaAutocomplete<Emoji>}
|
||||
loadingComponent={Loading}
|
||||
renderToBody
|
||||
minChar={0}
|
||||
trigger={{
|
||||
":": {
|
||||
dataProvider: (token: string) => {
|
||||
return matchSorter(emojis, token.trim(), { keys: ["keywords"] }).slice(0, 10);
|
||||
},
|
||||
component: Item,
|
||||
output: (item: Emoji) => item.char,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@ -6,12 +6,12 @@ import {
|
||||
ModalBody,
|
||||
Flex,
|
||||
Button,
|
||||
Textarea,
|
||||
Text,
|
||||
useDisclosure,
|
||||
VisuallyHiddenInput,
|
||||
IconButton,
|
||||
useToast,
|
||||
Box,
|
||||
Heading,
|
||||
} from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
@ -25,8 +25,10 @@ import { NoteLink } from "../note-link";
|
||||
import { NoteContents } from "../note/note-contents";
|
||||
import { PublishDetails } from "../publish-details";
|
||||
import { TrustProvider } from "../../providers/trust";
|
||||
import { ensureNotifyPubkeys, finalizeNote, getContentMentions } from "../../helpers/nostr/post";
|
||||
import { createEmojiTags, ensureNotifyPubkeys, finalizeNote, getContentMentions } from "../../helpers/nostr/post";
|
||||
import { UserAvatarStack } from "../compact-user-stack";
|
||||
import MagicTextArea from "../magic-textarea";
|
||||
import { useContextEmojis } from "../../providers/emoji-provider";
|
||||
|
||||
function emptyDraft(): DraftNostrEvent {
|
||||
return {
|
||||
@ -47,12 +49,12 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
|
||||
const toast = useToast();
|
||||
const { requestSignature } = useSigningContext();
|
||||
const writeRelays = useWriteRelayUrls();
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const [signing, setSigning] = useState(false);
|
||||
const [publishAction, setPublishAction] = useState<NostrPublishAction>();
|
||||
const { isOpen: showPreview, onToggle: togglePreview } = useDisclosure();
|
||||
const [draft, setDraft] = useState<DraftNostrEvent>(() => Object.assign(emptyDraft(), initialDraft));
|
||||
const imageUploadRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const emojis = useContextEmojis();
|
||||
|
||||
const uploadImage = async (imageFile: File) => {
|
||||
try {
|
||||
@ -77,14 +79,19 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
|
||||
setDraft((d) => ({ ...d, content: event.target.value }));
|
||||
};
|
||||
|
||||
const finalDraft = useMemo(() => {
|
||||
let updatedDraft = finalizeNote(draft);
|
||||
const contentMentions = getContentMentions(draft.content);
|
||||
updatedDraft = createEmojiTags(updatedDraft, emojis);
|
||||
updatedDraft = ensureNotifyPubkeys(updatedDraft, contentMentions);
|
||||
return updatedDraft;
|
||||
}, [draft, emojis]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setWaiting(true);
|
||||
let updatedDraft = finalizeNote(draft);
|
||||
const contentMentions = getContentMentions(draft.content);
|
||||
updatedDraft = ensureNotifyPubkeys(updatedDraft, contentMentions);
|
||||
const signed = await requestSignature(updatedDraft);
|
||||
setWaiting(false);
|
||||
setSigning(true);
|
||||
const signed = await requestSignature(finalDraft);
|
||||
setSigning(false);
|
||||
|
||||
const pub = new NostrPublishAction("Post", writeRelays, signed);
|
||||
setPublishAction(pub);
|
||||
@ -115,22 +122,26 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
|
||||
Replying to: <NoteLink noteId={refs.replyId} />
|
||||
</Text>
|
||||
)}
|
||||
{showPreview ? (
|
||||
<TrustProvider trust>
|
||||
<NoteContents event={finalizeNote(draft)} />
|
||||
</TrustProvider>
|
||||
) : (
|
||||
<Textarea
|
||||
autoFocus
|
||||
mb="2"
|
||||
value={draft.content}
|
||||
onChange={handleContentChange}
|
||||
rows={5}
|
||||
onPaste={(e) => {
|
||||
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
|
||||
if (imageFile) uploadImage(imageFile);
|
||||
}}
|
||||
/>
|
||||
<MagicTextArea
|
||||
autoFocus
|
||||
mb="2"
|
||||
value={draft.content}
|
||||
onChange={handleContentChange}
|
||||
rows={5}
|
||||
onPaste={(e) => {
|
||||
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
|
||||
if (imageFile) uploadImage(imageFile);
|
||||
}}
|
||||
/>
|
||||
{draft.content.length > 0 && (
|
||||
<Box>
|
||||
<Heading size="sm">Preview:</Heading>
|
||||
<Box borderWidth={1} borderRadius="md" p="2">
|
||||
<TrustProvider trust>
|
||||
<NoteContents event={finalDraft} />
|
||||
</TrustProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Flex gap="2" alignItems="center" justifyContent="flex-end">
|
||||
<Flex mr="auto" gap="2">
|
||||
@ -152,9 +163,8 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
|
||||
/>
|
||||
</Flex>
|
||||
<UserAvatarStack label="Mentions" pubkeys={getContentMentions(draft.content)} />
|
||||
{draft.content.length > 0 && <Button onClick={togglePreview}>Preview</Button>}
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" type="submit" isLoading={waiting} onClick={handleSubmit} isDisabled={!canSubmit}>
|
||||
<Button colorScheme="blue" type="submit" isLoading={signing} onClick={handleSubmit} isDisabled={!canSubmit}>
|
||||
Post
|
||||
</Button>
|
||||
</Flex>
|
||||
@ -166,7 +176,9 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="4xl" closeOnOverlayClick={!!publishAction}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalBody padding={["2", "2", "4"]}>{renderContent()}</ModalBody>
|
||||
<ModalBody display="flex" flexDirection="column" padding={["2", "2", "4"]} gap="2">
|
||||
{renderContent()}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
@ -25,6 +25,7 @@ function EmojiPack({ addr, onSelect }: { addr: string; onSelect: ReactionPickerP
|
||||
key={emoji.name}
|
||||
icon={<Image src={emoji.url} height="1.2rem" />}
|
||||
aria-label={emoji.name}
|
||||
title={emoji.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSelect(emoji.name, emoji.url)}
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { DraftNostrEvent, NostrEvent, PTag, Tag } from "../../types/nostr-event";
|
||||
import { getMatchHashtag } from "../regexp";
|
||||
import { getMatchEmoji, getMatchHashtag } from "../regexp";
|
||||
import { normalizeToHex } from "../nip19";
|
||||
import { getReferences } from "./events";
|
||||
import { getEventRelays } from "../../services/event-relays";
|
||||
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||
import { getPubkey, safeDecode } from "../nip19";
|
||||
import { Emoji } from "../../providers/emoji-provider";
|
||||
|
||||
function addTag(tags: Tag[], tag: Tag, overwrite = false) {
|
||||
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) {
|
||||
@ -91,6 +92,21 @@ export function createHashtagTags(draft: DraftNostrEvent) {
|
||||
return updatedDraft;
|
||||
}
|
||||
|
||||
export function createEmojiTags(draft: DraftNostrEvent, emojis: Emoji[]) {
|
||||
const updatedDraft: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
||||
|
||||
// create tags for all occurrences of #hashtag
|
||||
const matches = updatedDraft.content.matchAll(getMatchEmoji());
|
||||
for (const [_, name] of matches) {
|
||||
const emoji = emojis.find((e) => e.name === name);
|
||||
if (emoji?.url) {
|
||||
updatedDraft.tags = addTag(updatedDraft.tags, ["emoji", emoji.name, emoji.url]);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedDraft;
|
||||
}
|
||||
|
||||
export function finalizeNote(draft: DraftNostrEvent) {
|
||||
let updated = draft;
|
||||
updated = createHashtagTags(updated);
|
||||
|
@ -3,6 +3,7 @@ export const getMatchNostrLink = () =>
|
||||
export const getMatchHashtag = () => /(^|[^\p{L}])#([\p{L}\p{N}]+)/gu;
|
||||
export const getMatchLink = () =>
|
||||
/https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{Letter}\p{Number}&\.-\/\?=#\-@%\+_,:]*)/gu;
|
||||
export const getMatchEmoji = () => /:([a-zA-Z0-9_]+):/gi;
|
||||
|
||||
// read more https://www.regular-expressions.info/unicode.html#category
|
||||
export function stripInvisibleChar(str?: string) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import "./polyfill";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app";
|
||||
import { GlobalProviders } from "./providers";
|
||||
|
1
src/polyfill.ts
Normal file
1
src/polyfill.ts
Normal file
@ -0,0 +1 @@
|
||||
window.global = window;
|
46
src/providers/emoji-provider.tsx
Normal file
46
src/providers/emoji-provider.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { PropsWithChildren, createContext, useContext } from "react";
|
||||
import { lib } from "emojilib";
|
||||
import useUserEmojiPacks from "../hooks/use-users-emoji-packs";
|
||||
import useReplaceableEvents from "../hooks/use-replaceable-events";
|
||||
import { useCurrentAccount } from "../hooks/use-current-account";
|
||||
import { isEmojiTag } from "../types/nostr-event";
|
||||
|
||||
const defaultEmojis = Object.entries(lib).map(([name, emojiObject]) => ({
|
||||
...emojiObject,
|
||||
keywords: [name, ...emojiObject.keywords],
|
||||
name,
|
||||
}));
|
||||
|
||||
export type Emoji = { name: string; keywords: string[]; char: string; url?: string };
|
||||
|
||||
const EmojiContext = createContext<Emoji[]>([]);
|
||||
|
||||
export function useContextEmojis() {
|
||||
return useContext(EmojiContext);
|
||||
}
|
||||
|
||||
export function DefaultEmojiProvider({ children }: PropsWithChildren) {
|
||||
return <EmojiProvider emojis={defaultEmojis}>{children}</EmojiProvider>;
|
||||
}
|
||||
|
||||
export function UserEmojiProvider({ children }: PropsWithChildren) {
|
||||
const account = useCurrentAccount();
|
||||
const userPacks = useUserEmojiPacks(account?.pubkey);
|
||||
const events = useReplaceableEvents(userPacks?.packs);
|
||||
|
||||
const emojis = events
|
||||
.map((event) =>
|
||||
event.tags.filter(isEmojiTag).map((t) => ({ name: t[1], url: t[2], keywords: [t[1]], char: `:${t[1]}:` })),
|
||||
)
|
||||
.flat();
|
||||
|
||||
console.log(userPacks, emojis);
|
||||
|
||||
return <EmojiProvider emojis={emojis}>{children}</EmojiProvider>;
|
||||
}
|
||||
|
||||
export default function EmojiProvider({ children, emojis }: PropsWithChildren & { emojis: Emoji[] }) {
|
||||
const parent = useContext(EmojiContext);
|
||||
|
||||
return <EmojiContext.Provider value={[...parent, ...emojis]}>{children}</EmojiContext.Provider>;
|
||||
}
|
@ -7,6 +7,7 @@ import DeleteEventProvider from "./delete-event-provider";
|
||||
import { InvoiceModalProvider } from "./invoice-modal";
|
||||
import NotificationTimelineProvider from "./notification-timeline";
|
||||
import PostModalProvider from "./post-modal-provider";
|
||||
import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider";
|
||||
|
||||
// Top level providers, should be render as close to the root as possible
|
||||
export const GlobalProviders = ({ children }: { children: React.ReactNode }) => {
|
||||
@ -27,7 +28,11 @@ export function PageProviders({ children }: { children: React.ReactNode }) {
|
||||
<DeleteEventProvider>
|
||||
<InvoiceModalProvider>
|
||||
<NotificationTimelineProvider>
|
||||
<PostModalProvider>{children}</PostModalProvider>
|
||||
<DefaultEmojiProvider>
|
||||
<UserEmojiProvider>
|
||||
<PostModalProvider>{children}</PostModalProvider>
|
||||
</UserEmojiProvider>
|
||||
</DefaultEmojiProvider>
|
||||
</NotificationTimelineProvider>
|
||||
</InvoiceModalProvider>
|
||||
</DeleteEventProvider>
|
||||
|
11
src/types/emojilib.d.ts
vendored
Normal file
11
src/types/emojilib.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
type EmojiShape = {
|
||||
keywords: string[];
|
||||
char: string;
|
||||
fitzpatrick_scale: boolean;
|
||||
category: string;
|
||||
};
|
||||
|
||||
declare module "emojilib" {
|
||||
const lib: { [key: string]: EmojiShape };
|
||||
export { lib };
|
||||
}
|
@ -3,6 +3,7 @@ export type ATag = ["a", string] | ["a", string, string];
|
||||
export type PTag = ["p", string] | ["p", string, string] | ["p", string, string, string];
|
||||
export type RTag = ["r", string] | ["r", string, string];
|
||||
export type DTag = ["d"] | ["d", string];
|
||||
export type EmojiTag = ["emoji", string, string];
|
||||
export type Tag = string[] | ETag | PTag | RTag | DTag | ATag;
|
||||
|
||||
export type NostrEvent = {
|
||||
@ -38,3 +39,6 @@ export function isDTag(tag: Tag): tag is DTag {
|
||||
export function isATag(tag: Tag): tag is ATag {
|
||||
return tag[0] === "a" && tag[1] !== undefined;
|
||||
}
|
||||
export function isEmojiTag(tag: Tag): tag is EmojiTag {
|
||||
return tag[0] === "emoji" && tag[1] !== undefined && tag[2] !== undefined;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Button, ButtonGroup, Flex, Textarea, useDisclosure, useToast } from "@chakra-ui/react";
|
||||
import { Box, Button, ButtonGroup, Flex, useDisclosure, useToast } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Kind } from "nostr-tools";
|
||||
import dayjs from "dayjs";
|
||||
@ -8,21 +8,20 @@ import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { UserAvatarStack } from "../../../components/compact-user-stack";
|
||||
import { ThreadItem, getThreadMembers } from "../../../helpers/thread";
|
||||
import { NoteContents } from "../../../components/note/note-contents";
|
||||
import { addReplyTags, ensureNotifyPubkeys, finalizeNote, getContentMentions } from "../../../helpers/nostr/post";
|
||||
import {
|
||||
addReplyTags,
|
||||
createEmojiTags,
|
||||
ensureNotifyPubkeys,
|
||||
finalizeNote,
|
||||
getContentMentions,
|
||||
} from "../../../helpers/nostr/post";
|
||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||
import { useSigningContext } from "../../../providers/signing-provider";
|
||||
import { useWriteRelayUrls } from "../../../hooks/use-client-relays";
|
||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
import { unique } from "../../../helpers/array";
|
||||
|
||||
function NoteContentPreview({ content }: { content: string }) {
|
||||
const draft = useMemo(
|
||||
() => finalizeNote({ kind: Kind.Text, content, created_at: dayjs().unix(), tags: [] }),
|
||||
[content],
|
||||
);
|
||||
|
||||
return <NoteContents event={draft} />;
|
||||
}
|
||||
import MagicTextArea from "../../../components/magic-textarea";
|
||||
import { useContextEmojis } from "../../../providers/emoji-provider";
|
||||
|
||||
export type ReplyFormProps = {
|
||||
item: ThreadItem;
|
||||
@ -33,12 +32,12 @@ export type ReplyFormProps = {
|
||||
export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProps) {
|
||||
const toast = useToast();
|
||||
const account = useCurrentAccount();
|
||||
const showPreview = useDisclosure();
|
||||
const emojis = useContextEmojis();
|
||||
const { requestSignature } = useSigningContext();
|
||||
const writeRelays = useWriteRelayUrls();
|
||||
|
||||
const threadMembers = useMemo(() => getThreadMembers(item, account?.pubkey), [item, account?.pubkey]);
|
||||
const { register, getValues, watch, handleSubmit } = useForm({
|
||||
const { setValue, getValues, watch, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
content: "",
|
||||
},
|
||||
@ -48,15 +47,17 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
|
||||
|
||||
watch("content");
|
||||
|
||||
const draft = useMemo(() => {
|
||||
let updated = finalizeNote({ kind: Kind.Text, content: getValues().content, created_at: dayjs().unix(), tags: [] });
|
||||
updated = createEmojiTags(updated, emojis);
|
||||
updated = addReplyTags(updated, item.event);
|
||||
updated = ensureNotifyPubkeys(updated, notifyPubkeys);
|
||||
return updated;
|
||||
}, [getValues().content, emojis]);
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
try {
|
||||
let draft = finalizeNote({ kind: Kind.Text, content: values.content, created_at: dayjs().unix(), tags: [] });
|
||||
draft = addReplyTags(draft, item.event);
|
||||
draft = ensureNotifyPubkeys(draft, notifyPubkeys);
|
||||
|
||||
const signed = await requestSignature(draft);
|
||||
if (!signed) return;
|
||||
// TODO: write to other users inbox relays
|
||||
const pub = new NostrPublishAction("Reply", writeRelays, signed);
|
||||
|
||||
if (onSubmitted) onSubmitted(signed);
|
||||
@ -67,24 +68,28 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{showPreview.isOpen ? (
|
||||
<MagicTextArea
|
||||
placeholder="Reply"
|
||||
autoFocus
|
||||
mb="2"
|
||||
rows={5}
|
||||
isRequired
|
||||
value={getValues().content}
|
||||
onChange={(e) => setValue("content", e.target.value)}
|
||||
/>
|
||||
{getValues().content.length > 0 && (
|
||||
<Box p="2" borderWidth={1} borderRadius="md" mb="2">
|
||||
<NoteContentPreview content={getValues().content} />
|
||||
<NoteContents event={draft} />
|
||||
</Box>
|
||||
) : (
|
||||
<Textarea placeholder="Reply" {...register("content")} autoFocus mb="2" rows={5} required />
|
||||
)}
|
||||
<Flex gap="2" alignItems="center">
|
||||
<ButtonGroup size="sm">
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit">Submit</Button>
|
||||
</ButtonGroup>
|
||||
<UserAvatarStack label="Notify" pubkeys={notifyPubkeys} />
|
||||
{getValues().content.length > 0 && (
|
||||
<Button size="sm" ml="auto" onClick={showPreview.onToggle}>
|
||||
Preview
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" colorScheme="brand" size="sm" ml="auto">
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
|
36506
stats.html
36506
stats.html
File diff suppressed because one or more lines are too long
43
yarn.lock
43
yarn.lock
@ -2762,6 +2762,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.3.tgz#a136f83b0758698df454e328759dbd3d44555311"
|
||||
integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==
|
||||
|
||||
"@types/webscopeio__react-textarea-autocomplete@^4.7.2":
|
||||
version "4.7.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/webscopeio__react-textarea-autocomplete/-/webscopeio__react-textarea-autocomplete-4.7.2.tgz#605e8a6b4194fb4b6e55df8a19bc8fcd56319cfa"
|
||||
integrity sha512-e1DZGD+eH19BnllTWCGXAdrMa2kI53wEMuhn/d+wUmnu8//ZI6BiuK/EPdw07fI4+tlyo5qdPZdXdpkoXHJVOw==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/yauzl@^2.9.1":
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
|
||||
@ -2779,6 +2786,14 @@
|
||||
"@babel/plugin-transform-react-jsx-source" "^7.22.5"
|
||||
react-refresh "^0.14.0"
|
||||
|
||||
"@webscopeio/react-textarea-autocomplete@^4.9.2":
|
||||
version "4.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@webscopeio/react-textarea-autocomplete/-/react-textarea-autocomplete-4.9.2.tgz#b39e57d8048ad2e8790d70073afe63eafa877345"
|
||||
integrity sha512-9l5lbyA709d5HHvI/COflSnblBJeYGxB2/0ghP3m3YViLzXRMzJwaXqnqz6oA96y7QdR3pQWYtVmkUKA0AUVAA==
|
||||
dependencies:
|
||||
custom-event "^1.0.1"
|
||||
textarea-caret "3.0.2"
|
||||
|
||||
"@xobotyi/scrollbar-width@^1.9.5":
|
||||
version "1.9.5"
|
||||
resolved "https://registry.yarnpkg.com/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d"
|
||||
@ -3462,6 +3477,11 @@ csv@^5.5.3:
|
||||
csv-stringify "^5.6.5"
|
||||
stream-transform "^2.1.3"
|
||||
|
||||
custom-event@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
|
||||
integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==
|
||||
|
||||
cypress@^12.17.4:
|
||||
version "12.17.4"
|
||||
resolved "https://registry.yarnpkg.com/cypress/-/cypress-12.17.4.tgz#b4dadf41673058493fa0d2362faa3da1f6ae2e6c"
|
||||
@ -3676,6 +3696,11 @@ emoji-regex@^8.0.0:
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
|
||||
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
|
||||
|
||||
emojilib@2:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/emojilib/-/emojilib-2.4.0.tgz#ac518a8bb0d5f76dda57289ccb2fdf9d39ae721e"
|
||||
integrity sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==
|
||||
|
||||
end-of-stream@^1.1.0:
|
||||
version "1.4.4"
|
||||
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
|
||||
@ -4981,6 +5006,14 @@ map-obj@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a"
|
||||
integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==
|
||||
|
||||
match-sorter@^6.3.1:
|
||||
version "6.3.1"
|
||||
resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.3.1.tgz#98cc37fda756093424ddf3cbc62bfe9c75b92bda"
|
||||
integrity sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
remove-accents "0.4.2"
|
||||
|
||||
mdn-data@2.0.14:
|
||||
version "2.0.14"
|
||||
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
|
||||
@ -5745,6 +5778,11 @@ regjsparser@^0.9.1:
|
||||
dependencies:
|
||||
jsesc "~0.5.0"
|
||||
|
||||
remove-accents@0.4.2:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5"
|
||||
integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==
|
||||
|
||||
request-progress@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe"
|
||||
@ -6295,6 +6333,11 @@ terser@^5.0.0:
|
||||
commander "^2.20.0"
|
||||
source-map-support "~0.5.20"
|
||||
|
||||
textarea-caret@3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/textarea-caret/-/textarea-caret-3.0.2.tgz#f360c48699aa1abf718680a43a31a850665c2caf"
|
||||
integrity sha512-gRzeti2YS4did7UJnPQ47wrjD+vp+CJIe9zbsu0bJ987d8QVLvLNG9757rqiQTIy4hGIeFauTTJt5Xkn51UkXg==
|
||||
|
||||
throttle-debounce@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-3.0.1.tgz#32f94d84dfa894f786c9a1f290e7a645b6a19abb"
|
||||
|
Loading…
x
Reference in New Issue
Block a user