user @ autocomplete

This commit is contained in:
hzrd149 2023-08-31 16:55:17 -05:00
parent 3ac189306f
commit 3a2745ebdd
12 changed files with 199 additions and 68 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add @ user autocomplete when writing notes

View File

@ -5,31 +5,79 @@ import ReactTextareaAutocomplete, {
TextareaProps as ReactTextareaAutocompleteProps,
} from "@webscopeio/react-textarea-autocomplete";
import "@webscopeio/react-textarea-autocomplete/style.css";
import { nip19 } from "nostr-tools";
import { matchSorter } from "match-sorter/dist/match-sorter.esm.js";
import { Emoji, useContextEmojis } from "../providers/emoji-provider";
import { UserDirectory, useUserDirectoryContext } from "../providers/user-directory-provider";
import { UserAvatar } from "./user-avatar";
import userMetadataService from "../services/user-metadata";
const Item = ({ entity: { name, char, url } }: ItemComponentProps<Emoji>) => {
if (url)
export type PeopleToken = { pubkey: string; names: string[] };
type Token = Emoji | PeopleToken;
function isEmojiToken(token: Token): token is Emoji {
return Object.hasOwn(token, "char");
}
function isPersonToken(token: Token): token is PeopleToken {
return Object.hasOwn(token, "pubkey");
}
const Item = ({ entity }: ItemComponentProps<Token>) => {
if (isEmojiToken(entity)) {
const { url, name, char } = entity;
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>;
} else if (isPersonToken(entity)) {
return (
<span>
{name}: <Image src={url} h="1.2em" w="1.2em" display="inline-block" verticalAlign="middle" title={name} />
<UserAvatar pubkey={entity.pubkey} size="xs" /> {entity.names[0]}
</span>
);
else return <span>{`${name}: ${char}`}</span>;
} else return null;
};
function output(token: Token) {
if (isEmojiToken(token)) {
return token.char;
} else if (isPersonToken(token)) {
return "nostr:" + nip19.npubEncode(token.pubkey);
} else return "";
}
function getUsersFromDirectory(directory: UserDirectory) {
const people: PeopleToken[] = [];
for (const pubkey of directory) {
const metadata = userMetadataService.getSubject(pubkey).value;
if (!metadata) continue;
const names: string[] = [];
if (metadata.display_name) names.push(metadata.display_name);
if (metadata.name) names.push(metadata.name);
if (names.length > 0) {
people.push({ pubkey, names });
}
}
return people;
}
const Loading: ReactTextareaAutocompleteProps<
Emoji,
Token,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>["loadingComponent"] = ({ data }) => <div>Loading</div>;
export default function MagicTextArea({ ...props }: TextareaProps) {
const emojis = useContextEmojis();
const getDirectory = useUserDirectoryContext();
return (
<Textarea
{...props}
as={ReactTextareaAutocomplete<Emoji>}
as={ReactTextareaAutocomplete<Token>}
loadingComponent={Loading}
renderToBody
minChar={0}
@ -39,7 +87,16 @@ export default function MagicTextArea({ ...props }: TextareaProps) {
return matchSorter(emojis, token.trim(), { keys: ["keywords"] }).slice(0, 10);
},
component: Item,
output: (item: Emoji) => item.char,
output,
},
"@": {
dataProvider: async (token: string) => {
console.log("Getting user directory");
const dir = getUsersFromDirectory(await getDirectory());
return matchSorter(dir, token.trim(), { keys: ["names"] }).slice(0, 10);
},
component: Item,
output,
},
}}
/>

View File

