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
"""