diff --git a/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentListFallback.tsx b/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentListFallback.tsx index a008ab8fc7..d8721b2fa6 100644 --- a/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentListFallback.tsx +++ b/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentListFallback.tsx @@ -26,6 +26,7 @@ export function CommentListFallback() { + ); } diff --git a/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentsBottomSheet.tsx b/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentsBottomSheet.tsx index 5571ea8144..fdb561d15c 100644 --- a/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentsBottomSheet.tsx +++ b/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentsBottomSheet.tsx @@ -10,8 +10,9 @@ import { import { View } from 'react-native'; import { Keyboard } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; -import { graphql, useLazyLoadQuery, usePaginationFragment } from 'react-relay'; +import { graphql, useFragment, useLazyLoadQuery, usePaginationFragment } from 'react-relay'; import { useEventComment } from 'src/hooks/useEventComment'; +import { useMentionableMessage } from 'src/hooks/useMentionableMessage'; import { usePostComment } from 'src/hooks/usePostComment'; import { CommentsBottomSheetList } from '~/components/Feed/CommentsBottomSheet/CommentsBottomSheetList'; @@ -21,12 +22,15 @@ import { GalleryBottomSheetModalType, } from '~/components/GalleryBottomSheet/GalleryBottomSheetModal'; import { useSafeAreaPadding } from '~/components/SafeAreaViewWithPadding'; +import { SearchResultsFallback } from '~/components/Search/SearchResultFallback'; +import { SearchResults } from '~/components/Search/SearchResults'; import { Typography } from '~/components/Typography'; import { CommentsBottomSheetConnectedCommentsListFragment$key } from '~/generated/CommentsBottomSheetConnectedCommentsListFragment.graphql'; import { CommentsBottomSheetConnectedCommentsListPaginationQuery } from '~/generated/CommentsBottomSheetConnectedCommentsListPaginationQuery.graphql'; import { CommentsBottomSheetConnectedCommentsListQuery } from '~/generated/CommentsBottomSheetConnectedCommentsListQuery.graphql'; import { CommentsBottomSheetConnectedPostCommentsListFragment$key } from '~/generated/CommentsBottomSheetConnectedPostCommentsListFragment.graphql'; import { CommentsBottomSheetConnectedPostCommentsListQuery } from '~/generated/CommentsBottomSheetConnectedPostCommentsListQuery.graphql'; +import { CommentsBottomSheetQueryFragment$key } from '~/generated/CommentsBottomSheetQueryFragment.graphql'; import { removeNullValues } from '~/shared/relay/removeNullValues'; import useKeyboardStatus from '../../../utils/useKeyboardStatus'; @@ -36,12 +40,29 @@ import { CommentListFallback } from './CommentListFallback'; const SNAP_POINTS = [400]; type CommentsBottomSheetProps = { + activeCommentId?: string; feedId: string; bottomSheetRef: ForwardedRef; type: FeedItemTypes; + queryRef: CommentsBottomSheetQueryFragment$key; }; -export function CommentsBottomSheet({ bottomSheetRef, feedId, type }: CommentsBottomSheetProps) { +export function CommentsBottomSheet({ + activeCommentId, + bottomSheetRef, + feedId, + type, + queryRef, +}: CommentsBottomSheetProps) { + const query = useFragment( + graphql` + fragment CommentsBottomSheetQueryFragment on Query { + ...useMentionableMessageQueryFragment + } + `, + queryRef + ); + const internalRef = useRef(null); const [isOpen, setIsOpen] = useState(false); @@ -58,16 +79,30 @@ export function CommentsBottomSheet({ bottomSheetRef, feedId, type }: CommentsBo const { submitComment: postComment, isSubmittingComment: isSubmittingPostComment } = usePostComment(); + const { + aliasKeyword, + isSelectingMentions, + selectMention, + mentions, + setMessage, + message, + resetMentions, + handleSelectionChange, + } = useMentionableMessage(query); + const handleSubmit = useCallback( (value: string) => { if (type === 'Post') { postComment({ feedId, value, + mentions, onSuccess: () => { Keyboard.dismiss(); }, }); + + resetMentions(); return; } @@ -79,7 +114,7 @@ export function CommentsBottomSheet({ bottomSheetRef, feedId, type }: CommentsBo }, }); }, - [feedId, type, submitComment, postComment] + [feedId, type, mentions, submitComment, postComment, resetMentions] ); const isSubmitting = useMemo(() => { @@ -112,19 +147,52 @@ export function CommentsBottomSheet({ bottomSheetRef, feedId, type }: CommentsBo onChange={() => setIsOpen(true)} android_keyboardInputMode="adjustResize" keyboardBlurBehavior="restore" + onDismiss={resetMentions} > - - Comments - - - - }> - {isOpen && } - + + {isSelectingMentions ? ( + + }> + {}} + blurInputFocus={() => {}} + onSelect={selectMention} + onlyShowTopResults + isMentionSearch + /> + + + ) : ( + + + Comments + + + }> + {isOpen && ( + + )} + + + + )} - {}} /> + {}} + /> ); @@ -133,17 +201,23 @@ export function CommentsBottomSheet({ bottomSheetRef, feedId, type }: CommentsBo type ConnectedCommentsListProps = { type: FeedItemTypes; feedId: string; + activeCommentId?: string; }; -function ConnectedCommentsList({ type, feedId }: ConnectedCommentsListProps) { +function ConnectedCommentsList({ type, feedId, activeCommentId }: ConnectedCommentsListProps) { if (type === 'Post') { - return ; + return ; } - return ; + return ; } -function ConnectedEventCommentsList({ feedId }: { feedId: string }) { +type ConnectedCommentsProps = { + activeCommentId?: string; + feedId: string; +}; + +function ConnectedEventCommentsList({ activeCommentId, feedId }: ConnectedCommentsProps) { const queryRef = useLazyLoadQuery( graphql` query CommentsBottomSheetConnectedCommentsListQuery( @@ -200,7 +274,11 @@ function ConnectedEventCommentsList({ feedId }: { feedId: string }) { return ( {comments.length > 0 ? ( - + ) : ( ( graphql` query CommentsBottomSheetConnectedPostCommentsListQuery( @@ -270,7 +348,11 @@ function ConnectedPostCommentsList({ feedId }: { feedId: string }) { return ( {comments.length > 0 ? ( - + ) : ( removeNullValues(comment.mentions), [comment.mentions]); + if (!comment.comment) { return null; } return ( - + diff --git a/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentsBottomSheetList.tsx b/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentsBottomSheetList.tsx index eb5bc5d6db..bac59acfde 100644 --- a/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentsBottomSheetList.tsx +++ b/apps/mobile/src/components/Feed/CommentsBottomSheet/CommentsBottomSheetList.tsx @@ -1,36 +1,57 @@ import { FlashList, ListRenderItem } from '@shopify/flash-list'; -import { useCallback } from 'react'; -import { View } from 'react-native'; +import { useCallback, useEffect, useRef } from 'react'; import { graphql, useFragment } from 'react-relay'; import { CommentsBottomSheetLine } from '~/components/Feed/CommentsBottomSheet/CommentsBottomSheetLine'; import { CommentsBottomSheetList$key } from '~/generated/CommentsBottomSheetList.graphql'; type CommentsListProps = { + activeCommentId?: string; onLoadMore: () => void; commentRefs: CommentsBottomSheetList$key; }; -export function CommentsBottomSheetList({ commentRefs, onLoadMore }: CommentsListProps) { +export function CommentsBottomSheetList({ + activeCommentId, + commentRefs, + onLoadMore, +}: CommentsListProps) { const comments = useFragment( graphql` fragment CommentsBottomSheetList on Comment @relay(plural: true) { + dbid ...CommentsBottomSheetLineFragment } `, commentRefs ); - const renderItem = useCallback>(({ item: comment }) => { - return ( - - - - ); - }, []); + const ref = useRef>(null); + + useEffect(() => { + if (activeCommentId && ref.current) { + const index = comments.findIndex((comment) => comment.dbid === activeCommentId); + if (index !== -1) { + setTimeout(() => { + if (!ref.current) { + return; + } + ref.current.scrollToIndex({ index, animated: true }); + }, 200); + } + } + }, [activeCommentId, comments]); + + const renderItem = useCallback>( + ({ item: comment }) => { + return ; + }, + [activeCommentId] + ); return ( >(); + const { toggleAdmire, hasViewerAdmiredEvent } = useTogglePostAdmire({ postRef: post, queryRef: query, @@ -107,6 +112,12 @@ export function FeedPostSocializeSection({ feedPostRef, queryRef }: Props) { commentsBottomSheetRef.current?.present(); }, []); + useEffect(() => { + if (route.params?.commentId) { + handleOpenCommentBottomSheet(); + } + }, [route.params, handleOpenCommentBottomSheet]); + return ( <> @@ -133,7 +144,13 @@ export function FeedPostSocializeSection({ feedPostRef, queryRef }: Props) { onCommentPress={handleOpenCommentBottomSheet} /> - + ); } diff --git a/apps/mobile/src/components/Feed/Posts/PostListCaption.tsx b/apps/mobile/src/components/Feed/Posts/PostListCaption.tsx index e8008ae25d..fbddf4292b 100644 --- a/apps/mobile/src/components/Feed/Posts/PostListCaption.tsx +++ b/apps/mobile/src/components/Feed/Posts/PostListCaption.tsx @@ -1,14 +1,12 @@ -import { useCallback, useRef, useState } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { useMemo } from 'react'; +import { View } from 'react-native'; import { graphql, useFragment } from 'react-relay'; -import { GalleryBottomSheetModalType } from '~/components/GalleryBottomSheet/GalleryBottomSheetModal'; -import { Markdown } from '~/components/Markdown'; +import ProcessedText from '~/components/ProcessedText/ProcessedText'; import { PostListCaptionFragment$key } from '~/generated/PostListCaptionFragment.graphql'; +import { removeNullValues } from '~/shared/relay/removeNullValues'; import { replaceUrlsWithMarkdownFormat } from '~/shared/utils/replaceUrlsWithMarkdownFormat'; -import { WarningLinkBottomSheet } from './WarningLinkBottomSheet'; - type Props = { feedPostRef: PostListCaptionFragment$key; }; @@ -19,35 +17,22 @@ export function PostListCaption({ feedPostRef }: Props) { fragment PostListCaptionFragment on Post { __typename caption + mentions { + ...ProcessedTextFragment + } } `, feedPostRef ); - const [redirectUrl, setRedirectUrl] = useState(''); const { caption } = feedPost; - const captionWithMarkdownLinks = replaceUrlsWithMarkdownFormat(caption ?? ''); - const bottomSheetRef = useRef(null); - - const handleLinkPress = useCallback((url: string) => { - bottomSheetRef.current?.present(); - setRedirectUrl(url); - }, []); + const nonNullMentions = useMemo(() => removeNullValues(feedPost.mentions), [feedPost.mentions]); return ( - - {captionWithMarkdownLinks} - - + ); } - -const markdownStyles = StyleSheet.create({ - body: { - fontSize: 14, - }, -}); diff --git a/apps/mobile/src/components/Feed/Posts/PostListItem.tsx b/apps/mobile/src/components/Feed/Posts/PostListItem.tsx index 7b8580be9a..09d84663bd 100644 --- a/apps/mobile/src/components/Feed/Posts/PostListItem.tsx +++ b/apps/mobile/src/components/Feed/Posts/PostListItem.tsx @@ -32,7 +32,6 @@ export function PostListItem({ feedPostRef, queryRef }: Props) { tokens { dbid community { - name contractAddress { address chain diff --git a/apps/mobile/src/components/Feed/Socialize/CommentBox.tsx b/apps/mobile/src/components/Feed/Socialize/CommentBox.tsx index 81a39b3154..6415f1a8d6 100644 --- a/apps/mobile/src/components/Feed/Socialize/CommentBox.tsx +++ b/apps/mobile/src/components/Feed/Socialize/CommentBox.tsx @@ -1,7 +1,7 @@ import { BottomSheetTextInput } from '@gorhom/bottom-sheet'; import { useColorScheme } from 'nativewind'; -import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; -import { Text, View } from 'react-native'; +import { useCallback, useLayoutEffect, useMemo } from 'react'; +import { NativeSyntheticEvent, Text, TextInputSelectionChangeEventData, View } from 'react-native'; import Animated, { useSharedValue, withSpring } from 'react-native-reanimated'; import useKeyboardStatus from 'src/utils/useKeyboardStatus'; @@ -11,6 +11,10 @@ import colors from '~/shared/theme/colors'; import { SendIcon } from './SendIcon'; type Props = { + value: string; + onChangeText: (value: string) => void; + onSelectionChange: (selection: { start: number; end: number }) => void; + onClose: () => void; autoFocus?: boolean; @@ -22,6 +26,9 @@ type Props = { }; export function CommentBox({ + value, + onChangeText, + onSelectionChange, autoFocus, onClose, isNotesModal = false, @@ -29,7 +36,6 @@ export function CommentBox({ isSubmittingComment, }: Props) { const { colorScheme } = useColorScheme(); - const [value, setValue] = useState(''); const characterCount = useMemo(() => 300 - value.length, [value]); @@ -40,8 +46,8 @@ export function CommentBox({ }, [onClose]); const resetComment = useCallback(() => { - setValue(''); - }, []); + onChangeText(''); + }, [onChangeText]); const showXMark = useMemo(() => { // If its coming from comment button, show the x mark @@ -83,7 +89,10 @@ export function CommentBox({ ) => { + onSelectionChange(e.nativeEvent.selection); + }} className="text-sm h-5" selectionColor={colorScheme === 'dark' ? colors.white : colors.black['800']} autoCapitalize="none" diff --git a/apps/mobile/src/components/Feed/Socialize/CommentLine.tsx b/apps/mobile/src/components/Feed/Socialize/CommentLine.tsx index 58ed6c2bc6..49d0ef95ce 100644 --- a/apps/mobile/src/components/Feed/Socialize/CommentLine.tsx +++ b/apps/mobile/src/components/Feed/Socialize/CommentLine.tsx @@ -1,11 +1,12 @@ +import { useMemo } from 'react'; import { Text, View, ViewProps } from 'react-native'; import { graphql, useFragment } from 'react-relay'; import { GalleryTouchableOpacity } from '~/components/GalleryTouchableOpacity'; +import ProcessedText from '~/components/ProcessedText/ProcessedText'; import { UsernameDisplay } from '~/components/UsernameDisplay'; import { CommentLineFragment$key } from '~/generated/CommentLineFragment.graphql'; - -import ProcessedCommentText from './ProcessedCommentText'; +import { removeNullValues } from '~/shared/relay/removeNullValues'; type Props = { commentRef: CommentLineFragment$key; @@ -21,11 +22,16 @@ export function CommentLine({ commentRef, style, onCommentPress }: Props) { commenter @required(action: THROW) { ...UsernameDisplayFragment } + mentions { + ...ProcessedTextFragment + } } `, commentRef ); + const nonNullMentions = useMemo(() => removeNullValues(comment.mentions), [comment.mentions]); + return ( {' '} - + diff --git a/apps/mobile/src/components/Feed/Socialize/FeedEventSocializeSection.tsx b/apps/mobile/src/components/Feed/Socialize/FeedEventSocializeSection.tsx index 6393bba451..845c1f6bfd 100644 --- a/apps/mobile/src/components/Feed/Socialize/FeedEventSocializeSection.tsx +++ b/apps/mobile/src/components/Feed/Socialize/FeedEventSocializeSection.tsx @@ -67,6 +67,7 @@ export function FeedEventSocializeSection({ feedEventRef, queryRef, onCommentPre graphql` fragment FeedEventSocializeSectionQueryFragment on Query { ...useToggleAdmireQueryFragment + ...CommentsBottomSheetQueryFragment } `, queryRef @@ -147,6 +148,7 @@ export function FeedEventSocializeSection({ feedEventRef, queryRef, onCommentPre type="FeedEvent" feedId={event.dbid} bottomSheetRef={commentsBottomSheetRef} + queryRef={query} /> ); diff --git a/apps/mobile/src/components/Feed/Socialize/ProcessedCommentText.tsx b/apps/mobile/src/components/Feed/Socialize/ProcessedCommentText.tsx deleted file mode 100644 index d1d768581b..0000000000 --- a/apps/mobile/src/components/Feed/Socialize/ProcessedCommentText.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { ReactNode, useCallback, useMemo, useRef } from 'react'; -import { Text } from 'react-native'; - -import { GalleryBottomSheetModalType } from '~/components/GalleryBottomSheet/GalleryBottomSheetModal'; -import { Typography } from '~/components/Typography'; -import { VALID_URL } from '~/shared/utils/regex'; - -import { WarningLinkBottomSheet } from '../Posts/WarningLinkBottomSheet'; - -type LinkProps = { - url: string; -}; - -const LinkComponent = ({ url }: LinkProps) => { - const bottomSheetRef = useRef(null); - const handleLinkPress = useCallback(() => { - bottomSheetRef.current?.present(); - }, []); - - return ( - <> - - {url} - - - - ); -}; - -type CommentProps = { - comment: string; -}; - -// Makes a raw comment value display-ready by converting urls to link components -export default function ProcessedCommentText({ comment }: CommentProps) { - const processedText = useMemo(() => { - const chunks = comment.split(VALID_URL); - const urls = comment.match(VALID_URL); - - const result: ReactNode[] = []; - - chunks.forEach((chunk, index) => { - result.push({chunk}); - if (urls && urls[index]) { - result.push(); - } - }); - - return result; - }, [comment]); - - return ( - - {processedText} - - ); -} diff --git a/apps/mobile/src/components/Notification/Notification.tsx b/apps/mobile/src/components/Notification/Notification.tsx index 97b47f205f..21cbc4501d 100644 --- a/apps/mobile/src/components/Notification/Notification.tsx +++ b/apps/mobile/src/components/Notification/Notification.tsx @@ -13,6 +13,7 @@ import { SomeoneCommentedOnYourFeedEvent } from './Notifications/SomeoneCommente import { SomeoneCommentedOnYourPost } from './Notifications/SomeoneCommentedOnYourPost'; import { SomeoneFollowedYou } from './Notifications/SomeoneFollowedYou'; import { SomeoneFollowedYouBack } from './Notifications/SomeoneFollowedYouBack'; +import { SomeoneMentionedYou } from './Notifications/SomeoneMentionedYou'; import { SomeoneViewedYourGallery } from './Notifications/SomeoneViewedYourGallery'; type NotificationInnerProps = { @@ -31,6 +32,7 @@ export function Notification({ notificationRef, queryRef }: NotificationInnerPro ...SomeoneViewedYourGalleryQueryFragment ...SomeoneAdmiredYourPostQueryFragment ...SomeoneCommentedOnYourPostQueryFragment + ...SomeoneMentionedYouQueryFragment } `, queryRef @@ -85,6 +87,11 @@ export function Notification({ notificationRef, queryRef }: NotificationInnerPro __typename ...NewTokensFragment } + + ... on SomeoneMentionedYouNotification { + __typename + ...SomeoneMentionedYouFragment + } } `, notificationRef @@ -111,6 +118,8 @@ export function Notification({ notificationRef, queryRef }: NotificationInnerPro ) : null; } else if (notification.__typename === 'NewTokensNotification') { return ; + } else if (notification.__typename === 'SomeoneMentionedYouNotification') { + return ; } return ; }, [notification, query]); diff --git a/apps/mobile/src/components/Notification/NotificationSkeleton.tsx b/apps/mobile/src/components/Notification/NotificationSkeleton.tsx index c499bb151d..475cf3d12e 100644 --- a/apps/mobile/src/components/Notification/NotificationSkeleton.tsx +++ b/apps/mobile/src/components/Notification/NotificationSkeleton.tsx @@ -73,6 +73,25 @@ export function NotificationSkeleton({ } } } + ... on SomeoneMentionedYouNotification { + mentionSource { + __typename + ... on Post { + tokens { + ...NotificationPostPreviewWithBoundaryFragment + } + } + ... on Comment { + source { + ... on Post { + tokens { + ...NotificationPostPreviewWithBoundaryFragment + } + } + } + } + } + } } `, notificationRef @@ -112,6 +131,17 @@ export function NotificationSkeleton({ ) { return notification.post?.tokens?.[0]; } + + if (notification.__typename === 'SomeoneMentionedYouNotification') { + if (notification.mentionSource?.__typename === 'Post') { + return notification.mentionSource.tokens?.[0]; + } + + if (notification.mentionSource?.__typename === 'Comment') { + return notification.mentionSource.source?.tokens?.[0]; + } + } + return null; }, [notification]); diff --git a/apps/mobile/src/components/Notification/Notifications/SomeoneMentionedYou.tsx b/apps/mobile/src/components/Notification/Notifications/SomeoneMentionedYou.tsx new file mode 100644 index 0000000000..7533f424d0 --- /dev/null +++ b/apps/mobile/src/components/Notification/Notifications/SomeoneMentionedYou.tsx @@ -0,0 +1,185 @@ +import { useNavigation } from '@react-navigation/native'; +import { useMemo } from 'react'; +import { Text, View } from 'react-native'; +import { useFragment } from 'react-relay'; +import { graphql } from 'relay-runtime'; + +import { NotificationSkeleton } from '~/components/Notification/NotificationSkeleton'; +import ProcessedText from '~/components/ProcessedText/ProcessedText'; +import { Typography } from '~/components/Typography'; +import { NotificationSkeletonResponsibleUsersFragment$key } from '~/generated/NotificationSkeletonResponsibleUsersFragment.graphql'; +import { SomeoneMentionedYouFragment$key } from '~/generated/SomeoneMentionedYouFragment.graphql'; +import { SomeoneMentionedYouQueryFragment$key } from '~/generated/SomeoneMentionedYouQueryFragment.graphql'; +import { MainTabStackNavigatorProp } from '~/navigation/types'; +import { removeNullValues } from '~/shared/relay/removeNullValues'; + +type SomeoneCommentedOnYourFeedEventProps = { + queryRef: SomeoneMentionedYouQueryFragment$key; + notificationRef: SomeoneMentionedYouFragment$key; +}; + +type NotificationDataType = { + author: string; + message: string; + usersMentioned: NotificationSkeletonResponsibleUsersFragment$key; + onPress: () => void; + type: 'post' | 'comment'; +}; + +export function SomeoneMentionedYou({ + queryRef, + notificationRef, +}: SomeoneCommentedOnYourFeedEventProps) { + const query = useFragment( + graphql` + fragment SomeoneMentionedYouQueryFragment on Query { + ...NotificationSkeletonQueryFragment + } + `, + queryRef + ); + + const notification = useFragment( + graphql` + fragment SomeoneMentionedYouFragment on SomeoneMentionedYouNotification { + __typename + + mentionSource @required(action: THROW) { + __typename + ... on Post { + __typename + dbid + caption + author { + username + ...NotificationSkeletonResponsibleUsersFragment + } + } + ... on Comment { + __typename + dbid + commenter { + username + ...NotificationSkeletonResponsibleUsersFragment + } + source { + ... on Post { + __typename + dbid + } + ... on FeedEvent { + __typename + dbid + } + } + comment + } + } + + ...NotificationSkeletonFragment + } + `, + notificationRef + ); + + const navigation = useNavigation(); + + const notificationData: NotificationDataType = useMemo(() => { + if (notification?.mentionSource?.__typename === 'Post') { + return { + author: notification.mentionSource?.author?.username ?? 'Someone', + message: notification.mentionSource?.caption ?? '', + usersMentioned: removeNullValues([notification.mentionSource?.author]), + onPress: () => { + let postId = ''; + + if (notification.mentionSource.__typename === 'Post') { + postId = notification.mentionSource.dbid; + } + navigation.navigate('Post', { postId }); + }, + type: 'post', + }; + } + + if (notification?.mentionSource?.__typename === 'Comment') { + const { mentionSource } = notification; + const author = mentionSource?.commenter?.username ?? 'Someone'; + const message = mentionSource?.comment ?? ''; + const usersMentioned = removeNullValues([mentionSource?.commenter]); + + const onPress = () => { + let postId = ''; + let commentId = ''; + + if (mentionSource.__typename === 'Comment') { + commentId = mentionSource.dbid; + if ( + mentionSource.source?.__typename === 'Post' || + mentionSource.source?.__typename === 'FeedEvent' + ) { + postId = mentionSource.source.dbid; + } + } + + navigation.navigate('Post', { + postId: postId || ' ', + commentId: commentId || '', + }); + }; + + return { author, message, usersMentioned, onPress, type: 'comment' }; + } + + return { + author: 'Someone', + message: '', + usersMentioned: [], + onPress: () => {}, + type: 'post', + }; + }, [navigation, notification]); + + if (!notification) { + return null; + } + + const { author, message, usersMentioned, onPress, type } = notificationData; + + return ( + + + + + {author} + {' '} + mentioned you in a{' '} + + {type} + + + + + + + + + ); +} diff --git a/apps/mobile/src/components/Post/PostInput.tsx b/apps/mobile/src/components/Post/PostInput.tsx index 7acbbfe22d..34ec1cf26c 100644 --- a/apps/mobile/src/components/Post/PostInput.tsx +++ b/apps/mobile/src/components/Post/PostInput.tsx @@ -1,7 +1,12 @@ import clsx from 'clsx'; import { useColorScheme } from 'nativewind'; import { useMemo } from 'react'; -import { TextInput, View } from 'react-native'; +import { + NativeSyntheticEvent, + TextInput, + TextInputSelectionChangeEventData, + View, +} from 'react-native'; import { graphql, useFragment } from 'react-relay'; import { PostInputTokenFragment$key } from '~/generated/PostInputTokenFragment.graphql'; @@ -14,9 +19,10 @@ type Props = { value: string; onChange: (newText: string) => void; tokenRef: PostInputTokenFragment$key; + onSelectionChange: (selection: { start: number; end: number }) => void; }; -export function PostInput({ value, onChange, tokenRef }: Props) { +export function PostInput({ value, onChange, tokenRef, onSelectionChange }: Props) { const token = useFragment( graphql` fragment PostInputTokenFragment on Token { @@ -49,6 +55,9 @@ export function PostInput({ value, onChange, tokenRef }: Props) { selectionColor={colorScheme === 'dark' ? colors.white : colors.black['800']} placeholderTextColor={colorScheme === 'dark' ? colors.metal : colors.shadow} multiline + onSelectionChange={(e: NativeSyntheticEvent) => { + onSelectionChange(e.nativeEvent.selection); + }} maxLength={MAX_LENGTH} autoCapitalize="none" autoComplete="off" diff --git a/apps/mobile/src/components/ProcessedText/ProcessedText.tsx b/apps/mobile/src/components/ProcessedText/ProcessedText.tsx new file mode 100644 index 0000000000..2f8da20b34 --- /dev/null +++ b/apps/mobile/src/components/ProcessedText/ProcessedText.tsx @@ -0,0 +1,98 @@ +import { ReactNode, useMemo } from 'react'; +import { Text, TextProps } from 'react-native'; +import { graphql, useFragment } from 'react-relay'; + +import { Typography } from '~/components/Typography'; +import { ProcessedTextFragment$key } from '~/generated/ProcessedTextFragment.graphql'; + +import { LinkComponent } from './elements/LinkComponent'; +import { MentionComponent } from './elements/MentionComponent'; +import { + getMarkdownLinkElements, + getMentionElements, + getUrlElements, + TextElement, +} from './TextElementParser'; + +type ProcessedTextProps = { + text: string; + mentionsRef?: ProcessedTextFragment$key; +} & TextProps; + +// Makes a raw text value display-ready by converting urls to link components +export default function ProcessedText({ text, mentionsRef = [], ...props }: ProcessedTextProps) { + const mentions = useFragment( + graphql` + fragment ProcessedTextFragment on Mention @relay(plural: true) { + __typename + ...TextElementParserMentionsFragment + ...MentionComponentFragment + } + `, + mentionsRef + ); + + const processedText = useMemo(() => { + const markdownElements = getMarkdownLinkElements(text); + const urlElements = getUrlElements(text, markdownElements); + const mentionElements = getMentionElements(text, mentions); + + const elements = [...markdownElements, ...urlElements, ...mentionElements]; + + // Sort elements based on their start index + elements.sort((a, b) => a.start - b.start); + + // Construct the final output based on sorted intervals + const result: ReactNode[] = []; + let lastEndIndex = 0; + + elements.forEach((element, index) => { + // Add text before this element (if any) + addTextElement(result, text.substring(lastEndIndex, element.start), 'text-before', index); + // Add the element (either mention, URL, or markdown-link) + addLinkElement(result, element, index); + lastEndIndex = element.end; + }); + + // Add any remaining text after the last element + addTextElement(result, text.substring(lastEndIndex), 'text-after', elements.length); + + return result; + }, [text, mentions]); + + return ( + + {processedText} + + ); +} + +const addTextElement = ( + result: ReactNode[], + value: string, + type: 'text-before' | 'text-after', + index: number +) => { + if (value) result.push({value}); +}; + +const addLinkElement = (result: ReactNode[], element: TextElement, index: number) => { + if (element.type === 'mention' && element.mentionRef) { + result.push( + + ); + } else if (element.type === 'url') { + result.push(); + } else if (element.type === 'markdown-link' && element.url) { + result.push(); + } +}; diff --git a/apps/mobile/src/components/ProcessedText/TextElementParser.ts b/apps/mobile/src/components/ProcessedText/TextElementParser.ts new file mode 100644 index 0000000000..a95ee94bee --- /dev/null +++ b/apps/mobile/src/components/ProcessedText/TextElementParser.ts @@ -0,0 +1,123 @@ +import { graphql, readInlineData } from 'react-relay'; + +import { MentionComponentFragment$key } from '~/generated/MentionComponentFragment.graphql'; +import { ProcessedTextFragment$data } from '~/generated/ProcessedTextFragment.graphql'; +import { + TextElementParserMentionsFragment$data, + TextElementParserMentionsFragment$key, +} from '~/generated/TextElementParserMentionsFragment.graphql'; +import { MARKDOWN_LINK_REGEX, VALID_URL } from '~/shared/utils/regex'; + +export type TextElement = { + type: 'mention' | 'url' | 'markdown-link'; + value: string; + start: number; + end: number; + mentionRef?: MentionComponentFragment$key; + url?: string; +}; + +export function getMentionElements( + text: string, + mentionRefs: ProcessedTextFragment$data +): TextElement[] { + function fetchMention(mentionRef: TextElementParserMentionsFragment$key) { + return readInlineData( + graphql` + fragment TextElementParserMentionsFragment on Mention @inline { + interval { + __typename + start + length + } + entity { + __typename + } + ...MentionComponentFragment + } + `, + mentionRef + ); + } + + const mentions: TextElementParserMentionsFragment$data[] = []; + + mentionRefs.forEach((mentionRef) => { + mentions.push(fetchMention(mentionRef)); + }); + + const elements: TextElement[] = []; + + mentions?.forEach((mention) => { + if (!mention?.entity || !mention?.interval) return; + + const { start, length } = mention.interval; + const mentionText = text.substring(start, start + length); + + elements.push({ + type: 'mention', + value: mentionText, + start: start, + end: start + length, + mentionRef: mention, + }); + }); + + return elements; +} + +export function getMarkdownLinkElements(text: string): TextElement[] { + const elements: TextElement[] = []; + + // Identify markdown-style links and add them to the elements array + const markdownLinkMatches = text.matchAll(MARKDOWN_LINK_REGEX); + for (const match of markdownLinkMatches) { + const [fullMatch, linkText, linkUrl] = match; + const startIndex = match.index ?? 0; + + elements.push({ + type: 'markdown-link', + // If there's no link text, then we use the link URL as the value + value: linkText ?? linkUrl ?? '', + start: startIndex, + end: startIndex + fullMatch.length, + url: linkUrl, + }); + } + + return elements; +} + +export function getUrlElements(text: string, existingElements: TextElement[] = []): TextElement[] { + const elements: TextElement[] = []; + + const URL_REGEX = new RegExp(VALID_URL, 'g'); // Make sure the URL regex has the 'g' flag + let urlMatch; + while ((urlMatch = URL_REGEX.exec(text)) !== null) { + const [match] = urlMatch; + const startIndex = urlMatch.index; + const endIndex = startIndex + match.length; + + // Before pushing a URL to the elements array, we check if it's within a markdown link. + // If it's not, then we push it. + if (!isWithinMarkdownLink(startIndex, endIndex, existingElements)) { + elements.push({ + type: 'url', + value: match, + start: startIndex, + end: endIndex, + }); + } + } + + return elements; +} + +function isWithinMarkdownLink(start: number, end: number, elements: TextElement[]) { + for (const element of elements) { + if (element.type === 'markdown-link' && start >= element.start && end <= element.end) { + return true; + } + } + return false; +} diff --git a/apps/mobile/src/components/ProcessedText/elements/LinkComponent.tsx b/apps/mobile/src/components/ProcessedText/elements/LinkComponent.tsx new file mode 100644 index 0000000000..9eec69951a --- /dev/null +++ b/apps/mobile/src/components/ProcessedText/elements/LinkComponent.tsx @@ -0,0 +1,26 @@ +import { useCallback, useRef } from 'react'; +import { Text } from 'react-native'; + +import { WarningLinkBottomSheet } from '~/components/Feed/Posts/WarningLinkBottomSheet'; +import { GalleryBottomSheetModalType } from '~/components/GalleryBottomSheet/GalleryBottomSheetModal'; + +type Props = { + value?: string; + url: string; +}; + +export function LinkComponent({ url, value }: Props) { + const bottomSheetRef = useRef(null); + const handleLinkPress = useCallback(() => { + bottomSheetRef.current?.present(); + }, []); + + return ( + <> + + {value ?? url} + + + + ); +} diff --git a/apps/mobile/src/components/ProcessedText/elements/MentionComponent.tsx b/apps/mobile/src/components/ProcessedText/elements/MentionComponent.tsx new file mode 100644 index 0000000000..7cf14a8099 --- /dev/null +++ b/apps/mobile/src/components/ProcessedText/elements/MentionComponent.tsx @@ -0,0 +1,62 @@ +import { useNavigation } from '@react-navigation/native'; +import { useCallback } from 'react'; +import { Text } from 'react-native'; +import { graphql, useFragment } from 'react-relay'; + +import { MentionComponentFragment$key } from '~/generated/MentionComponentFragment.graphql'; +import { MainTabStackNavigatorProp } from '~/navigation/types'; + +type Props = { + mention: string; + mentionRef: MentionComponentFragment$key; +}; + +export function MentionComponent({ mention, mentionRef }: Props) { + const mentionData = useFragment( + graphql` + fragment MentionComponentFragment on Mention { + __typename + entity { + __typename + ... on GalleryUser { + __typename + username + } + ... on Community { + __typename + contractAddress { + __typename + address + chain + } + } + } + } + `, + mentionRef + ); + + const navigation = useNavigation(); + + const onMentionPress = useCallback(() => { + if (mentionData.entity?.__typename === 'GalleryUser') { + navigation.navigate('Profile', { + username: mentionData.entity.username ?? '', + }); + return; + } + + if (mentionData.entity?.__typename === 'Community') { + navigation.navigate('Community', { + contractAddress: mentionData.entity.contractAddress?.address ?? '', + chain: mentionData.entity.contractAddress?.chain ?? '', + }); + } + }, [mentionData, navigation]); + + return ( + + {mention} + + ); +} diff --git a/apps/mobile/src/components/Search/Community/CommunitySearchResult.tsx b/apps/mobile/src/components/Search/Community/CommunitySearchResult.tsx index bc7dc10e68..67d36f1a69 100644 --- a/apps/mobile/src/components/Search/Community/CommunitySearchResult.tsx +++ b/apps/mobile/src/components/Search/Community/CommunitySearchResult.tsx @@ -1,6 +1,7 @@ import { useNavigation } from '@react-navigation/native'; import { useCallback } from 'react'; import { graphql, useFragment } from 'react-relay'; +import { MentionType } from 'src/hooks/useMentionableMessage'; import { CommunityProfilePicture } from '~/components/ProfilePicture/CommunityProfilePicture'; import { CommunitySearchResultFragment$key } from '~/generated/CommunitySearchResultFragment.graphql'; @@ -10,11 +11,13 @@ import { SearchResult } from '../SearchResult'; type Props = { communityRef: CommunitySearchResultFragment$key; + onSelect?: (item: MentionType) => void; }; -export function CommunitySearchResult({ communityRef }: Props) { +export function CommunitySearchResult({ communityRef, onSelect = () => {} }: Props) { const community = useFragment( graphql` fragment CommunitySearchResultFragment on Community { + dbid name description contractAddress { @@ -32,6 +35,15 @@ export function CommunitySearchResult({ communityRef }: Props) { const contractAddress = community.contractAddress; const { address, chain } = contractAddress ?? {}; + if (onSelect) { + onSelect({ + type: 'Community', + label: community.name ?? '', + value: community.dbid, + }); + return; + } + if (!address || !chain) { return; } @@ -40,7 +52,7 @@ export function CommunitySearchResult({ communityRef }: Props) { contractAddress: address, chain, }); - }, [community, navigation]); + }, [community, navigation, onSelect]); return ( + + + + + + + + + + + + + ); +} + +function SearchResultFallback() { + return ( + + + + + + + + + + + + ); +} + +function SearchTitleFallback() { + return ( + + + + + + ); +} diff --git a/apps/mobile/src/components/Search/SearchResults.tsx b/apps/mobile/src/components/Search/SearchResults.tsx index d2d02099a6..f037938267 100644 --- a/apps/mobile/src/components/Search/SearchResults.tsx +++ b/apps/mobile/src/components/Search/SearchResults.tsx @@ -2,6 +2,7 @@ import { FlashList, ListRenderItem } from '@shopify/flash-list'; import { useCallback, useDeferredValue, useMemo } from 'react'; import { View } from 'react-native'; import { graphql, useLazyLoadQuery } from 'react-relay'; +import { MentionType } from 'src/hooks/useMentionableMessage'; import { CommunitySearchResultFragment$key } from '~/generated/CommunitySearchResultFragment.graphql'; import { GallerySearchResultFragment$key } from '~/generated/GallerySearchResultFragment.graphql'; @@ -12,7 +13,6 @@ import { Typography } from '../Typography'; import { CommunitySearchResult } from './Community/CommunitySearchResult'; import { NUM_PREVIEW_SEARCH_RESULTS } from './constants'; import { GallerySearchResult } from './Gallery/GallerySearchResult'; -import { useSearchContext } from './SearchContext'; import { SearchFilterType } from './SearchFilter'; import { SearchSection } from './SearchSection'; import { UserSearchResult } from './User/UserSearchResult'; @@ -38,13 +38,25 @@ type SearchListItem = }; type Props = { + keyword: string; activeFilter: SearchFilterType; onChangeFilter: (filter: SearchFilterType) => void; blurInputFocus: () => void; + onSelect?: (item: MentionType) => void; + + onlyShowTopResults?: boolean; + isMentionSearch?: boolean; }; -export function SearchResults({ activeFilter, onChangeFilter, blurInputFocus }: Props) { - const { keyword } = useSearchContext(); +export function SearchResults({ + activeFilter, + keyword, + onChangeFilter, + blurInputFocus, + onSelect = () => {}, + onlyShowTopResults = false, + isMentionSearch = false, +}: Props) { const deferredKeyword = useDeferredValue(keyword); const query = useLazyLoadQuery( @@ -209,7 +221,7 @@ export function SearchResults({ activeFilter, onChangeFilter, blurInputFocus }: } } - if (hasGalleries) { + if (hasGalleries && !isMentionSearch) { items.push({ kind: 'search-section-header', sectionType: 'gallery', @@ -254,11 +266,21 @@ export function SearchResults({ activeFilter, onChangeFilter, blurInputFocus }: hasCommunities, hasGalleries, hasUsers, + isMentionSearch, searchCommunities, searchGalleries, searchUsers, ]); + const showAllButton = useMemo( + () => (sectionType: SearchFilterType) => { + if (onlyShowTopResults) return true; + + return activeFilter === sectionType; + }, + [activeFilter, onlyShowTopResults] + ); + const renderItem = useCallback>( ({ item }) => { if (item.kind === 'search-section-header') { @@ -267,20 +289,20 @@ export function SearchResults({ activeFilter, onChangeFilter, blurInputFocus }: title={item.sectionTitle} onShowAll={() => onChangeFilter(item.sectionType)} numResults={item.numberOfResults} - isShowAll={activeFilter === item.sectionType} + isShowAll={showAllButton(item.sectionType)} /> ); } else if (item.kind === 'user-search-result') { - return ; + return ; } else if (item.kind === 'gallery-search-result') { return ; } else if (item.kind === 'community-search-result') { - return ; + return ; } return ; }, - [activeFilter, onChangeFilter] + [onChangeFilter, onSelect, showAllButton] ); if (isEmpty) { diff --git a/apps/mobile/src/components/Search/User/UserSearchResult.tsx b/apps/mobile/src/components/Search/User/UserSearchResult.tsx index 1123169ea8..6b1eb5b07e 100644 --- a/apps/mobile/src/components/Search/User/UserSearchResult.tsx +++ b/apps/mobile/src/components/Search/User/UserSearchResult.tsx @@ -1,6 +1,7 @@ import { useNavigation } from '@react-navigation/native'; import { useCallback } from 'react'; import { graphql, useFragment } from 'react-relay'; +import { MentionType } from 'src/hooks/useMentionableMessage'; import { ProfilePicture } from '~/components/ProfilePicture/ProfilePicture'; import { UserSearchResultFragment$key } from '~/generated/UserSearchResultFragment.graphql'; @@ -10,12 +11,14 @@ import { SearchResult } from '../SearchResult'; type Props = { userRef: UserSearchResultFragment$key; + onSelect?: (item: MentionType) => void; }; -export function UserSearchResult({ userRef }: Props) { +export function UserSearchResult({ userRef, onSelect = () => {} }: Props) { const user = useFragment( graphql` fragment UserSearchResultFragment on GalleryUser { + dbid username bio @@ -27,10 +30,19 @@ export function UserSearchResult({ userRef }: Props) { const navigation = useNavigation(); const handlePress = useCallback(() => { + if (onSelect) { + onSelect({ + type: 'User', + label: user.username ?? '', + value: user.dbid, + }); + return; + } + if (user.username) { navigation.push('Profile', { username: user.username }); } - }, [navigation, user.username]); + }, [onSelect, navigation, user.dbid, user.username]); return ( ([]); + + const [isSelectingMentions, setIsSelectingMentions] = useState(false); + + const [aliasKeyword, setAliasKeyword] = useState(''); + const debouncedAliasKeyword = useDebounce(aliasKeyword, 100); + + const [selection, setSelection] = useState({ start: 0, end: 0 }); + + const isMentionEnabled = isFeatureEnabled(FeatureFlag.MENTIONS, query); + + const handleSetMention = useCallback( + (mention: MentionType) => { + // Use substring to only look up to the current cursor position + const upToCursor = message.substring(0, selection.start); + const mentionStartPos = upToCursor.lastIndexOf(aliasKeyword); + const mentionEndPos = mentionStartPos + aliasKeyword.length; + + const newMessage = `${message.substring(0, mentionStartPos)}@${ + mention.label + } ${message.substring(mentionEndPos)}`; + + setMessage(newMessage); + + const newMention: Mention = { + interval: { + start: mentionStartPos, + length: mention.label.length + 1, // +1 for the @ + }, + }; + + if (mention.type === 'User') { + newMention.userId = mention.value; + } else { + newMention.communityId = mention.value; + } + + // Calculate the length difference between the old alias and the new mention + const lengthDifference = mention.label.length + 2 - aliasKeyword.length; // +2 for the @ and space after the mention + + // Adjust the positions of mentions that come after the newly added mention + const adjustedMentions = mentions.map((existingMention) => { + if (existingMention.interval.start >= mentionStartPos) { + return { + ...existingMention, + interval: { + start: existingMention.interval.start + lengthDifference, + length: existingMention.interval.length, + }, + }; + } + return existingMention; + }); + + setIsSelectingMentions(false); + + setMentions([...adjustedMentions, newMention]); + }, + [mentions, message, setMessage, aliasKeyword, selection.start] + ); + + const handleSetMessage = useCallback( + (text: string) => { + if (!isMentionEnabled) { + setMessage(text); + return; + } + + // Check the word where the cursor is (or was last placed) + const wordAtCursor = text + .slice(0, selection.start + 1) + .split(' ') + .pop(); + + if (wordAtCursor && wordAtCursor[0] === '@' && wordAtCursor.length > 1) { + setAliasKeyword(wordAtCursor); + setIsSelectingMentions(true); + } else { + setAliasKeyword(''); + setIsSelectingMentions(false); + } + + // Determine how many characters were added or removed + const diff = message.length - text.length; + + // Update the positions of the mentions based on the added/removed characters + let updatedMentions = mentions.map((mention) => { + // If the change occurred before a mention, adjust its position + if (mention.interval.start >= selection.start) { + return { + ...mention, + interval: { + ...mention.interval, + start: mention.interval.start - diff, + }, + }; + } + return mention; + }); + + // Remove any mentions that were deleted + updatedMentions = updatedMentions.filter((mention) => { + // if start < 0, it means the mention was deleted + return ( + mention.interval.start >= 0 && + mention.interval.start + mention.interval.length <= text.length + ); + }); + + setMentions(updatedMentions); + setMessage(text); + }, + [isMentionEnabled, mentions, message, selection] + ); + + const resetMentions = useCallback(() => { + setMentions([]); + setIsSelectingMentions(false); + setMessage(''); + }, []); + + const handleSelectionChange = useCallback((selection: { start: number; end: number }) => { + setSelection(selection); + }, []); + + return { + aliasKeyword: debouncedAliasKeyword, + isSelectingMentions, + message, + setMessage: handleSetMessage, + selectMention: handleSetMention, + mentions: mentions || [], + resetMentions, + handleSelectionChange, + }; +} diff --git a/apps/mobile/src/hooks/usePostComment.ts b/apps/mobile/src/hooks/usePostComment.ts index f53c4b58ec..1d88fb2c2d 100644 --- a/apps/mobile/src/hooks/usePostComment.ts +++ b/apps/mobile/src/hooks/usePostComment.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { ConnectionHandler, fetchQuery, graphql, useRelayEnvironment } from 'react-relay'; import { SelectorStoreUpdater } from 'relay-runtime'; -import { usePostCommentMutation } from '~/generated/usePostCommentMutation.graphql'; +import { MentionInput, usePostCommentMutation } from '~/generated/usePostCommentMutation.graphql'; import { usePostCommentQuery } from '~/generated/usePostCommentQuery.graphql'; import { useReportError } from '~/shared/contexts/ErrorReportingContext'; import { usePromisifiedMutation } from '~/shared/relay/usePromisifiedMutation'; @@ -10,15 +10,20 @@ import { usePromisifiedMutation } from '~/shared/relay/usePromisifiedMutation'; type submitCommentProps = { feedId: string; value: string; + mentions?: MentionInput[]; onSuccess?: () => void; }; export function usePostComment() { const [submitComment, isSubmittingComment] = usePromisifiedMutation(graphql` - mutation usePostCommentMutation($postId: DBID!, $comment: String!, $connections: [ID!]!) - @raw_response_type { - commentOnPost(comment: $comment, postId: $postId) { + mutation usePostCommentMutation( + $postId: DBID! + $comment: String! + $connections: [ID!]! + $mentions: [MentionInput!]! + ) @raw_response_type { + commentOnPost(comment: $comment, postId: $postId, mentions: $mentions) { ... on CommentOnPostPayload { __typename @@ -31,6 +36,24 @@ export function usePostComment() { id username } + mentions { + interval { + start + length + } + entity { + ... on GalleryUser { + __typename + id + dbid + } + ... on Community { + __typename + id + dbid + } + } + } creationTime } } @@ -41,7 +64,7 @@ export function usePostComment() { const relayEnvironment = useRelayEnvironment(); const handleSubmit = useCallback( - async ({ feedId, value, onSuccess = () => {} }: submitCommentProps) => { + async ({ feedId, value, onSuccess = () => {}, mentions = [] }: submitCommentProps) => { if (value.length === 0) { return; } @@ -93,6 +116,26 @@ export function usePostComment() { }; const optimisticId = Math.random().toString(); + + const optimisticResponseMentions = mentions.map((mention) => { + const mentionOptimisiticResponse = { + interval: { + start: mention?.interval?.start ?? 0, + length: mention?.interval?.length ?? 0, + }, + entity: { + __isNode: mention.userId ? 'GalleryUser' : 'Community', + __typename: mention.userId ? 'GalleryUser' : 'Community', + dbid: mention.userId ?? mention.communityId, + id: mention.userId + ? `GalleryUser:${mention.userId}` + : `Community:${mention.communityId}`, + }, + }; + + return mentionOptimisiticResponse; + }); + const response = await submitComment({ updater, optimisticUpdater: updater, @@ -107,6 +150,7 @@ export function usePostComment() { id: query?.viewer?.user?.id ?? 'unknown', username: query?.viewer?.user?.username ?? null, }, + mentions: optimisticResponseMentions, creationTime: new Date().toISOString(), dbid: optimisticId, id: `Comment:${optimisticId}`, @@ -117,6 +161,7 @@ export function usePostComment() { comment: value, postId: feedId, connections: [interactionsConnection, commentsBottomSheetConnection], + mentions, }, }); diff --git a/apps/mobile/src/navigation/types.ts b/apps/mobile/src/navigation/types.ts index e0bf907240..d554c2cd2c 100644 --- a/apps/mobile/src/navigation/types.ts +++ b/apps/mobile/src/navigation/types.ts @@ -48,7 +48,7 @@ export type MainTabStackNavigatorParamList = { page: ScreenWithNftSelector; }; SettingsProfile: undefined; - Post: { postId: string }; + Post: { postId: string; commentId?: string }; NotificationSettingsScreen: undefined; // The main five tabs diff --git a/apps/mobile/src/screens/PostScreen/PostComposerScreen.tsx b/apps/mobile/src/screens/PostScreen/PostComposerScreen.tsx index 79cc3bd677..c3234c38a9 100644 --- a/apps/mobile/src/screens/PostScreen/PostComposerScreen.tsx +++ b/apps/mobile/src/screens/PostScreen/PostComposerScreen.tsx @@ -3,6 +3,7 @@ import { Suspense, useCallback, useRef, useState } from 'react'; import { Keyboard, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { graphql, useFragment, useLazyLoadQuery } from 'react-relay'; +import { useMentionableMessage } from 'src/hooks/useMentionableMessage'; import { BackButton } from '~/components/BackButton'; import { GalleryBottomSheetModalType } from '~/components/GalleryBottomSheet/GalleryBottomSheetModal'; @@ -10,6 +11,8 @@ import { GalleryTouchableOpacity } from '~/components/GalleryTouchableOpacity'; import { PostInput } from '~/components/Post/PostInput'; import { PostTokenPreview } from '~/components/Post/PostTokenPreview'; import { WarningPostBottomSheet } from '~/components/Post/WarningPostBottomSheet'; +import { SearchResultsFallback } from '~/components/Search/SearchResultFallback'; +import { SearchResults } from '~/components/Search/SearchResults'; import { Typography } from '~/components/Typography'; import { useToastActions } from '~/contexts/ToastContext'; import { PostComposerScreenQuery } from '~/generated/PostComposerScreenQuery.graphql'; @@ -43,6 +46,7 @@ function PostComposerScreenInner() { ...usePostTokenFragment } } + ...useMentionableMessageQueryFragment } `, { @@ -60,23 +64,33 @@ function PostComposerScreenInner() { tokenRef: token, }); - const [caption, setCaption] = useState(''); const [isPosting, setIsPosting] = useState(false); const mainTabNavigation = useNavigation(); const feedTabNavigation = useNavigation(); const navigation = useNavigation(); + const { + aliasKeyword, + isSelectingMentions, + selectMention, + mentions, + setMessage, + message, + resetMentions, + handleSelectionChange, + } = useMentionableMessage(query); + const bottomSheetRef = useRef(null); const handleBackPress = useCallback(() => { - if (!caption) { + if (!message) { navigation.goBack(); return; } Keyboard.dismiss(); bottomSheetRef.current?.present(); - }, [caption, navigation]); + }, [message, navigation]); const { pushToast } = useToastActions(); @@ -91,7 +105,8 @@ function PostComposerScreenInner() { await post({ tokenId, - caption, + caption: message, + mentions, }); mainTabNavigation.reset({ @@ -114,16 +129,19 @@ function PostComposerScreenInner() { } setIsPosting(false); + resetMentions(); pushToast({ children: , }); }, [ - caption, + message, feedTabNavigation, isPosting, mainTabNavigation, + mentions, post, pushToast, + resetMentions, route.params.redirectTo, token, ]); @@ -158,11 +176,30 @@ function PostComposerScreenInner() { - - - }> - - + + + {isSelectingMentions ? ( + }> + {}} + blurInputFocus={() => {}} + onSelect={selectMention} + onlyShowTopResults + isMentionSearch + /> + + ) : ( + }> + + + )} diff --git a/apps/mobile/src/screens/PostScreen/usePost.tsx b/apps/mobile/src/screens/PostScreen/usePost.tsx index 7e038f41c3..536c547344 100644 --- a/apps/mobile/src/screens/PostScreen/usePost.tsx +++ b/apps/mobile/src/screens/PostScreen/usePost.tsx @@ -4,7 +4,7 @@ import { SelectorStoreUpdater } from 'relay-runtime'; import { useToastActions } from '~/contexts/ToastContext'; import { usePostDeleteMutation } from '~/generated/usePostDeleteMutation.graphql'; -import { usePostMutation } from '~/generated/usePostMutation.graphql'; +import { MentionInput, usePostMutation } from '~/generated/usePostMutation.graphql'; import { usePostTokenFragment$key } from '~/generated/usePostTokenFragment.graphql'; import { useReportError } from '~/shared/contexts/ErrorReportingContext'; import { usePromisifiedMutation } from '~/shared/relay/usePromisifiedMutation'; @@ -12,6 +12,7 @@ import { usePromisifiedMutation } from '~/shared/relay/usePromisifiedMutation'; type PostTokensInput = { tokenId: string; caption?: string; + mentions?: MentionInput[]; }; type Props = { @@ -73,7 +74,7 @@ export function usePost({ tokenRef }: Props) { ); const handlePost = useCallback( - ({ tokenId, caption }: PostTokensInput) => { + ({ tokenId, caption, mentions = [] }: PostTokensInput) => { const updater: SelectorStoreUpdater = (store, response) => { if (response.postTokens?.post?.__typename === 'Post') { // Get the new post @@ -126,6 +127,7 @@ export function usePost({ tokenRef }: Props) { // In future, we can use this to post multiple tokens at once tokenIds: [tokenId], caption, + mentions, }, }, }).catch((error) => { diff --git a/apps/mobile/src/screens/SearchScreen.tsx b/apps/mobile/src/screens/SearchScreen.tsx index 51bb2b958a..535d13822d 100644 --- a/apps/mobile/src/screens/SearchScreen.tsx +++ b/apps/mobile/src/screens/SearchScreen.tsx @@ -50,6 +50,7 @@ export function SearchScreen() { {keyword ? ( ; const ROLE_FLAGS: Record> = { ADMIN: { KOALA: true, + MENTIONS: true, }, BETA_TESTER: { KOALA: true, + MENTIONS: false, }, EARLY_ACCESS: { KOALA: true, + MENTIONS: false, }, }; diff --git a/packages/shared/src/utils/regex.ts b/packages/shared/src/utils/regex.ts index 277e71d9ce..fe8d00cb74 100644 --- a/packages/shared/src/utils/regex.ts +++ b/packages/shared/src/utils/regex.ts @@ -22,3 +22,6 @@ export const HTTPS_URL = /^https?:\/\//i; // check if ethereum address export const ETH_ADDRESS = /^0x[a-fA-F0-9]{40}$/; + +// check markdown link +export const MARKDOWN_LINK_REGEX = /\[([^\]]+)]\((https?:\/\/[^\s]+)\)/g; diff --git a/schema.graphql b/schema.graphql index b8f5743f02..2ece0ebf35 100644 --- a/schema.graphql +++ b/schema.graphql @@ -43,6 +43,7 @@ type Admire implements Node { creationTime: Time lastUpdated: Time admirer: GalleryUser + source: AdmireSource } type AdmireFeedEventPayload { @@ -61,6 +62,8 @@ type AdmirePostPayload { union AdmirePostPayloadOrError = AdmirePostPayload | ErrInvalidInput | ErrNotAuthorized +union AdmireSource = Post | FeedEvent + type AdmireTokenPayload { viewer: Viewer token: Token @@ -260,6 +263,15 @@ type Comment implements Node { replyTo: Comment commenter: GalleryUser comment: String + mentions: [Mention] + replies(before: String, after: String, first: Int, last: Int): CommentsConnection + source: CommentSource + deleted: Boolean +} + +type CommentEdge { + node: Comment + cursor: String } type CommentOnFeedEventPayload { @@ -280,6 +292,13 @@ type CommentOnPostPayload { union CommentOnPostPayloadOrError = CommentOnPostPayload | ErrInvalidInput | ErrNotAuthorized +type CommentsConnection { + edges: [CommentEdge] + pageInfo: PageInfo! +} + +union CommentSource = Post | FeedEvent + type CommunitiesConnection { edges: [CommunityEdge] pageInfo: PageInfo! @@ -542,6 +561,10 @@ type ErrNeedsToReconnectSocial implements Error { message: String! } +type ErrNoAvatarRecordSet implements Error { + message: String! +} + type ErrNoCookie implements Error { message: String! } @@ -596,6 +619,10 @@ type FallbackMedia { mediaType: String } +input FarcasterAuth { + address: Address! +} + type FarcasterSocialAccount implements SocialAccount { type: SocialAccountType! social_id: String! @@ -623,14 +650,13 @@ type FeedEvent implements Node { admires(before: String, after: String, first: Int, last: Int): FeedEventAdmiresConnection comments(before: String, after: String, first: Int, last: Int): FeedEventCommentsConnection caption: String - interactions(before: String, after: String, first: Int, last: Int): FeedEventInteractionsConnection + interactions(before: String, after: String, first: Int, last: Int): InteractionsConnection viewerAdmire: Admire hasViewerAdmiredEvent: Boolean } type FeedEventAdmireEdge { node: Admire - event: FeedEvent cursor: String } @@ -643,7 +669,6 @@ union FeedEventByIdOrError = FeedEvent | ErrFeedEventNotFound | ErrUnknownAction type FeedEventCommentEdge { node: Comment - event: FeedEvent cursor: String } @@ -658,17 +683,6 @@ interface FeedEventData { action: Action } -type FeedEventInteractionsConnection { - edges: [FeedEventInteractionsEdge] - pageInfo: PageInfo! -} - -type FeedEventInteractionsEdge { - node: Interaction - event: FeedEvent - cursor: String -} - """Can return posts as well""" union FeedEventOrError = FeedEvent | Post | ErrPostNotFound | ErrFeedEventNotFound | ErrUnknownAction @@ -752,7 +766,7 @@ type GalleryUser implements Node { isAuthenticatedUser: Boolean followers: [GalleryUser] following: [GalleryUser] - feed(before: String, after: String, first: Int, last: Int, includePosts: Boolean! = false): FeedConnection + feed(before: String, after: String, first: Int, last: Int): FeedConnection sharedFollowers(before: String, after: String, first: Int, last: Int): UsersConnection sharedCommunities(before: String, after: String, first: Int, last: Int): CommunitiesConnection createdCommunities(input: CreatedCommunitiesInput!, before: String, after: String, first: Int, last: Int): CommunitiesConnection @@ -837,11 +851,31 @@ type ImageMedia implements Media { union Interaction = Admire | Comment +type InteractionsConnection { + edges: [InteractionsEdge] + pageInfo: PageInfo! +} + +type InteractionsEdge { + node: Interaction + cursor: String +} + enum InteractionType { Admire Comment } +type Interval { + start: Int! + length: Int! +} + +input IntervalInput { + start: Int! + length: Int! +} + type InvalidMedia implements Media { previewURLs: PreviewURLSet mediaURL: String @@ -860,6 +894,10 @@ type JsonMedia implements Media { fallbackMedia: FallbackMedia } +input LensAuth { + address: Address! +} + type LensSocialAccount implements SocialAccount { type: SocialAccountType! social_id: String! @@ -910,6 +948,21 @@ type MembershipTier implements Node { owners: [TokenHolder] } +type Mention { + entity: MentionEntity + interval: Interval +} + +union MentionEntity = GalleryUser | Community + +input MentionInput { + interval: IntervalInput + userId: DBID + communityId: DBID +} + +union MentionSource = Comment | Post + type MerchDiscountCode { code: String! tokenId: String @@ -974,7 +1027,7 @@ type Mutation { updateCollectionHidden(input: UpdateCollectionHiddenInput!): UpdateCollectionHiddenPayloadOrError updateTokenInfo(input: UpdateTokenInfoInput!): UpdateTokenInfoPayloadOrError setSpamPreference(input: SetSpamPreferenceInput!): SetSpamPreferencePayloadOrError - syncTokens(chains: [Chain!]): SyncTokensPayloadOrError + syncTokens(chains: [Chain!], incrementally: Boolean): SyncTokensPayloadOrError syncCreatedTokensForNewContracts(input: SyncCreatedTokensForNewContractsInput!): SyncCreatedTokensForNewContractsPayloadOrError syncCreatedTokensForExistingContract(input: SyncCreatedTokensForExistingContractInput!): SyncCreatedTokensForExistingContractPayloadOrError refreshToken(tokenId: DBID!): RefreshTokenPayloadOrError @@ -998,9 +1051,9 @@ type Mutation { admirePost(postId: DBID!): AdmirePostPayloadOrError admireToken(tokenId: DBID!): AdmireTokenPayloadOrError removeAdmire(admireId: DBID!): RemoveAdmirePayloadOrError - commentOnFeedEvent(feedEventId: DBID!, replyToID: DBID, comment: String!): CommentOnFeedEventPayloadOrError + commentOnFeedEvent(feedEventId: DBID!, replyToID: DBID, comment: String!, mentions: [MentionInput!]): CommentOnFeedEventPayloadOrError removeComment(commentId: DBID!): RemoveCommentPayloadOrError - commentOnPost(postId: DBID!, replyToID: DBID, comment: String!): CommentOnPostPayloadOrError + commentOnPost(postId: DBID!, replyToID: DBID, comment: String!, mentions: [MentionInput!]): CommentOnPostPayloadOrError postTokens(input: PostTokensInput!): PostTokensPayloadOrError referralPostToken(input: ReferralPostTokenInput!): ReferralPostTokenPayloadOrError referralPostPreflight(input: ReferralPostPreflightInput!): ReferralPostPreflightPayloadOrError @@ -1028,6 +1081,7 @@ type Mutation { revokeRolesFromUser(username: String!, roles: [Role]): RevokeRolesFromUserPayloadOrError syncTokensForUsername(username: String!, chains: [Chain!]!): SyncTokensForUsernamePayloadOrError syncCreatedTokensForUsername(username: String!, chains: [Chain!]!): SyncCreatedTokensForUsernamePayloadOrError + syncCreatedTokensForUsernameAndExistingContract(username: String!, chainAddress: ChainAddressInput!): SyncCreatedTokensForUsernameAndExistingContractPayloadOrError banUserFromFeed(username: String!, action: String!): BanUserFromFeedPayloadOrError unbanUserFromFeed(username: String!): UnbanUserFromFeedPayloadOrError mintPremiumCardToWallet(input: MintPremiumCardToWalletInput!): MintPremiumCardToWalletPayloadOrError @@ -1131,16 +1185,16 @@ type Post implements Node { creationTime: Time tokens: [Token] caption: String + mentions: [Mention] admires(before: String, after: String, first: Int, last: Int): PostAdmiresConnection comments(before: String, after: String, first: Int, last: Int): PostCommentsConnection - interactions(before: String, after: String, first: Int, last: Int): PostInteractionsConnection + interactions(before: String, after: String, first: Int, last: Int): InteractionsConnection viewerAdmire: Admire } type PostAdmireEdge { node: Admire cursor: String - post: Post } type PostAdmiresConnection { @@ -1151,7 +1205,6 @@ type PostAdmiresConnection { type PostCommentEdge { node: Comment cursor: String - post: Post } type PostCommentsConnection { @@ -1170,24 +1223,13 @@ type PostComposerDraftDetailsPayload { tokenDescription: String } -union PostComposerDraftDetailsPayloadOrError = PostComposerDraftDetailsPayload | ErrInvalidInput +union PostComposerDraftDetailsPayloadOrError = PostComposerDraftDetailsPayload | ErrInvalidInput | ErrCommunityNotFound type PostEdge { node: PostOrError cursor: String } -type PostInteractionsConnection { - edges: [PostInteractionsEdge] - pageInfo: PageInfo! -} - -type PostInteractionsEdge { - node: Interaction - cursor: String - post: Post -} - union PostOrError = Post | ErrPostNotFound | ErrInvalidInput type PostsConnection { @@ -1198,6 +1240,7 @@ type PostsConnection { input PostTokensInput { tokenIds: [DBID!] caption: String + mentions: [MentionInput!] } type PostTokensPayload { @@ -1265,9 +1308,9 @@ type Query { communityByAddress(communityAddress: ChainAddressInput!, forceRefresh: Boolean): CommunityByAddressOrError generalAllowlist: [ChainAddress!] galleryOfTheWeekWinners: [GalleryUser!] - globalFeed(before: String, after: String, first: Int, last: Int, includePosts: Boolean! = false): FeedConnection - trendingFeed(before: String, after: String, first: Int, last: Int, includePosts: Boolean! = false): FeedConnection - curatedFeed(before: String, after: String, first: Int, last: Int, includePosts: Boolean! = false): FeedConnection + globalFeed(before: String, after: String, first: Int, last: Int): FeedConnection + trendingFeed(before: String, after: String, first: Int, last: Int): FeedConnection + curatedFeed(before: String, after: String, first: Int, last: Int): FeedConnection feedEventById(id: DBID!): FeedEventByIdOrError postById(id: DBID!): PostOrError getMerchTokens(wallet: Address!): MerchTokensPayloadOrError @@ -1446,7 +1489,7 @@ type SetProfileImagePayload { viewer: Viewer } -union SetProfileImagePayloadOrError = SetProfileImagePayload | ErrAuthenticationFailed | ErrUserNotFound | ErrInvalidInput | ErrTokenNotFound | ErrNotAuthorized +union SetProfileImagePayloadOrError = SetProfileImagePayload | ErrAuthenticationFailed | ErrUserNotFound | ErrInvalidInput | ErrTokenNotFound | ErrNotAuthorized | ErrNoAvatarRecordSet input SetSpamPreferenceInput { tokens: [DBID!]! @@ -1480,6 +1523,8 @@ enum SocialAccountType { input SocialAuthMechanism { twitter: TwitterAuth debug: DebugSocialAuth + farcaster: FarcasterAuth + lens: LensAuth } type SocialConnection implements Node { @@ -1533,6 +1578,17 @@ type SomeoneAdmiredYourPostNotification implements Notification & Node & Grouped admirers(before: String, after: String, first: Int, last: Int): GroupNotificationUsersConnection } +type SomeoneAdmiredYourTokenNotification implements Notification & Node & GroupedNotification { + id: ID! + dbid: DBID! + seen: Boolean + creationTime: Time + updatedTime: Time + count: Int + token: Token + admirers(before: String, after: String, first: Int, last: Int): GroupNotificationUsersConnection +} + type SomeoneCommentedOnYourFeedEventNotification implements Notification & Node { id: ID! dbid: DBID! @@ -1573,6 +1629,35 @@ type SomeoneFollowedYouNotification implements Notification & Node & GroupedNoti followers(before: String, after: String, first: Int, last: Int): GroupNotificationUsersConnection } +type SomeoneMentionedYouNotification implements Notification & Node { + id: ID! + dbid: DBID! + seen: Boolean + creationTime: Time + updatedTime: Time + mentionSource: MentionSource +} + +type SomeoneMentionedYourCommunityNotification implements Notification & Node { + id: ID! + dbid: DBID! + seen: Boolean + creationTime: Time + updatedTime: Time + mentionSource: MentionSource + community: Community +} + +type SomeoneRepliedToYourCommentNotification implements Notification & Node { + id: ID! + dbid: DBID! + seen: Boolean + creationTime: Time + updatedTime: Time + comment: Comment + originalComment: Comment +} + type SomeoneViewedYourGalleryNotification implements Notification & Node & GroupedNotification { id: ID! dbid: DBID! @@ -1610,6 +1695,12 @@ type SyncCreatedTokensForNewContractsPayload { union SyncCreatedTokensForNewContractsPayloadOrError = SyncCreatedTokensForNewContractsPayload | ErrNotAuthorized | ErrSyncFailed +type SyncCreatedTokensForUsernameAndExistingContractPayload { + message: String! +} + +union SyncCreatedTokensForUsernameAndExistingContractPayloadOrError = SyncCreatedTokensForUsernameAndExistingContractPayload | ErrInvalidInput | ErrNotAuthorized | ErrSyncFailed | ErrCommunityNotFound + type SyncCreatedTokensForUsernamePayload { message: String! } @@ -2109,7 +2200,7 @@ type Viewer implements Node { user: GalleryUser socialAccounts: SocialAccounts viewerGalleries: [ViewerGallery] - feed(before: String, after: String, first: Int, last: Int, includePosts: Boolean! = false): FeedConnection + feed(before: String, after: String, first: Int, last: Int): FeedConnection email: UserEmail """