@ -8,6 +8,7 @@ import { InvoiceModalProvider } from "./invoice-modal";
import NotificationTimelineProvider from "./notification-timeline";
import PostModalProvider from "./post-modal-provider";
import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider";
import { UserContactsUserDirectoryProvider } from "./user-directory-provider";
// Top level providers, should be render as close to the root as possible
export const GlobalProviders = ({ children }: { children: React.ReactNode }) => {
@ -30,7 +31,9 @@ export function PageProviders({ children }: { children: React.ReactNode }) {
<NotificationTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<PostModalProvider>{children}</PostModalProvider>
<UserContactsUserDirectoryProvider>
<PostModalProvider>{children}</PostModalProvider>
</UserContactsUserDirectoryProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</NotificationTimelineProvider>

View File

@ -0,0 +1,47 @@
import { PropsWithChildren, createContext, useCallback, useContext } from "react";
import { useCurrentAccount } from "../hooks/use-current-account";
import useUserContactList from "../hooks/use-user-contact-list";
import { getPubkeysFromList } from "../helpers/nostr/lists";
export type UserDirectory = string[];
export type GetDirectoryFn = () => Promise<UserDirectory> | UserDirectory;
const UserDirectoryContext = createContext<GetDirectoryFn>(async () => []);
export function useUserDirectoryContext() {
return useContext(UserDirectoryContext);
}
export function UserContactsUserDirectoryProvider({ children, pubkey }: PropsWithChildren & { pubkey?: string }) {
const account = useCurrentAccount();
const contacts = useUserContactList(pubkey || account?.pubkey);
const getDirectory = useCallback(async () => {
const people = contacts ? getPubkeysFromList(contacts).map((p) => p.pubkey) : [];
const directory: UserDirectory = [];
for (const pubkey of people) {
directory.push(pubkey);
}
return directory;
}, [contacts]);
return <UserDirectoryProvider getDirectory={getDirectory}>{children}</UserDirectoryProvider>;
}
export default function UserDirectoryProvider({
children,
getDirectory,
}: PropsWithChildren & { getDirectory: GetDirectoryFn }) {
const parent = useContext(UserDirectoryContext);
const wrapper = useCallback<() => Promise<UserDirectory>>(async () => {
const dir = parent ? await parent() : [];
const newDir = await getDirectory();
for (const pubkey of newDir) {
if (!dir.includes(pubkey)) dir.push(pubkey);
}
return dir;
}, [parent, getDirectory]);
return <UserDirectoryContext.Provider value={wrapper}>{children}</UserDirectoryContext.Provider>;
}

View File

@ -1,5 +1,5 @@
import { useMemo } from "react";
import { Box, Button, ButtonGroup, Flex, useDisclosure, useToast } from "@chakra-ui/react";
import { Box, Button, ButtonGroup, Flex, useToast } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { Kind } from "nostr-tools";
import dayjs from "dayjs";
@ -22,6 +22,7 @@ import NostrPublishAction from "../../../classes/nostr-publish-action";
import { unique } from "../../../helpers/array";
import MagicTextArea from "../../../components/magic-textarea";
import { useContextEmojis } from "../../../providers/emoji-provider";
import UserDirectoryProvider from "../../../providers/user-directory-provider";
export type ReplyFormProps = {
item: ThreadItem;
@ -67,30 +68,32 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
});
return (
<form onSubmit={submit}>
<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">
<NoteContents event={draft} />
</Box>
)}
<Flex gap="2" alignItems="center">
<ButtonGroup size="sm">
<Button onClick={onCancel}>Cancel</Button>
</ButtonGroup>
<UserAvatarStack label="Notify" pubkeys={notifyPubkeys} />
<Button type="submit" colorScheme="brand" size="sm" ml="auto">
Submit
</Button>
</Flex>
</form>
<UserDirectoryProvider getDirectory={() => threadMembers}>
<form onSubmit={submit}>
<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">
<NoteContents event={draft} />
</Box>
)}
<Flex gap="2" alignItems="center">
<ButtonGroup size="sm">
<Button onClick={onCancel}>Cancel</Button>
</ButtonGroup>
<UserAvatarStack label="Notify" pubkeys={notifyPubkeys} />
<Button type="submit" colorScheme="brand" size="sm" ml="auto">
Submit
</Button>
</Flex>
</form>
</UserDirectoryProvider>
);
}

View File

@ -1,5 +1,6 @@
import { Button, ButtonGroup, Flex, useDisclosure } from "@chakra-ui/react";
import { useState } from "react";
import { ArrowDownSIcon, ArrowUpSIcon, ReplyIcon } from "../../../components/icons";
import { Note } from "../../../components/note";
import { countReplies, ThreadItem } from "../../../helpers/thread";

View File

@ -1,6 +1,7 @@
import { Flex, Spinner } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { useParams } from "react-router-dom";
import { Note } from "../../components/note";
import { isHexKey } from "../../helpers/nip19";
import { useThreadLoader } from "../../hooks/use-thread-loader";

View File

