diff --git a/components/CommentsDrawer/CommentForm.tsx b/components/CommentsDrawer/CommentForm.tsx new file mode 100644 index 00000000..7ca28818 --- /dev/null +++ b/components/CommentsDrawer/CommentForm.tsx @@ -0,0 +1,97 @@ +import { Avatar, Group } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import DraftEditor from "components/DraftEditor/DraftEditor"; +import { + DraftEditorProvider, + draftEditorDecorator, +} from "components/DraftEditor/DraftEditorProvider"; +import MentionMenu from "components/DraftEditor/MentionMenu"; +import { EditorState } from "draft-js"; +import useAuth from "hooks/useAuth"; +import { useComments } from "ndk/NDKCommentsProvider"; +import { useNDK } from "ndk/NDKProvider"; +import useProfilePicSrc from "ndk/hooks/useProfilePicSrc"; +import { useUser } from "ndk/hooks/useUser"; +import { createKind1Event, getFormattedAtName } from "ndk/utils"; +import { useState } from "react"; +import { noop } from "utils/common"; + +type CommentFormValues = { + comment: string; +}; + +export default function CommentForm() { + const { ndk, stemstrRelaySet } = useNDK(); + const { replyingTo, setReplyingTo, rootEvent, setHighlightedEvent } = + useComments(); + const [editorState, setEditorState] = useState(() => + EditorState.createEmpty(draftEditorDecorator) + ); + const isShowingName = replyingTo?.id !== rootEvent?.id; + const replyingToUser = useUser( + isShowingName ? replyingTo?.pubkey : undefined + ); + const formattedReplyingToName = isShowingName + ? getFormattedAtName(replyingToUser) + : ""; + const { authState } = useAuth(); + const user = useUser(authState.pk); + const src = useProfilePicSrc(user); + const form = useForm({ + initialValues: { + comment: "", + }, + validate: { + comment: (value) => (value ? null : "Comment required."), + }, + }); + + const handleSubmit = (values: CommentFormValues) => { + if (ndk && replyingTo) { + const event = createKind1Event(ndk, values.comment, { + replyingTo: replyingTo.rawEvent(), + }); + event + .publish(stemstrRelaySet) + .then(() => { + // Reset form + setHighlightedEvent(event); + setEditorState(EditorState.createEmpty(draftEditorDecorator)); + if (rootEvent) { + setReplyingTo(rootEvent); + } + form.reset(); + }) + .catch(noop); + } + }; + + return ( +
+ + + + ({ + border: `1px solid ${theme.colors.gray[4]}`, + })} + /> + { + form.setFieldValue("comment", value); + }} + replyingToName={formattedReplyingToName} + /> + + +
+ ); +} diff --git a/components/CommentsDrawer/CommentGroup.tsx b/components/CommentsDrawer/CommentGroup.tsx new file mode 100644 index 00000000..84488c41 --- /dev/null +++ b/components/CommentsDrawer/CommentGroup.tsx @@ -0,0 +1,154 @@ +import { Box, Button, DefaultProps, Group, Space } from "@mantine/core"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import NoteActionLike from "components/NoteAction/NoteActionLike"; +import NoteActionMore from "components/NoteAction/NoteActionMore"; +import NoteActionZap from "components/NoteActionZap/NoteActionZap"; +import NoteContent from "components/NoteContent/NoteContent"; +import { + RelativeTime, + UserDetailsAnchorWrapper, + UserDetailsAvatar, + UserDetailsDisplayName, + UserDetailsName, +} from "components/NoteHeader/NoteHeader"; +import { Comment, useComments } from "ndk/NDKCommentsProvider"; +import { EventProvider, useEvent } from "ndk/NDKEventProvider"; +import { useEffect, useState } from "react"; + +type CommentProps = { + comment: Comment; +}; + +export default function CommentGroup({ comment }: CommentProps) { + return ( + + + + + + + ); +} + +const CommentChildren = ({ events }: { events: NDKEvent[] }) => { + const { highlightedEvent } = useComments(); + const [isShowingReplies, setIsShowingReplies] = useState(false); + + useEffect(() => { + if (events.find((event) => event.id === highlightedEvent?.id)) { + setIsShowingReplies(true); + } + }, [events, highlightedEvent]); + + const handleShowRepliesClick = () => { + setIsShowingReplies(true); + }; + + if (!events.length) return null; + + return isShowingReplies ? ( + + {events.map((event) => ( + + + + ))} + + ) : ( + + ); +}; + +const CommentView = ({ isReply = false }: { isReply?: boolean }) => { + const { event } = useEvent(); + const { highlightedEvent } = useComments(); + const isHighlighted = event.id === highlightedEvent?.id; + + return ( + + + + + + + + ); +}; + +const CommentHeader = ({ + isReply, + ...rest +}: DefaultProps & { isReply: boolean }) => { + return ( + + + + + + + + + + ); +}; + +const CommentContent = ({ isReply = false }: { isReply?: boolean }) => { + const { event } = useEvent(); + + return ; +}; + +const CommentActions = ({ isReply = false }: { isReply?: boolean }) => { + return ( + + + + + + ); +}; + +const CommentActionReply = () => { + const { setReplyingTo, draftEditorRef } = useComments(); + const { event } = useEvent(); + + const handleClick = () => { + setReplyingTo(event); + draftEditorRef?.current?.focus(); + }; + + return ( + + ); +}; diff --git a/components/CommentsDrawer/CommentsDrawer.tsx b/components/CommentsDrawer/CommentsDrawer.tsx new file mode 100644 index 00000000..5d3a8638 --- /dev/null +++ b/components/CommentsDrawer/CommentsDrawer.tsx @@ -0,0 +1,81 @@ +import { Box, Group, Text } from "@mantine/core"; +import Drawer, { DrawerProps } from "components/Drawer/Drawer"; +import CommentsFeed from "./CommentsFeed"; +import { useEvent } from "ndk/NDKEventProvider"; +import { CommentsProvider, useComments } from "ndk/NDKCommentsProvider"; +import CommentForm from "./CommentForm"; +import { isMobile } from "react-device-detect"; + +export default function CommentsDrawer(props: DrawerProps) { + const { event } = useEvent(); + + return ( + ({ + drawer: { + borderTopWidth: 1, + borderTopStyle: "solid", + borderTopColor: theme.colors.purple[4], + boxShadow: "0px -8px 32px 0px #2E1F4D", + }, + })} + > + + + {isMobile ? ( + <> + + + + ) : ( + <> + + + + )} + + + ); +} + +const CommentsDrawerHeader = () => { + const { comments } = useComments(); + const commentsCount = + (comments?.length ?? 0) + + (comments?.reduce((prev, curr) => prev + curr.children.length, 0) ?? 0); + + return ( + ({ + borderBottom: `1px solid ${theme.colors.gray[6]}`, + })} + > + + Comments{" "} + {comments && ( + + {commentsCount} + + )} + + + ); +}; diff --git a/components/CommentsDrawer/CommentsFeed.tsx b/components/CommentsDrawer/CommentsFeed.tsx new file mode 100644 index 00000000..1ab87e96 --- /dev/null +++ b/components/CommentsDrawer/CommentsFeed.tsx @@ -0,0 +1,81 @@ +import { Box, Center, Loader, Stack, Text } from "@mantine/core"; +import { useComments } from "ndk/NDKCommentsProvider"; +import CommentGroup from "./CommentGroup"; +import { use, useEffect, useState } from "react"; +import { StarLineIcon, StarSolidIcon, StemIcon } from "icons/StemstrIcon"; + +export default function CommentsFeed() { + const { comments } = useComments(); + const [isInitialLoadComplete, setIsInitialLoadComplete] = useState(false); + + useEffect(() => { + const initialLoadTimeout = setTimeout(() => { + setIsInitialLoadComplete(true); + }, 2000); + + return () => { + clearTimeout(initialLoadTimeout); + }; + }, []); + + return ( + ({ + borderBottom: `1px solid ${theme.colors.gray[6]}`, + overflowY: "scroll", + })} + > + {comments ? ( + comments.map((comment) => ( + + )) + ) : ( +
+ {isInitialLoadComplete ? ( + + ) : ( + + )} +
+ )} +
+ ); +} + +const CommentsFeedNullState = () => { + return ( + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + It’s too quiet here! + + + Be the first to share your thoughts on this by adding a comment + +
+ ); +}; diff --git a/components/DraftEditor/DraftEditor.tsx b/components/DraftEditor/DraftEditor.tsx new file mode 100644 index 00000000..297c67ed --- /dev/null +++ b/components/DraftEditor/DraftEditor.tsx @@ -0,0 +1,60 @@ +import { BoxProps, Button, Flex, Text } from "@mantine/core"; +import DraftEditorTextarea from "./DraftEditorTextarea"; +import { useDraftEditor } from "./DraftEditorProvider"; +import { useEffect } from "react"; + +type DraftEditorProps = BoxProps & { + replyingToName?: string; + onChange: (value: string) => void; +}; + +export default function DraftEditor({ + replyingToName, + onChange, + ...rest +}: DraftEditorProps) { + const { canPost, rawNoteContent } = useDraftEditor(); + + useEffect(() => { + onChange(rawNoteContent); + }, [rawNoteContent]); + + return ( + ({ + border: "1px solid", + borderColor: theme.colors.gray[4], + borderRadius: 8, + flexGrow: 1, + })} + {...rest} + > + {replyingToName && ( + + {replyingToName} + + )} + + {canPost && ( + + )} + + ); +} diff --git a/components/DraftEditor/DraftEditorProvider.tsx b/components/DraftEditor/DraftEditorProvider.tsx new file mode 100644 index 00000000..01995322 --- /dev/null +++ b/components/DraftEditor/DraftEditorProvider.tsx @@ -0,0 +1,422 @@ +import { + createContext, + type PropsWithChildren, + useContext, + useState, + useRef, + RefObject, + Dispatch, + SetStateAction, + useCallback, + useMemo, + useEffect, +} from "react"; +import { NDKEvent, NDKUser, mergeEvent } from "@nostr-dev-kit/ndk"; +import { noop } from "utils/common"; +import { + CompositeDecorator, + ContentBlock, + ContentState, + EditorState, + Modifier, +} from "draft-js"; +import { Box } from "@mantine/core"; +import { useUsers } from "ndk/hooks/useUsers"; +import { profileEventsCache } from "ndk/inMemoryCacheAdapter"; +import { useNDK } from "ndk/NDKProvider"; +import { nip19 } from "nostr-tools"; + +export enum DraftEntityType { + Mention = "MENTION", +} + +type MentionEntityData = { + mention: NDKUser; +}; + +interface DraftEditorContextProps { + editorState: EditorState; + setEditorState: Dispatch>; + replyingTo?: NDKEvent; +} + +type HandleMentionFunc = (user?: NDKUser) => void; + +type DraftEditorContextType = DraftEditorContextProps & { + contentBodyRef?: RefObject; + mentionQuery?: string; + setMentionQuery: Dispatch>; + handleMention: HandleMentionFunc; + editorState: EditorState; + handleEditorChange: (newEditorState: EditorState) => void; + selectCurrentMentionOption: () => void; + navigateMentionList: (step: number) => void; + mentionOptions: NDKUser[]; + focusedOptionIndex: number; + canPost: boolean; + rawNoteContent: string; +}; + +const DraftEditorContext = createContext({ + handleMention: noop, + setMentionQuery: noop, + handleEditorChange: noop, + editorState: EditorState.createEmpty(), + setEditorState: noop, + selectCurrentMentionOption: noop, + navigateMentionList: noop, + mentionOptions: [], + focusedOptionIndex: 0, + canPost: false, + rawNoteContent: "", +}); + +export const DraftEditorProvider = ({ + editorState, + setEditorState, + replyingTo, + children, +}: PropsWithChildren) => { + const { ndk } = useNDK(); + const [mentionQuery, setMentionQuery] = useState(); + const contentBodyRef = useRef(null); + const rawNoteContent = useMemo(() => { + let content = convertToRawNoteContent(editorState); + return content; + }, [editorState]); + const canPost = Boolean(rawNoteContent.length); + + const handleEditorChange = useCallback( + (newEditorState: EditorState) => { + const contentState = newEditorState.getCurrentContent(); + + // Get the current selection + const selection = newEditorState.getSelection(); + + // Get the current block + const currentBlock = contentState.getBlockForKey( + selection.getAnchorKey() + ); + + // Get the selection's anchor offset + const anchorOffset = selection.getAnchorOffset(); + + // Get the current block's text + const text = currentBlock.getText().slice(0, anchorOffset); + + // Check if the cursor is within a mention entity + let isCursorWithinMentionEntity = false; + currentBlock.findEntityRanges( + (character) => { + const entityKey = character.getEntity(); + return ( + entityKey !== null && + contentState.getEntity(entityKey).getType() === + DraftEntityType.Mention + ); + }, + (start, end) => { + if (anchorOffset >= start && anchorOffset <= end) { + isCursorWithinMentionEntity = true; + } + } + ); + + const trigger = /(^|\s)\@[\w]*$/; // Check if the last word starts with "@" (mention trigger) + + if (trigger.test(text) && !isCursorWithinMentionEntity) { + const query = text.match(/\@([\w]*)$/)![1]; // Extract the mention query + setMentionQuery(query); + } else { + setMentionQuery(undefined); + } + + setEditorState(newEditorState); + }, + [setMentionQuery] + ); + + const handleMention = (mention?: NDKUser) => { + if (!mention || !editorState) return; + + const mentionText = `@${mention.profile?.name}`; + + const contentState = editorState.getCurrentContent(); + + // Get the current selection + const selection = editorState.getSelection(); + + // Get the current block's text + const blockText = contentState + .getBlockForKey(selection.getAnchorKey()) + .getText(); + + // Find the start position of the currently typed mention + const mentionStart = blockText.lastIndexOf( + "@", + selection.getAnchorOffset() - 1 + ); + + // Calculate the end position of the currently typed mention + const mentionEnd = mentionStart + mentionText.length; + + // Create a new content state with the selected mention as an entity + const entityData: MentionEntityData = { mention: mention }; + const contentStateWithEntity = contentState.createEntity( + DraftEntityType.Mention, + "IMMUTABLE", + entityData + ); + + // Get the entity key for the mention + const entityKey = contentStateWithEntity.getLastCreatedEntityKey(); + + // Create a new content state with the mention entity + const contentWithMention = Modifier.replaceText( + contentStateWithEntity, + selection.merge({ + anchorOffset: mentionStart, + focusOffset: + mentionStart + blockText.slice(mentionStart).search(/\s|$/), + }), + mentionText, + undefined, + entityKey + ); + + // Insert a space character after the "MENTION" entity + const contentWithSpace = Modifier.insertText( + contentWithMention, + selection.merge({ + anchorOffset: mentionEnd, + focusOffset: mentionEnd, + }), + " " + ); + + // Create a new editor state with the updated content state + const newEditorState = EditorState.push( + editorState, + contentWithSpace, + "insert-characters" + ); + + // Move the cursor to the end of the mention + const updatedSelection = selection.merge({ + anchorOffset: mentionEnd + 1, // +1 to account for space + focusOffset: mentionEnd + 1, + }); + + const finalEditorState = EditorState.forceSelection( + newEditorState, + updatedSelection + ); + + handleEditorChange(finalEditorState); + }; + + const [focusedOptionIndex, setFocusedOptionIndex] = useState(0); + const mentionOptionPubkeys = useMemo(() => { + const users: NDKUser[] = []; + const normalizedQuery = mentionQuery?.toLowerCase() ?? ""; + if (!normalizedQuery) return []; + for (const key in profileEventsCache) { + if (profileEventsCache.hasOwnProperty(key)) { + const ndkEvent = new NDKEvent(ndk, profileEventsCache[key]); + const ndkUser = ndkEvent.author; + ndkUser.profile = mergeEvent(ndkEvent, {}); + if ( + ndkUser.profile?.name?.toLowerCase().includes(normalizedQuery) || + ndkUser.profile?.displayName?.toLowerCase().includes(normalizedQuery) + ) + users.push(ndkUser); + } + } + // sort by displayName starts with query + users.sort((a, b) => { + return ( + Number(b.profile?.displayName?.startsWith(normalizedQuery)) - + Number(a.profile?.displayName?.startsWith(normalizedQuery)) + ); + }); + // sort by name starts with query + users.sort((a, b) => { + return ( + Number(b.profile?.name?.startsWith(normalizedQuery)) - + Number(a.profile?.name?.startsWith(normalizedQuery)) + ); + }); + return users.map((user) => user.hexpubkey()); + }, [mentionQuery]); + const mentionOptions = useUsers(mentionOptionPubkeys); + const selectCurrentMentionOption = useCallback(() => { + handleMention(mentionOptions[focusedOptionIndex]); + }, [mentionOptions]); + const navigateMentionList = useCallback( + (step: number) => { + if (mentionOptions.length > 0) { + let newIndex = + (focusedOptionIndex + step + mentionOptions.length) % + mentionOptions.length; + setFocusedOptionIndex(newIndex); + } + }, + [focusedOptionIndex, mentionOptions.length, setFocusedOptionIndex] + ); + + useEffect(() => { + setFocusedOptionIndex(0); + }, [mentionQuery]); + + return ( + + {children} + + ); +}; + +type RawDraftEntity = { + type: string; + mutability: "IMMUTABLE" | "MUTABLE" | "SEGMENTED"; + data: Record; +}; + +type BlockPart = { + start: number; + end: number; + text?: string; + entity?: RawDraftEntity; +}; + +function convertToRawNoteContent(editorState: EditorState): string { + let content = ""; + const contentState = editorState.getCurrentContent(); + const contentBlocks = contentState.getBlocksAsArray(); + for (let [index, contentBlock] of contentBlocks.entries()) { + const currentBlockParts: BlockPart[] = []; + const text = contentBlock.getText(); + contentBlock.findEntityRanges( + (character) => { + const entityKey = character.getEntity(); + return entityKey !== null; + }, + (start, end) => { + if (!currentBlockParts.length) { + currentBlockParts.push({ + start: 0, + end: start, + text: text.substring(0, start), + }); + } else { + const textStart = currentBlockParts[currentBlockParts.length - 1].end; + const textEnd = start; + currentBlockParts.push({ + start: textStart, + end: textEnd, + text: text.substring(textStart, textEnd), + }); + } + const entityKey = contentBlock.getEntityAt(start); + const entity = contentState.getEntity(entityKey); + currentBlockParts.push({ + start, + end, + entity: { + type: entity.getType(), + mutability: entity.getMutability(), + data: entity.getData(), + }, + }); + } + ); + const textStart = currentBlockParts.length + ? currentBlockParts[currentBlockParts.length - 1].end + : 0; + currentBlockParts.push({ + start: textStart, + end: text.length, + text: text.substring(textStart), + }); + + if (index > 0) { + content += "\n"; + } + currentBlockParts.forEach((blockPart) => { + if (blockPart.text) { + content += blockPart.text; + } else if (blockPart.entity) { + content += entityToText(blockPart.entity); + } + }); + } + + return content; +} + +function entityToText(entity: RawDraftEntity): string { + let text = ""; + + switch (entity.type) { + case DraftEntityType.Mention: + let data = entity.data as MentionEntityData; + const npubEncoded = nip19.npubEncode(data.mention.hexpubkey()); + text = `nostr:${npubEncoded}`; + break; + } + + return text; +} + +function mentionStrategy( + contentBlock: ContentBlock, + callback: (start: number, end: number) => void +) { + contentBlock.findEntityRanges((character) => { + const entityKey = character.getEntity(); + return ( + entityKey !== null && + ContentState.createFromText("").getEntity(entityKey).getType() === + DraftEntityType.Mention + ); + }, callback); +} + +function MentionSpan(props: PropsWithChildren) { + return ( + + {props.children} + + ); +} + +export const draftEditorDecorator = new CompositeDecorator([ + { + strategy: mentionStrategy, + component: MentionSpan, + }, +]); + +export const useDraftEditor = () => { + const context = useContext(DraftEditorContext); + if (context === undefined) { + throw new Error("useEvent must be used within an EventProvider"); + } + return context; +}; diff --git a/components/DraftEditor/DraftEditorTextarea.tsx b/components/DraftEditor/DraftEditorTextarea.tsx new file mode 100644 index 00000000..d02842b8 --- /dev/null +++ b/components/DraftEditor/DraftEditorTextarea.tsx @@ -0,0 +1,83 @@ +import { KeyboardEvent, useRef } from "react"; +import { + DraftHandleValue, + Editor, + EditorState, + getDefaultKeyBinding, +} from "draft-js"; +import { Box } from "@mantine/core"; +import { useDraftEditor } from "./DraftEditorProvider"; +import { useComments } from "ndk/NDKCommentsProvider"; + +export default function DraftEditorTextarea() { + const { + mentionQuery, + editorState, + handleEditorChange, + selectCurrentMentionOption, + navigateMentionList, + rawNoteContent, + } = useDraftEditor(); + const { setReplyingTo, rootEvent, draftEditorRef } = useComments(); + + const handleKeyCommand = (command: string): DraftHandleValue => { + switch (command) { + case "decrement-mention-list": + navigateMentionList(-1); + return "handled"; + case "increment-mention-list": + navigateMentionList(1); + return "handled"; + case "reset-replying-to": + if (rootEvent) { + setReplyingTo(rootEvent); + } + return "handled"; + } + + return "not-handled"; + }; + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.code) { + case "ArrowUp": + if (mentionQuery) { + return "decrement-mention-list"; + } + break; + case "ArrowDown": + if (mentionQuery) { + return "increment-mention-list"; + } + break; + case "Backspace": + if (!rawNoteContent) { + return "reset-replying-to"; + } + break; + } + return getDefaultKeyBinding(e); + }; + + const handleReturn = (e: KeyboardEvent, editorState: EditorState) => { + if (mentionQuery) { + selectCurrentMentionOption(); + return "handled"; + } + return "not-handled"; + }; + + return ( + + + + ); +} diff --git a/components/DraftEditor/MentionMenu.tsx b/components/DraftEditor/MentionMenu.tsx new file mode 100644 index 00000000..e8cdb7eb --- /dev/null +++ b/components/DraftEditor/MentionMenu.tsx @@ -0,0 +1,71 @@ +import { Avatar, Box, Group, Stack, Text } from "@mantine/core"; +import { useDraftEditor } from "./DraftEditorProvider"; +import { useUser } from "ndk/hooks/useUser"; +import useProfilePicSrc from "ndk/hooks/useProfilePicSrc"; +import { isMobile } from "react-device-detect"; + +export default function MentionMenu() { + const { mentionQuery, mentionOptions, focusedOptionIndex } = useDraftEditor(); + + if (!mentionQuery) return null; + + const height = 375; + + return ( + + {mentionOptions.map((user, index) => ( + + ))} + + ); +} + +const MentionMenuOption = ({ + pubkey, + isFocused, +}: { + pubkey: string; + isFocused: boolean; +}) => { + const { handleMention } = useDraftEditor(); + const user = useUser(pubkey); + const src = useProfilePicSrc(user); + + return ( + handleMention(user)} + p="md" + spacing={12} + bg={isFocused ? "dark.7" : undefined} + sx={(theme) => ({ + cursor: "pointer", + "&:hover": { + backgroundColor: theme.colors.dark[7], + }, + })} + > + + + + {user?.profile?.displayName || user?.profile?.name} + + + @{user?.profile?.name || user?.profile?.displayName} + + + + ); +}; diff --git a/components/Drawer/Drawer.styles.js b/components/Drawer/Drawer.styles.js new file mode 100644 index 00000000..f9152508 --- /dev/null +++ b/components/Drawer/Drawer.styles.js @@ -0,0 +1,21 @@ +import { createStyles } from "@mantine/core"; +import { hasNotch, isPwa } from "utils/common"; + +const useStyles = createStyles((theme, _params, getRef) => ({ + overlay: { + backgroundColor: `${theme.colors.dark[7]} !important`, + backdropFilter: "blur(16px)", + opacity: `${0.5} !important`, + }, + drawer: { + backgroundColor: theme.colors.dark[8], + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + maxWidth: 600, + margin: "auto", + paddingTop: "0 !important", + paddingBottom: isPwa() && hasNotch() ? "32px !important" : undefined, + }, +})); + +export default useStyles; diff --git a/components/Drawer/Drawer.tsx b/components/Drawer/Drawer.tsx index 49888f64..1e8a08b3 100644 --- a/components/Drawer/Drawer.tsx +++ b/components/Drawer/Drawer.tsx @@ -2,7 +2,6 @@ import { Drawer as BaseDrawer, type DrawerProps as BaseDrawerProp, } from "@mantine/core"; -import withStopClickPropagation from "../../utils/hoc/withStopClickPropagation"; import { type PropsWithChildren, type SyntheticEvent, @@ -12,8 +11,7 @@ import { import DrawerHandle from "./DrawerHandle"; import { noop } from "../../utils/common"; import { useMediaQuery } from "@mantine/hooks"; - -const MantineDrawer = withStopClickPropagation(BaseDrawer); +import useStyles from "components/Drawer/Drawer.styles"; export interface DrawerProps extends BaseDrawerProp { onDragEnd?: () => void; @@ -22,6 +20,9 @@ export interface DrawerProps extends BaseDrawerProp { const Drawer = ({ onDragEnd = noop, children, + withCloseButton, + position, + trapFocus, ...rest }: PropsWithChildren) => { const [isDragging, setIsDragging] = useState(false); @@ -31,6 +32,7 @@ const Drawer = ({ const isOpened = rest.opened; const containerRef = useRef(null); const [size, setSize] = useState("auto"); + const { classes } = useStyles(); const getClientY = (event: SyntheticEvent) => { if (event.nativeEvent instanceof TouchEvent) { @@ -80,7 +82,18 @@ const Drawer = ({ }; return ( - + e.stopPropagation()} + position={position || "bottom"} + withCloseButton={withCloseButton || false} + trapFocus={trapFocus || false} + classNames={{ + overlay: classes.overlay, + drawer: classes.drawer, + }} + {...rest} + size={size} + >
{children}
-
+ ); }; diff --git a/components/LoginForm/TermsOfService.tsx b/components/LoginForm/TermsOfService.tsx index ac020b6f..ff922767 100644 --- a/components/LoginForm/TermsOfService.tsx +++ b/components/LoginForm/TermsOfService.tsx @@ -19,6 +19,7 @@ export default function TermsOfService() { onClose={close} withCloseButton={false} onDragEnd={close} + padding="md" styles={(theme: MantineTheme) => ({ overlay: { backgroundColor: `${theme.colors.dark[7]} !important`, @@ -31,7 +32,6 @@ export default function TermsOfService() { borderTopRightRadius: 40, maxWidth: 600, margin: "auto", - padding: `0 16px 24px 16px !important`, }, })} > diff --git a/components/NoteAction/NoteActionComment.tsx b/components/NoteAction/NoteActionComment.tsx index 9d76c71f..444738f6 100644 --- a/components/NoteAction/NoteActionComment.tsx +++ b/components/NoteAction/NoteActionComment.tsx @@ -2,16 +2,17 @@ import { Group, Text } from "@mantine/core"; import { CommentIcon } from "icons/StemstrIcon"; import NoteAction from "./NoteAction"; import { useEvent } from "../../ndk/NDKEventProvider"; -import { openSheet } from "../../store/Sheets"; -import { useDispatch, useSelector } from "react-redux"; +import { useSelector } from "react-redux"; import useAuth from "hooks/useAuth"; import { AppState } from "../../store/Store"; import { selectNoteState } from "../../store/Notes"; +import { useDisclosure } from "@mantine/hooks"; +import CommentsDrawer from "components/CommentsDrawer/CommentsDrawer"; const NoteActionComment = () => { const { event } = useEvent(); - const dispatch = useDispatch(); const { guardAuth, guardSubscribed } = useAuth(); + const [opened, { open, close }] = useDisclosure(false); const { commentCount } = useSelector((state: AppState) => selectNoteState(state, event.id) ); @@ -19,24 +20,21 @@ const NoteActionComment = () => { const handleClickComment = () => { if (!guardAuth()) return; if (!guardSubscribed()) return; - - dispatch( - openSheet({ - sheetKey: "postSheet", - replyingTo: event.rawEvent(), - }) - ); + open(); }; return ( - - - {" "} - - {commentCount > 0 ? commentCount : ""} - - - + <> + + + + {" "} + + {commentCount > 0 ? commentCount : ""} + + + + ); }; diff --git a/components/NoteAction/NoteActionLike.tsx b/components/NoteAction/NoteActionLike.tsx index ff4905da..db62a4ab 100644 --- a/components/NoteAction/NoteActionLike.tsx +++ b/components/NoteAction/NoteActionLike.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Group, Text } from "@mantine/core"; +import { DefaultProps, Group, Text } from "@mantine/core"; import { motion, useAnimation } from "framer-motion"; import { useDispatch, useSelector } from "react-redux"; import { Kind } from "nostr-tools"; @@ -13,7 +13,11 @@ import useAuth from "hooks/useAuth"; import { currentUserLikedNote, selectNoteState } from "../../store/Notes"; import { AppState } from "../../store/Store"; -const NoteActionLike = () => { +type NoteActionLikeProps = DefaultProps & { + size?: number; +}; + +const NoteActionLike = ({ size = 18, c = "gray.1" }: NoteActionLikeProps) => { const dispatch = useDispatch(); const { ndk } = useNDK(); const { event } = useEvent(); @@ -70,16 +74,14 @@ const NoteActionLike = () => { ({ transition: "color 1s ease", - color: isLikedByCurrentUser - ? theme.colors.red[5] - : theme.colors.gray[1], })} noWrap > - + {" "} {reactionCount > 0 && {reactionCount}} diff --git a/components/NoteAction/NoteActionMore.tsx b/components/NoteAction/NoteActionMore.tsx index fdff77b4..841704e6 100644 --- a/components/NoteAction/NoteActionMore.tsx +++ b/components/NoteAction/NoteActionMore.tsx @@ -13,12 +13,16 @@ import { MoreIcon, ShareIcon, } from "icons/StemstrIcon"; -import withStopClickPropagation from "utils/hoc/withStopClickPropagation"; import { useEvent } from "../../ndk/NDKEventProvider"; import { selectUserPreferencesState } from "store/UserPreferences"; import { useSelector } from "react-redux"; +import { MouseEvent } from "react"; -const NoteActionMore = () => { +type NoteActionMoreProps = { + size: number; +}; + +const NoteActionMore = ({ size = 24 }: NoteActionMoreProps) => { const [opened, { open, close }] = useDisclosure(false); const { userPreferences } = useSelector(selectUserPreferencesState); @@ -79,8 +83,14 @@ const NoteActionMore = () => { Close -
({ cursor: "pointer" })}> - +
{ + e.stopPropagation(); + open(); + }} + sx={(theme) => ({ cursor: "pointer" })} + > +
); @@ -187,4 +197,4 @@ const NoteActionMoreShare = () => { ); }; -export default withStopClickPropagation(NoteActionMore); +export default NoteActionMore; diff --git a/components/NoteActionRow/NoteActionRow.tsx b/components/NoteActionRow/NoteActionRow.tsx index ec1e7cb7..ffa9a90a 100644 --- a/components/NoteActionRow/NoteActionRow.tsx +++ b/components/NoteActionRow/NoteActionRow.tsx @@ -1,128 +1,10 @@ import { Group } from "@mantine/core"; -import { useMemo, useEffect, useCallback, useState } from "react"; import NoteActionComment from "../NoteAction/NoteActionComment"; import NoteActionLike from "../NoteAction/NoteActionLike"; import NoteActionZap from "../NoteActionZap/NoteActionZap"; -import { useEvent } from "../../ndk/NDKEventProvider"; -import { Kind } from "nostr-tools"; -import { - NDKEvent, - NDKZapInvoice, - zapInvoiceFromEvent, -} from "@nostr-dev-kit/ndk"; -import { createRelaySet } from "../../ndk/utils"; -import { useNDK } from "../../ndk/NDKProvider"; -import { useDispatch, useSelector } from "react-redux"; -import { - setReactionCount, - setIsLikedByCurrentUser, - setCommentCount, - setZapsAmountTotal, - selectNoteState, - setIsZappedByCurrentUser, - setRepostCount, - setIsRepostedByCurrentUser, -} from "../../store/Notes"; -import { selectAuthState } from "../../store/Auth"; -import { DEFAULT_RELAY_URLS } from "../../constants"; -import { AppState } from "../../store/Store"; import NoteActionRepost from "components/NoteAction/NoteActionRepost"; const NoteActionRow = () => { - const dispatch = useDispatch(); - const auth = useSelector(selectAuthState); - const { ndk } = useNDK(); - const { event } = useEvent(); - const noteId = event.id; - const { zapsAmountTotal, reactionCount, commentCount } = useSelector( - (state: AppState) => selectNoteState(state, noteId) - ); - const filter = useMemo( - () => ({ - kinds: [Kind.Text, 1808 as Kind, Kind.Reaction, Kind.Zap, 6, 16], - "#e": [noteId], - }), - [noteId] - ); - const [hasFetchedMetaEvents, sethasFetchedMetaEvents] = useState(false); - const dispatchData = useCallback( - async (events: NDKEvent[]) => { - const reactions = events.filter(({ kind }) => kind === Kind.Reaction); - const reposts = events.filter( - ({ kind }) => kind && [6, 16].includes(kind) - ); - const newCommentCount = events.filter(({ kind, content }) => - [Kind.Text, 1808].includes(kind as Kind) - ).length; - const validZapInvoices = events - .map(zapInvoiceFromEvent) - .filter((zi) => zi !== null) as NDKZapInvoice[]; - const getZapsAmountTotal = () => { - return validZapInvoices.reduce((acc, { amount: amountInMillisats }) => { - const amount = amountInMillisats ? amountInMillisats / 1000 : 0; - - return acc + amount; - }, 0); - }; - const newZapsAmountTotal = getZapsAmountTotal(); - - // sometimes the subscription is not returning the right amounts after initial load. - // this makes sure it doesn't override the previously loaded values since the new values should not be less - // than the previous values. - if ( - commentCount > newCommentCount || - reactionCount > reactions.length || - zapsAmountTotal > newZapsAmountTotal - ) { - return; - } - - dispatch(setReactionCount({ id: noteId, value: reactions.length })); - dispatch( - setIsLikedByCurrentUser({ - id: noteId, - value: Boolean(reactions.find((ev) => ev.pubkey === auth.pk)), - }) - ); - dispatch(setRepostCount({ id: noteId, value: reposts.length })); - dispatch( - setIsRepostedByCurrentUser({ - id: noteId, - value: Boolean(reposts.find((ev) => ev.pubkey === auth.pk)), - }) - ); - dispatch(setCommentCount({ id: noteId, value: newCommentCount })); - dispatch(setZapsAmountTotal({ id: noteId, value: newZapsAmountTotal })); - dispatch( - setIsZappedByCurrentUser({ - id: noteId, - value: Boolean( - validZapInvoices.find(({ zappee }) => zappee === auth.pk) - ), - }) - ); - }, - [auth.pk, dispatch, noteId, commentCount, reactionCount, zapsAmountTotal] - ); - - useEffect(() => { - if (!ndk || hasFetchedMetaEvents) { - return; - } - sethasFetchedMetaEvents(true); - - const relaySet = createRelaySet( - [...DEFAULT_RELAY_URLS, process.env.NEXT_PUBLIC_STEMSTR_RELAY as string], - ndk - ); - - ndk - .fetchEvents(filter, {}, relaySet) - .then((events) => Array.from(events)) - .then(dispatchData) - .catch(console.error); - }, [ndk, filter, dispatchData, hasFetchedMetaEvents]); - return ( diff --git a/components/NoteActionZap/NoteActionZap.tsx b/components/NoteActionZap/NoteActionZap.tsx index 3d41b864..8b176f2e 100644 --- a/components/NoteActionZap/NoteActionZap.tsx +++ b/components/NoteActionZap/NoteActionZap.tsx @@ -1,4 +1,4 @@ -import { Space, Text, Group } from "@mantine/core"; +import { Text, Group, DefaultProps } from "@mantine/core"; import { ZapIcon } from "icons/StemstrIcon"; import NoteAction from "components/NoteAction/NoteAction"; import { useEvent } from "ndk/NDKEventProvider"; @@ -41,7 +41,7 @@ const ZapsAmountTotal = () => { ) : null; }; -const NoteActionContentWithZapWizard = () => { +const NoteActionContentWithZapWizard = ({ size, c }: NoteActionZapProps) => { const { start } = useZapWizard(); const { event } = useEvent(); const { isZappedByCurrentUser } = useSelector((state: AppState) => @@ -53,14 +53,10 @@ const NoteActionContentWithZapWizard = () => { ({ - color: isZappedByCurrentUser - ? theme.colors.orange[5] - : theme.colors.gray[1], - })} + c={isZappedByCurrentUser ? "orange.5" : c} noWrap > - + @@ -68,7 +64,11 @@ const NoteActionContentWithZapWizard = () => { ); }; -const NoteActionZap = () => { +type NoteActionZapProps = DefaultProps & { + size?: number; +}; + +const NoteActionZap = ({ size = 18, c = "gray.1" }: NoteActionZapProps) => { const { event } = useEvent(); const zapRecipient = useUser(event.pubkey); let isZappable: boolean | undefined = false; @@ -82,7 +82,7 @@ const NoteActionZap = () => { return isZappable && zapRecipient ? ( - + ) : null; }; diff --git a/components/NoteContent/NoteContent.tsx b/components/NoteContent/NoteContent.tsx index 93ea549c..0079d6a0 100644 --- a/components/NoteContent/NoteContent.tsx +++ b/components/NoteContent/NoteContent.tsx @@ -1,5 +1,5 @@ import { Fragment, type MouseEvent } from "react"; -import { Text, Anchor } from "@mantine/core"; +import { Text, Anchor, DefaultProps } from "@mantine/core"; import MentionLink from "./MentionLink"; import { NPUB_NOSTR_URI_REGEX } from "../../constants"; import useStyles from "./NoteContent.styles"; @@ -7,7 +7,9 @@ import useStyles from "./NoteContent.styles"; const HYPERLINK_REGEX = /(https?:\/\/[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|])/; -export const NoteContent = ({ content }: { content: string }) => { +type NoteContentProps = DefaultProps & { content: string }; + +export const NoteContent = ({ content, ...rest }: NoteContentProps) => { const { classes } = useStyles(); const formattingRegEx = new RegExp( `(?:${NPUB_NOSTR_URI_REGEX.source}|${HYPERLINK_REGEX.source})`, @@ -42,7 +44,7 @@ export const NoteContent = ({ content }: { content: string }) => { }); return ( - + {formattingRegEx.test(content) ? formattedContent : content} ); diff --git a/components/NoteHeader/NoteHeader.js b/components/NoteHeader/NoteHeader.js index 000ab822..e0ca959f 100644 --- a/components/NoteHeader/NoteHeader.js +++ b/components/NoteHeader/NoteHeader.js @@ -12,7 +12,7 @@ import useNip05 from "ndk/hooks/useNip05"; import { Nip05Status } from "store/Nip05"; import useProfilePicSrc from "ndk/hooks/useProfilePicSrc"; -const UserDetailsAnchorWrapper = ({ children }) => { +export const UserDetailsAnchorWrapper = ({ children }) => { const { event } = useEvent(); return ( @@ -43,15 +43,17 @@ const UserDetailsNip05 = () => { ) : null; }; -const UserDetailsAvatar = () => { +export const UserDetailsAvatar = ({ size = 42 }) => { const { event } = useEvent(); const user = useUser(event.pubkey); const src = useProfilePicSrc(user); - return ; + return ( + + ); }; -const UserDetailsDisplayName = (props) => { +export const UserDetailsDisplayName = (props) => { const { event } = useEvent(); const user = useUser(event.pubkey); @@ -62,7 +64,7 @@ const UserDetailsDisplayName = (props) => { ); }; -const UserDetailsName = (props) => { +export const UserDetailsName = (props) => { const { event } = useEvent(); const user = useUser(event.pubkey); const willDisplay = user?.profile?.name && user?.profile?.displayName; @@ -79,7 +81,7 @@ const UserDetailsName = (props) => { ); }; -const RelativeTime = (props) => { +export const RelativeTime = (props) => { const { event } = useEvent(); return ( diff --git a/components/NoteTags/NoteTags.js b/components/NoteTags/NoteTags.js index 3b5204b0..4a7465c7 100644 --- a/components/NoteTags/NoteTags.js +++ b/components/NoteTags/NoteTags.js @@ -1,5 +1,4 @@ import { Chip, Group } from "@mantine/core"; -import withStopClickPropagation from "../../utils/hoc/withStopClickPropagation"; import { useEvent } from "../../ndk/NDKEventProvider"; import { useRouter } from "next/router"; import { Route } from "../../enums"; @@ -7,9 +6,11 @@ import { Route } from "../../enums"; const NoteTags = ({ classes, ...rest }) => { const { event } = useEvent(); const router = useRouter(); + const tags = event?.tags?.filter((tag) => tag[0] == "t"); - return ( + return tags.length ? ( e.stopPropagation()} position="left" spacing={12} sx={(theme) => ({ @@ -20,26 +21,24 @@ const NoteTags = ({ classes, ...rest }) => { })} {...rest} > - {event?.tags - ?.filter((tag) => tag[0] == "t") - .map((tag, index) => ( - - router.push({ - pathname: Route.Tag, - query: { tag: tag[1] }, - }) - } - > - #{tag[1]} - - ))} + {tags.map((tag, index) => ( + + router.push({ + pathname: Route.Tag, + query: { tag: tag[1] }, + }) + } + > + #{tag[1]} + + ))} - ); + ) : null; }; -export default withStopClickPropagation(NoteTags); +export default NoteTags; diff --git a/components/ProfilePage/SubscriptionStatusDrawer.tsx b/components/ProfilePage/SubscriptionStatusDrawer.tsx index f65f22f5..19ada39a 100644 --- a/components/ProfilePage/SubscriptionStatusDrawer.tsx +++ b/components/ProfilePage/SubscriptionStatusDrawer.tsx @@ -78,6 +78,7 @@ export default function SubscriptionStatusDrawer() { withCloseButton={false} onDragEnd={closeSubscriptionDrawer} trapFocus={false} + padding="md" styles={(theme: MantineTheme) => ({ overlay: { backgroundColor: `${theme.colors.dark[7]} !important`, @@ -90,7 +91,6 @@ export default function SubscriptionStatusDrawer() { borderTopRightRadius: 40, maxWidth: 600, margin: "auto", - padding: `0 16px 24px 16px !important`, color: theme.colors.dark[8], }, })} diff --git a/components/SubscribeWizard/SubscribeDrawer.tsx b/components/SubscribeWizard/SubscribeDrawer.tsx index 5f4b1a9f..6d35d8ac 100644 --- a/components/SubscribeWizard/SubscribeDrawer.tsx +++ b/components/SubscribeWizard/SubscribeDrawer.tsx @@ -12,6 +12,7 @@ export default function SubscribeDrawer({ ({ overlay: { backgroundColor: `${theme.colors.dark[7]} !important`, @@ -24,7 +25,6 @@ export default function SubscribeDrawer({ borderTopRightRadius: 40, maxWidth: 600, margin: "auto", - padding: `0 16px 24px 16px !important`, }, })} onDragEnd={end} diff --git a/components/ZapWizard/ZapDrawer.tsx b/components/ZapWizard/ZapDrawer.tsx index 6d58daad..dd44b590 100644 --- a/components/ZapWizard/ZapDrawer.tsx +++ b/components/ZapWizard/ZapDrawer.tsx @@ -1,7 +1,6 @@ import { type PropsWithChildren } from "react"; import { useZapWizard } from "./ZapWizardProvider"; import Drawer from "../Drawer/Drawer"; -import { MantineTheme } from "@mantine/core"; interface ZapDrawerProps { isOpen: boolean; @@ -13,32 +12,10 @@ const ZapDrawer = ({ onClose, children, }: PropsWithChildren) => { - const { end, willShowCloseButton } = useZapWizard(); + const { end } = useZapWizard(); return ( - ({ - overlay: { - backgroundColor: `${theme.colors.dark[7]} !important`, - backdropFilter: "blur(16px)", - opacity: `${0.5} !important`, - }, - drawer: { - backgroundColor: theme.colors.dark[8], - borderTopLeftRadius: 40, - borderTopRightRadius: 40, - maxWidth: 600, - margin: "auto", - padding: `0 16px ${willShowCloseButton ? 48 : 24}px 16px !important`, - }, - })} - onDragEnd={end} - > + {children} ); diff --git a/icons/StemstrIcon.js b/icons/StemstrIcon.js index 4b731c4b..d6b457b5 100644 --- a/icons/StemstrIcon.js +++ b/icons/StemstrIcon.js @@ -1,21 +1,15 @@ import Bell from "./Line icons/Alerts & feedback/bell-02.svg"; - import ArrowRight from "./Line icons/Arrows/arrow-right.svg"; import ChevronLeft from "./Line icons/Arrows/chevron-left.svg"; import ChevronRight from "./Line icons/Arrows/chevron-right.svg"; import Download from "./Line icons/Arrows/arrow-down.svg"; import Infinity from "./Line icons/Arrows/infinity.svg"; - import Comment from "./Line icons/Communication/message-square-02.svg"; - import BracketsEllipses from "./Line icons/Development/brackets-ellipses.svg"; import Code from "./Line icons/Development/code-02.svg"; - import AlignLeft from "./Line icons/Editor/align-left.svg"; - import Tags from "./Line icons/Finance & eCommerce/tag-03.svg"; import Wallet from "./Line icons/Finance & eCommerce/wallet-02.svg"; - import AtSign from "./Line icons/General/at-sign.svg"; import Check from "./Line icons/General/check.svg"; import CheckCircle from "./Line icons/General/check-circle.svg"; @@ -36,26 +30,22 @@ import Share from "./Line icons/General/share-02.svg"; import Trash from "./Line icons/General/trash-03.svg"; import XClose from "./Line icons/General/x-close.svg"; import Zap from "./Line icons/General/zap.svg"; - import CameraPlus from "./Line icons/Images/camera-plus.svg"; - import Compass from "./Line icons/Maps & travel/compass-03.svg"; - import AddSound from "./Line icons/Media & devices/music.svg"; import Pause from "./Line icons/Media & devices/pause.svg"; import Play from "./Line icons/Media & devices/play.svg"; import Stop from "./Line icons/Media & devices/stop.svg"; - import Key from "./Line icons/Security/key-01.svg"; import Lock from "./Line icons/Security/lock-01.svg"; - +import StarLine from "./Line icons/Shapes/star-04.svg"; import Follow from "./Line icons/Users/user-right-01.svg"; import Following from "./Line icons/Users/user-check-01.svg"; import Profile from "./Line icons/Users/user-circle.svg"; import Unfollow from "./Line icons/Users/user-left-01.svg"; +import StarSolid from "./Solid icons/Shapes/star-04.svg"; import CalendarSolid from "./Solid icons/Time/calendar.svg"; - import StarsSolid from "./Solid icons/Weather/stars-02.svg"; import Collection from "./collection.svg"; @@ -455,6 +445,22 @@ export const StarsSolidIcon = (props) => { ); }; +export const StarSolidIcon = (props) => { + return ( + + + + ); +}; + +export const StarLineIcon = (props) => { + return ( + + + + ); +}; + export const InfinityIcon = (props) => { return ( diff --git a/ndk/NDKCommentsProvider.tsx b/ndk/NDKCommentsProvider.tsx new file mode 100644 index 00000000..3150fe61 --- /dev/null +++ b/ndk/NDKCommentsProvider.tsx @@ -0,0 +1,147 @@ +import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk"; +import { Kind } from "nostr-tools"; +import { + createContext, + type PropsWithChildren, + useContext, + useState, + useEffect, + Dispatch, + SetStateAction, + useRef, + MutableRefObject, +} from "react"; +import { useNDK } from "./NDKProvider"; +import { parseEventTags } from "./utils"; +import { noop } from "utils/common"; +import { Editor } from "draft-js"; + +export type Comment = { + event: NDKEvent; + children: NDKEvent[]; +}; + +interface CommentsContextProps { + rootEvent: NDKEvent; + enabled: boolean; +} + +type CommentsContextType = { + rootEvent?: NDKEvent; + comments?: Comment[]; + replyingTo?: NDKEvent; + highlightedEvent?: NDKEvent; + setReplyingTo: Dispatch>; + setHighlightedEvent: Dispatch>; + draftEditorRef?: MutableRefObject; +}; + +const CommentsContext = createContext({ + setReplyingTo: noop, + setHighlightedEvent: noop, +}); + +export const CommentsProvider = ({ + children, + rootEvent, + enabled, +}: PropsWithChildren) => { + const { ndk } = useNDK(); + const [events, setEvents] = useState([]); + const [comments, setComments] = useState(); + const [replyingTo, setReplyingTo] = useState(rootEvent); + const [highlightedEvent, setHighlightedEvent] = useState(); + const draftEditorRef = useRef(null); + + useEffect(() => { + if (enabled && ndk) { + const filter: NDKFilter = { + kinds: [Kind.Text], + "#e": [rootEvent.id], + }; + ndk.fetchEvents(filter).then((eventSet) => { + const events = Array.from(eventSet.values()).sort( + (a, b) => b.created_at! - a.created_at! + ); + setEvents(events); + }); + } else { + setComments(undefined); + } + }, [enabled, ndk]); + + useEffect(() => { + if (events.length) { + let comments: Comment[] = events + .filter((event) => { + const { root, reply } = parseEventTags(event); + if (reply && reply[1] === rootEvent.id) return true; + return !reply && root && root[1] === rootEvent.id; + }) + .map((event) => { + const children = events + .filter((child) => { + return eventIsDescendantOf(child, event, events); + }) + .sort((a, b) => a.created_at! - b.created_at!); + return { event, children }; + }); + setComments(comments); + } + }, [events]); + + useEffect(() => { + let timeout: NodeJS.Timeout | undefined; + if (highlightedEvent) { + setEvents((events) => { + if (events.find((event) => event.id === highlightedEvent.id)) + return events; + return [highlightedEvent, ...events]; + }); + timeout = setTimeout(() => { + setHighlightedEvent(undefined); + }, 5000); + } + + return () => { + clearTimeout(timeout); + }; + }, [highlightedEvent]); + + const contextValue: CommentsContextType = { + rootEvent, + comments, + replyingTo, + highlightedEvent, + setReplyingTo, + setHighlightedEvent, + draftEditorRef, + }; + + return ( + + {children} + + ); +}; + +const eventIsDescendantOf = ( + child: NDKEvent, + ancestor: NDKEvent, + events: NDKEvent[] +): boolean => { + const { reply } = parseEventTags(child); + if (!reply) return false; + if (reply[1] === ancestor.id) return true; + const parent = events.find((event) => event.id === reply[1]); + if (!parent) return false; + return eventIsDescendantOf(parent, ancestor, events); +}; + +export const useComments = () => { + const context = useContext(CommentsContext); + if (context === undefined) { + throw new Error("useComments must be used within a CommentsProvider"); + } + return context; +}; diff --git a/ndk/NDKEventProvider.tsx b/ndk/NDKEventProvider.tsx index 44ba3d26..a3debf98 100644 --- a/ndk/NDKEventProvider.tsx +++ b/ndk/NDKEventProvider.tsx @@ -3,10 +3,35 @@ import { type PropsWithChildren, useContext, useMemo, + useState, + useCallback, + useEffect, } from "react"; -import { NDKUser, NDKEvent, NostrEvent } from "@nostr-dev-kit/ndk"; +import { + NDKUser, + NDKEvent, + NostrEvent, + zapInvoiceFromEvent, + NDKZapInvoice, +} from "@nostr-dev-kit/ndk"; import { useUser } from "./hooks/useUser"; import { useNDK } from "./NDKProvider"; +import { useDispatch, useSelector } from "react-redux"; +import { selectAuthState } from "store/Auth"; +import { AppState } from "store/Store"; +import { + selectNoteState, + setCommentCount, + setIsLikedByCurrentUser, + setIsRepostedByCurrentUser, + setIsZappedByCurrentUser, + setReactionCount, + setRepostCount, + setZapsAmountTotal, +} from "store/Notes"; +import { Kind } from "nostr-tools"; +import { createRelaySet } from "./utils"; +import { DEFAULT_RELAY_URLS } from "../constants"; interface EventContextProps { event: NDKEvent; @@ -36,6 +61,98 @@ export const EventProvider = ({ return event; }, [event.kind]); + const dispatch = useDispatch(); + const auth = useSelector(selectAuthState); + const noteId = event.id; + const { zapsAmountTotal, reactionCount, commentCount } = useSelector( + (state: AppState) => selectNoteState(state, noteId) + ); + const filter = useMemo( + () => ({ + kinds: [Kind.Text, 1808 as Kind, Kind.Reaction, Kind.Zap, 6, 16], + "#e": [noteId], + }), + [noteId] + ); + const [hasFetchedMetaEvents, sethasFetchedMetaEvents] = useState(false); + const dispatchData = useCallback( + async (events: NDKEvent[]) => { + const reactions = events.filter(({ kind }) => kind === Kind.Reaction); + const reposts = events.filter( + ({ kind }) => kind && [6, 16].includes(kind) + ); + const newCommentCount = events.filter(({ kind, content }) => + [Kind.Text, 1808].includes(kind as Kind) + ).length; + const validZapInvoices = events + .map(zapInvoiceFromEvent) + .filter((zi) => zi !== null) as NDKZapInvoice[]; + const getZapsAmountTotal = () => { + return validZapInvoices.reduce((acc, { amount: amountInMillisats }) => { + const amount = amountInMillisats ? amountInMillisats / 1000 : 0; + + return acc + amount; + }, 0); + }; + const newZapsAmountTotal = getZapsAmountTotal(); + + // sometimes the subscription is not returning the right amounts after initial load. + // this makes sure it doesn't override the previously loaded values since the new values should not be less + // than the previous values. + if ( + commentCount > newCommentCount || + reactionCount > reactions.length || + zapsAmountTotal > newZapsAmountTotal + ) { + return; + } + + dispatch(setReactionCount({ id: noteId, value: reactions.length })); + dispatch( + setIsLikedByCurrentUser({ + id: noteId, + value: Boolean(reactions.find((ev) => ev.pubkey === auth.pk)), + }) + ); + dispatch(setRepostCount({ id: noteId, value: reposts.length })); + dispatch( + setIsRepostedByCurrentUser({ + id: noteId, + value: Boolean(reposts.find((ev) => ev.pubkey === auth.pk)), + }) + ); + dispatch(setCommentCount({ id: noteId, value: newCommentCount })); + dispatch(setZapsAmountTotal({ id: noteId, value: newZapsAmountTotal })); + dispatch( + setIsZappedByCurrentUser({ + id: noteId, + value: Boolean( + validZapInvoices.find(({ zappee }) => zappee === auth.pk) + ), + }) + ); + }, + [auth.pk, dispatch, noteId, commentCount, reactionCount, zapsAmountTotal] + ); + + useEffect(() => { + if (!ndk || hasFetchedMetaEvents) { + return; + } + sethasFetchedMetaEvents(true); + + const relaySet = createRelaySet( + [...DEFAULT_RELAY_URLS, process.env.NEXT_PUBLIC_STEMSTR_RELAY as string], + ndk + ); + + ndk + .fetchEvents(filter, {}, relaySet) + .then((events) => Array.from(events)) + .then(dispatchData) + .catch(console.error); + }, [ndk, filter, dispatchData, hasFetchedMetaEvents]); + return ( {children} diff --git a/ndk/utils.tsx b/ndk/utils.tsx index 834638ac..ef722bf5 100644 --- a/ndk/utils.tsx +++ b/ndk/utils.tsx @@ -236,6 +236,15 @@ export const getNormalizedUsername = (user?: NDKUser) => { return user?.profile?.name ? `@${user.profile.name}` : ""; }; +export const getFormattedAtName = (user?: NDKUser) => { + return ( + "@" + + (user?.profile?.name || + user?.profile?.displayName || + `${user?.hexpubkey().substring(0, 5)}...`) + ); +}; + export const getFormattedName = (user?: NDKUser) => { if (user?.profile?.name) return `@${user.profile.name}`; return ( @@ -586,3 +595,62 @@ export const createSigner = async ( export const isRootEvent = (event: NDKEvent): boolean => { return !event.tags.find((tag) => tag[0] === "e"); }; + +export const createKind1Event = ( + ndk: NDK, + content: string, + opts: { + hashtags?: string[]; + replyingTo?: NostrEvent; + } +): NDKEvent => { + return createEvent(ndk, Kind.Text, content, opts); +}; + +export const createEvent = ( + ndk: NDK, + kind: Kind, + content: string, + opts: { + hashtags?: string[]; + replyingTo?: NostrEvent; + } +): NDKEvent => { + const created_at = Math.floor(Date.now() / 1000); + + const tags = [ + ["client", "stemstr.app"], + ["stemstr_version", "1.0"], + ]; + + opts.hashtags?.forEach((hashtag) => { + tags.push(["t", hashtag]); + }); + + if (opts.replyingTo?.id) { + const { root } = parseEventTags(new NDKEvent(ndk, opts.replyingTo)); + if (root) { + tags.push(root); + tags.push(["e", opts.replyingTo.id, "", "reply"]); + } else { + tags.push(["e", opts.replyingTo.id, "", "root"]); + } + const pTagPKs = [opts.replyingTo.pubkey]; + opts.replyingTo.tags.forEach((t) => { + if (t[0] === "p") { + pTagPKs.push(t[1]); + } + }); + Array.from(new Set(pTagPKs)).forEach((pk) => { + tags.push(["p", pk]); + }); + } + + const event = new NDKEvent(ndk); + event.kind = kind; + event.created_at = created_at; + event.tags = tags; + event.content = content; + + return event; +}; diff --git a/package.json b/package.json index 6d24b638..a5ffa907 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "axios": "^1.3.4", "cookies-next": "^2.1.1", "dayjs": "^1.11.6", + "draft-js": "^0.11.7", "embla-carousel-react": "^7.0.3", "framer-motion": "^10.11.2", "hls.js": "^1.3.5", @@ -72,6 +73,7 @@ "@testing-library/jest-dom": "^5.16.3", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^14.0.4", + "@types/draft-js": "^0.11.13", "@types/jest": "^29.5.0", "@types/node": "^18.11.4", "@types/react": "18.0.21", diff --git a/pages/_app.tsx b/pages/_app.tsx index c3d62ef2..1f4b1962 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -19,6 +19,8 @@ import { NostrNotificationsProvider } from "ndk/NostrNotificationsProvider"; import { SubscribeWizardProvider } from "components/SubscribeWizard/SubscribeWizardProvider"; import { Analytics } from "@vercel/analytics/react"; +import "../theme/global.css"; + if (process.env.NEXT_PUBLIC_STEMSTR_RELAY) DEFAULT_RELAY_URLS.push(process.env.NEXT_PUBLIC_STEMSTR_RELAY); diff --git a/theme/Theme.tsx b/theme/Theme.tsx index 9800ccdb..c571a365 100644 --- a/theme/Theme.tsx +++ b/theme/Theme.tsx @@ -29,7 +29,7 @@ const stemstrTheme: MantineThemeOverride = { "#EAE2FC", // purple.1 "#BFAAEA", // purple.2 "#BFAAEA", // purple.3 - "#BFAAEA", // purple.4 + "#9747FF", // purple.4 "#865AE2", // purple.5 "#865AE2", // purple.6 "#763AF4", // purple.7 diff --git a/theme/global.css b/theme/global.css new file mode 100644 index 00000000..fe9aa769 --- /dev/null +++ b/theme/global.css @@ -0,0 +1,5 @@ +.public-DraftEditorPlaceholder-inner { + position: absolute; + color: #8a8a8a; + pointer-events: none; +} diff --git a/yarn.lock b/yarn.lock index 3615438d..27952c74 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5047,6 +5047,14 @@ resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz" integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== +"@types/draft-js@^0.11.13": + version "0.11.13" + resolved "https://registry.yarnpkg.com/@types/draft-js/-/draft-js-0.11.13.tgz#6b14115a2c3404248701c4d773feeb0b69ff81f7" + integrity sha512-4cMlNWoKwcRwkA+GtCD53u5Dj78n9Z8hScB5rZYKw4Q3UZxh0h1Fm4ezLgGjAHkhsdrviWQm3MPuUi/jLOuq8A== + dependencies: + "@types/react" "*" + immutable "~3.7.4" + "@types/eslint-scope@^3.7.3": version "3.7.4" resolved "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz" @@ -6406,6 +6414,11 @@ arrify@^2.0.1: resolved "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz" integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + asn1.js@^5.2.0: version "5.4.1" resolved "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz" @@ -7666,6 +7679,11 @@ core-js@^3.0.4, core-js@^3.6.5, core-js@^3.8.2: resolved "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz" integrity sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig== +core-js@^3.6.4: + version "3.32.2" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.32.2.tgz#172fb5949ef468f93b4be7841af6ab1f21992db7" + integrity sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" @@ -7767,6 +7785,13 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: safe-buffer "^5.0.1" sha.js "^2.4.8" +cross-fetch@^3.0.4: + version "3.1.8" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" + integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg== + dependencies: + node-fetch "^2.6.12" + cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -8309,6 +8334,15 @@ dotenv@^8.0.0: resolved "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz" integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g== +draft-js@^0.11.7: + version "0.11.7" + resolved "https://registry.yarnpkg.com/draft-js/-/draft-js-0.11.7.tgz#be293aaa255c46d8a6647f3860aa4c178484a206" + integrity sha512-ne7yFfN4sEL82QPQEn80xnADR8/Q6ALVworbC5UOSzOvjffmYfFsr3xSZtxbIirti14R7Y33EZC5rivpLgIbsg== + dependencies: + fbjs "^2.0.0" + immutable "~3.7.4" + object-assign "^4.1.1" + duplexer2@^0.1.2: version "0.1.4" resolved "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz" @@ -9599,6 +9633,25 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fbjs-css-vars@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8" + integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ== + +fbjs@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-2.0.0.tgz#01fb812138d7e31831ed3e374afe27b9169ef442" + integrity sha512-8XA8ny9ifxrAWlyhAbexXcs3rRMtxWcs3M0lctLfB49jRDHiaxj+Mo0XxbwE7nKZYzgCFoq64FS+WFd4IycPPQ== + dependencies: + core-js "^3.6.4" + cross-fetch "^3.0.4" + fbjs-css-vars "^1.0.0" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + fetch-blob@^3.1.2, fetch-blob@^3.1.4: version "3.2.0" resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" @@ -10701,6 +10754,11 @@ immer@^9.0.16: resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.19.tgz#67fb97310555690b5f9cd8380d38fc0aabb6b38b" integrity sha512-eY+Y0qcsB4TZKwgQzLaE/lqYMlKhv5J9dyd2RhhtGhNo2njPXDqU9XPfcNfa3MIDsdtZt5KlkIsirlo4dHsWdQ== +immutable@~3.7.4: + version "3.7.6" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b" + integrity sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw== + import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz" @@ -12208,7 +12266,7 @@ log-symbols@^4.0.0: chalk "^4.1.0" is-unicode-supported "^0.1.0" -loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -12831,6 +12889,13 @@ node-fetch@^2.6.1, node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" +node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-fetch@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.1.tgz#b3eea7b54b3a48020e46f4f88b9c5a7430d20b2e" @@ -13856,6 +13921,13 @@ promise.prototype.finally@^3.1.0: define-properties "^1.1.3" es-abstract "^1.19.1" +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + prompts@^2.0.1, prompts@^2.4.0: version "2.4.2" resolved "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz" @@ -14927,7 +14999,7 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= @@ -16120,6 +16192,11 @@ typescript@^5.0.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== +ua-parser-js@^0.7.18: + version "0.7.36" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.36.tgz#382c5d6fc09141b6541be2cae446ecfcec284db2" + integrity sha512-CPPLoCts2p7D8VbybttE3P2ylv0OBZEAy7a12DsulIEcAiMtWJy+PBgMXgWDI80D5UwqE8oQPHYnk13tm38M2Q== + ua-parser-js@^1.0.33: version "1.0.35" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.35.tgz#c4ef44343bc3db0a3cbefdf21822f1b1fc1ab011"