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, TextareaProps as ReactTextareaAutocompleteProps,
} from "@webscopeio/react-textarea-autocomplete"; } from "@webscopeio/react-textarea-autocomplete";
import "@webscopeio/react-textarea-autocomplete/style.css"; import "@webscopeio/react-textarea-autocomplete/style.css";
import { nip19 } from "nostr-tools";
import { matchSorter } from "match-sorter/dist/match-sorter.esm.js"; import { matchSorter } from "match-sorter/dist/match-sorter.esm.js";
import { Emoji, useContextEmojis } from "../providers/emoji-provider"; 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>) => { export type PeopleToken = { pubkey: string; names: string[] };
if (url) 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 ( return (
<span> <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> </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< const Loading: ReactTextareaAutocompleteProps<
Emoji, Token,
React.TextareaHTMLAttributes<HTMLTextAreaElement> React.TextareaHTMLAttributes<HTMLTextAreaElement>
>["loadingComponent"] = ({ data }) => <div>Loading</div>; >["loadingComponent"] = ({ data }) => <div>Loading</div>;
export default function MagicTextArea({ ...props }: TextareaProps) { export default function MagicTextArea({ ...props }: TextareaProps) {
const emojis = useContextEmojis(); const emojis = useContextEmojis();
const getDirectory = useUserDirectoryContext();
return ( return (
<Textarea <Textarea
{...props} {...props}
as={ReactTextareaAutocomplete<Emoji>} as={ReactTextareaAutocomplete<Token>}
loadingComponent={Loading} loadingComponent={Loading}
renderToBody renderToBody
minChar={0} minChar={0}
@@ -39,7 +87,16 @@ export default function MagicTextArea({ ...props }: TextareaProps) {
return matchSorter(emojis, token.trim(), { keys: ["keywords"] }).slice(0, 10); return matchSorter(emojis, token.trim(), { keys: ["keywords"] }).slice(0, 10);
}, },
component: Item, 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 NotificationTimelineProvider from "./notification-timeline";
import PostModalProvider from "./post-modal-provider"; import PostModalProvider from "./post-modal-provider";
import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-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 // Top level providers, should be render as close to the root as possible
export const GlobalProviders = ({ children }: { children: React.ReactNode }) => { export const GlobalProviders = ({ children }: { children: React.ReactNode }) => {
@@ -30,7 +31,9 @@ export function PageProviders({ children }: { children: React.ReactNode }) {
<NotificationTimelineProvider> <NotificationTimelineProvider>
<DefaultEmojiProvider> <DefaultEmojiProvider>
<UserEmojiProvider> <UserEmojiProvider>
<PostModalProvider>{children}</PostModalProvider> <UserContactsUserDirectoryProvider>
<PostModalProvider>{children}</PostModalProvider>
</UserContactsUserDirectoryProvider>
</UserEmojiProvider> </UserEmojiProvider>
</DefaultEmojiProvider> </DefaultEmojiProvider>
</NotificationTimelineProvider> </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 { 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 { useForm } from "react-hook-form";
import { Kind } from "nostr-tools"; import { Kind } from "nostr-tools";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -22,6 +22,7 @@ import NostrPublishAction from "../../../classes/nostr-publish-action";
import { unique } from "../../../helpers/array"; import { unique } from "../../../helpers/array";
import MagicTextArea from "../../../components/magic-textarea"; import MagicTextArea from "../../../components/magic-textarea";
import { useContextEmojis } from "../../../providers/emoji-provider"; import { useContextEmojis } from "../../../providers/emoji-provider";
import UserDirectoryProvider from "../../../providers/user-directory-provider";
export type ReplyFormProps = { export type ReplyFormProps = {
item: ThreadItem; item: ThreadItem;
@@ -67,30 +68,32 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
}); });
return ( return (
<form onSubmit={submit}> <UserDirectoryProvider getDirectory={() => threadMembers}>
<MagicTextArea <form onSubmit={submit}>
placeholder="Reply" <MagicTextArea
autoFocus placeholder="Reply"
mb="2" autoFocus
rows={5} mb="2"
isRequired rows={5}
value={getValues().content} isRequired
onChange={(e) => setValue("content", e.target.value)} value={getValues().content}
/> onChange={(e) => setValue("content", e.target.value)}
{getValues().content.length > 0 && ( />
<Box p="2" borderWidth={1} borderRadius="md" mb="2"> {getValues().content.length > 0 && (
<NoteContents event={draft} /> <Box p="2" borderWidth={1} borderRadius="md" mb="2">
</Box> <NoteContents event={draft} />
)} </Box>
<Flex gap="2" alignItems="center"> )}
<ButtonGroup size="sm"> <Flex gap="2" alignItems="center">
<Button onClick={onCancel}>Cancel</Button> <ButtonGroup size="sm">
</ButtonGroup> <Button onClick={onCancel}>Cancel</Button>
<UserAvatarStack label="Notify" pubkeys={notifyPubkeys} /> </ButtonGroup>
<Button type="submit" colorScheme="brand" size="sm" ml="auto"> <UserAvatarStack label="Notify" pubkeys={notifyPubkeys} />
Submit <Button type="submit" colorScheme="brand" size="sm" ml="auto">
</Button> Submit
</Flex> </Button>
</form> </Flex>
</form>
</UserDirectoryProvider>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import React, { useRef } from "react"; import React, { useRef } from "react";
import { Box, Text } from "@chakra-ui/react"; import { Box, Text } from "@chakra-ui/react";
import { ParsedStream } from "../../../../helpers/nostr/stream"; import { ParsedStream } from "../../../../helpers/nostr/stream";
import { UserAvatar } from "../../../../components/user-avatar"; import { UserAvatar } from "../../../../components/user-avatar";
import { UserLink } from "../../../../components/user-link"; 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 { NostrEvent, isPTag } from "../../../../types/nostr-event";
import { useCurrentAccount } from "../../../../hooks/use-current-account"; import { useCurrentAccount } from "../../../../hooks/use-current-account";
import ChatMessageForm from "./chat-message-form"; import ChatMessageForm from "./chat-message-form";
import UserDirectoryProvider from "../../../../providers/user-directory-provider";
const hideScrollbar = css` const hideScrollbar = css`
scrollbar-width: 0; 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 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 zaps = useMemo(() => {
const parsed = []; const parsed = [];
@@ -77,40 +85,42 @@ export default function StreamChat({
return ( return (
<IntersectionObserverProvider callback={callback} root={scrollBox}> <IntersectionObserverProvider callback={callback} root={scrollBox}>
<LightboxProvider> <UserDirectoryProvider getDirectory={() => pubkeysInChat}>
<Card {...props} overflow="hidden" background={isChatLog ? "transparent" : undefined}> <LightboxProvider>
{!isPopup && ( <Card {...props} overflow="hidden" background={isChatLog ? "transparent" : undefined}>
<CardHeader py="3" display="flex" justifyContent="space-between" alignItems="center"> {!isPopup && (
<Heading size="md">Stream Chat</Heading> <CardHeader py="3" display="flex" justifyContent="space-between" alignItems="center">
{actions} <Heading size="md">Stream Chat</Heading>
</CardHeader> {actions}
)} </CardHeader>
<CardBody display="flex" flexDirection="column" overflow="hidden" p={0}> )}
<TopZappers zaps={zaps} pt={!isPopup ? 0 : undefined} /> <CardBody display="flex" flexDirection="column" overflow="hidden" p={0}>
<Flex <TopZappers zaps={zaps} pt={!isPopup ? 0 : undefined} />
overflowY="scroll" <Flex
overflowX="hidden" overflowY="scroll"
ref={scrollBox} overflowX="hidden"
direction="column-reverse" ref={scrollBox}
flex={1} direction="column-reverse"
px="4" flex={1}
py="2" px="4"
mb="2" py="2"
gap="2" mb="2"
css={isChatLog && hideScrollbar} gap="2"
> css={isChatLog && hideScrollbar}
{events.map((event) => >
event.kind === STREAM_CHAT_MESSAGE_KIND ? ( {events.map((event) =>
<ChatMessage key={event.id} event={event} stream={stream} /> event.kind === STREAM_CHAT_MESSAGE_KIND ? (
) : ( <ChatMessage key={event.id} event={event} stream={stream} />
<ZapMessage key={event.id} zap={event} stream={stream} /> ) : (
), <ZapMessage key={event.id} zap={event} stream={stream} />
)} ),
</Flex> )}
{!isChatLog && <ChatMessageForm stream={stream} />} </Flex>
</CardBody> {!isChatLog && <ChatMessageForm stream={stream} />}
</Card> </CardBody>
</LightboxProvider> </Card>
</LightboxProvider>
</UserDirectoryProvider>
</IntersectionObserverProvider> </IntersectionObserverProvider>
); );
} }

View File

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

View File

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