@ -1,4 +1,5 @@
import React, { useMemo } from "react";
import { EmbedableContent, embedUrls } from "../../../../helpers/embeds";
import {
embedEmoji,

View File

@ -1,5 +1,6 @@
import React, { useRef } from "react";
import { Box, Text } from "@chakra-ui/react";
import { ParsedStream } from "../../../../helpers/nostr/stream";
import { UserAvatar } from "../../../../components/user-avatar";
import { UserLink } from "../../../../components/user-link";

View File

@ -19,6 +19,7 @@ import useUserMuteList from "../../../../hooks/use-user-mute-list";
import { NostrEvent, isPTag } from "../../../../types/nostr-event";
import { useCurrentAccount } from "../../../../hooks/use-current-account";
import ChatMessageForm from "./chat-message-form";
import UserDirectoryProvider from "../../../../providers/user-directory-provider";
const hideScrollbar = css`
scrollbar-width: 0;
@ -58,6 +59,13 @@ export default function StreamChat({
);
const events = useSubject(timeline.timeline).sort((a, b) => b.created_at - a.created_at);
const pubkeysInChat = useMemo(() => {
const set = new Set<string>();
for (const event of events) {
set.add(event.pubkey);
}
return Array.from(set);
}, [events]);
const zaps = useMemo(() => {
const parsed = [];
@ -77,40 +85,42 @@ export default function StreamChat({
return (
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<LightboxProvider>
<Card {...props} overflow="hidden" background={isChatLog ? "transparent" : undefined}>
{!isPopup && (
<CardHeader py="3" display="flex" justifyContent="space-between" alignItems="center">
<Heading size="md">Stream Chat</Heading>
{actions}
</CardHeader>
)}
<CardBody display="flex" flexDirection="column" overflow="hidden" p={0}>
<TopZappers zaps={zaps} pt={!isPopup ? 0 : undefined} />
<Flex
overflowY="scroll"
overflowX="hidden"
ref={scrollBox}
direction="column-reverse"
flex={1}
px="4"
py="2"
mb="2"
gap="2"
css={isChatLog && hideScrollbar}
>
{events.map((event) =>
event.kind === STREAM_CHAT_MESSAGE_KIND ? (
<ChatMessage key={event.id} event={event} stream={stream} />
) : (
<ZapMessage key={event.id} zap={event} stream={stream} />
),
)}
</Flex>
{!isChatLog && <ChatMessageForm stream={stream} />}
</CardBody>
</Card>
</LightboxProvider>
<UserDirectoryProvider getDirectory={() => pubkeysInChat}>
<LightboxProvider>
<Card {...props} overflow="hidden" background={isChatLog ? "transparent" : undefined}>
{!isPopup && (
<CardHeader py="3" display="flex" justifyContent="space-between" alignItems="center">
<Heading size="md">Stream Chat</Heading>
{actions}
</CardHeader>
)}
<CardBody display="flex" flexDirection="column" overflow="hidden" p={0}>
<TopZappers zaps={zaps} pt={!isPopup ? 0 : undefined} />
<Flex
overflowY="scroll"
overflowX="hidden"
ref={scrollBox}
direction="column-reverse"
flex={1}
px="4"
py="2"
mb="2"
gap="2"
css={isChatLog && hideScrollbar}
>
{events.map((event) =>
event.kind === STREAM_CHAT_MESSAGE_KIND ? (
<ChatMessage key={event.id} event={event} stream={stream} />
) : (
<ZapMessage key={event.id} zap={event} stream={stream} />
),
)}
</Flex>
{!isChatLog && <ChatMessageForm stream={stream} />}
</CardBody>
</Card>
</LightboxProvider>
</UserDirectoryProvider>
</IntersectionObserverProvider>
);
}

View File

@ -1,4 +1,5 @@
import { Box, Flex, FlexProps, Text } from "@chakra-ui/react";
import { ParsedZap } from "../../../../helpers/zaps";
import { UserAvatar } from "../../../../components/user-avatar";
import { UserLink } from "../../../../components/user-link";

View File

@ -1,5 +1,6 @@
import React, { useMemo, useRef } from "react";
import { Box, Flex, Text } from "@chakra-ui/react";
import { ParsedStream } from "../../../../helpers/nostr/stream";
import { UserAvatar } from "../../../../components/user-avatar";
import { UserLink } from "../../../../components/user-link";