mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-18 11:32:30 +02:00
user @ autocomplete
This commit is contained in:
5
.changeset/nervous-suns-confess.md
Normal file
5
.changeset/nervous-suns-confess.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add @ user autocomplete when writing notes
|
@@ -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,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@@ -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>
|
||||||
|
47
src/providers/user-directory-provider.tsx
Normal file
47
src/providers/user-directory-provider.tsx
Normal 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>;
|
||||||
|
}
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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";
|
||||||
|
@@ -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";
|
||||||
|
@@ -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,
|
||||||
|
@@ -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";
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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";
|
||||||
|
@@ -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";
|
||||||
|
Reference in New Issue
Block a user