diff --git a/src/apps/app-router.tsx b/src/apps/app-router.tsx index 539b2d81e..99caf840c 100644 --- a/src/apps/app-router.tsx +++ b/src/apps/app-router.tsx @@ -15,7 +15,7 @@ const redirectToRoot = () => ; export const AppRouter = () => { return ( - + {featureFlags.enableNotificationsApp && } diff --git a/src/components/messenger/feed/components/comment-input/index.tsx b/src/components/messenger/feed/components/comment-input/index.tsx new file mode 100644 index 000000000..03fa4ff15 --- /dev/null +++ b/src/components/messenger/feed/components/comment-input/index.tsx @@ -0,0 +1,24 @@ +import { ViewModes } from '../../../../../shared-components/theme-engine'; +import { PostInput } from '../post-input'; + +import styles from './styles.module.scss'; +import { useCommentInput } from './useCommentInput'; + +export interface CommentInputProps { + postId: string; +} + +export const CommentInput = ({ postId }: CommentInputProps) => { + const { error, isConnected, onSubmit } = useCommentInput(postId); + + return ( + + ); +}; diff --git a/src/components/messenger/feed/components/comment-input/styles.module.scss b/src/components/messenger/feed/components/comment-input/styles.module.scss new file mode 100644 index 000000000..2de5c7089 --- /dev/null +++ b/src/components/messenger/feed/components/comment-input/styles.module.scss @@ -0,0 +1,11 @@ +.Input { + width: 100% !important; + box-sizing: border-box !important; + margin-top: 0; + border: none; + + [class*='__create-outer'] { + padding: 0; + border: none; + } +} diff --git a/src/components/messenger/feed/components/comment-input/useCommentInput.ts b/src/components/messenger/feed/components/comment-input/useCommentInput.ts new file mode 100644 index 000000000..24f24e437 --- /dev/null +++ b/src/components/messenger/feed/components/comment-input/useCommentInput.ts @@ -0,0 +1,21 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { SagaActionTypes } from '../../../../../store/posts'; +import { RootState } from '../../../../../store'; +import { useAccount } from 'wagmi'; + +export const useCommentInput = (postId: string) => { + const dispatch = useDispatch(); + const channelId = useSelector((state: RootState) => state.chat.activeConversationId); + const error = useSelector((state: RootState) => state.posts.error); + const { isConnected } = useAccount(); + + const onSubmit = (message: string) => { + dispatch({ type: SagaActionTypes.SendPost, payload: { channelId, replyToId: postId, message } }); + }; + + return { + error, + isConnected, + onSubmit, + }; +}; diff --git a/src/components/messenger/feed/components/main-feed/index.tsx b/src/components/messenger/feed/components/main-feed/index.tsx new file mode 100644 index 000000000..d14115bfb --- /dev/null +++ b/src/components/messenger/feed/components/main-feed/index.tsx @@ -0,0 +1,16 @@ +import { useMainFeed } from './useMainFeed'; + +import { PostInputContainer as PostInput } from '../post-input/container'; +import { FeedViewContainer } from '../../feed-view-container/feed-view-container'; +import { ScrollbarContainer } from '../../../../scrollbar-container'; + +export const MainFeed = () => { + const { activeConversationId, isSubmittingPost, onSubmitPost } = useMainFeed(); + + return ( + + + + + ); +}; diff --git a/src/components/messenger/feed/components/main-feed/useMainFeed.ts b/src/components/messenger/feed/components/main-feed/useMainFeed.ts new file mode 100644 index 000000000..88dbc7996 --- /dev/null +++ b/src/components/messenger/feed/components/main-feed/useMainFeed.ts @@ -0,0 +1,24 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { SagaActionTypes } from '../../../../../store/posts'; +import { RootState } from '../../../../../store'; +import { Media } from '../../../../message-input/utils'; + +export const useMainFeed = () => { + const dispatch = useDispatch(); + + const activeConversationId = useSelector((state: RootState) => state.chat.activeConversationId); + const isSubmittingPost = useSelector((state: RootState) => state.posts.isSubmitting); + + const onSubmitPost = (message: string, media: Media[] = []) => { + dispatch({ + type: SagaActionTypes.SendPost, + payload: { channelId: activeConversationId, message: message, files: media }, + }); + }; + + return { + activeConversationId, + isSubmittingPost, + onSubmitPost, + }; +}; diff --git a/src/components/messenger/feed/components/post-input/container.tsx b/src/components/messenger/feed/components/post-input/container.tsx index a8248e441..fff03d59c 100644 --- a/src/components/messenger/feed/components/post-input/container.tsx +++ b/src/components/messenger/feed/components/post-input/container.tsx @@ -16,6 +16,8 @@ export interface PublicProperties { initialValue?: string; isSubmitting?: boolean; error?: string; + className?: string; + variant?: 'comment' | 'post'; onSubmit: (message: string, media: Media[]) => void; onPostInputRendered?: (textareaRef: RefObject) => void; @@ -58,6 +60,7 @@ export class Container extends React.Component { return ( { avatarUrl={this.props.user.data?.profileSummary.profileImage} viewMode={this.props.viewMode} isWalletConnected={this.props.isWalletConnected} + variant={this.props.variant ?? 'post'} /> ); } diff --git a/src/components/messenger/feed/components/post-input/index.tsx b/src/components/messenger/feed/components/post-input/index.tsx index 1fccf1cbe..c8dcfeb83 100644 --- a/src/components/messenger/feed/components/post-input/index.tsx +++ b/src/components/messenger/feed/components/post-input/index.tsx @@ -11,6 +11,7 @@ import { ViewModes } from '../../../../../shared-components/theme-engine'; import { IconFaceSmile } from '@zero-tech/zui/icons'; import { bemClassName } from '../../../../../lib/bem'; +import classNames from 'classnames'; import './styles.scss'; // should move these to a shared location @@ -204,7 +205,7 @@ export class PostInput extends React.Component { {...cn('input')} onChange={this.onChange} onKeyDown={this.onKeyDown} - placeholder='Write a Post...' + placeholder={this.props.variant === 'comment' ? 'Write a Comment...' : 'Write a Post...'} ref={this.textareaRef} rows={2} value={this.state.value} @@ -258,6 +259,6 @@ export class PostInput extends React.Component { } render() { - return
{this.renderInput()}
; + return
{this.renderInput()}
; } } diff --git a/src/components/messenger/feed/components/post/actions/reply/reply-action.module.scss b/src/components/messenger/feed/components/post/actions/reply/reply-action.module.scss new file mode 100644 index 000000000..afd72136d --- /dev/null +++ b/src/components/messenger/feed/components/post/actions/reply/reply-action.module.scss @@ -0,0 +1,8 @@ +.Container > *:active { + background: none !important; +} + +.Disabled { + pointer-events: none; + opacity: 0.5; +} diff --git a/src/components/messenger/feed/components/post/actions/reply/reply-action.tsx b/src/components/messenger/feed/components/post/actions/reply/reply-action.tsx new file mode 100644 index 000000000..b03d99e12 --- /dev/null +++ b/src/components/messenger/feed/components/post/actions/reply/reply-action.tsx @@ -0,0 +1,21 @@ +import { Action } from '@zero-tech/zui/components/Post'; +import { IconMessageChatSquare } from '@zero-tech/zui/components/Icons'; + +import styles from './reply-action.module.scss'; +import { useReplyAction } from './useReplyAction'; + +export interface ReplyActionProps { + postId: string; + numberOfReplies: number; +} + +export const ReplyAction = ({ postId, numberOfReplies }: ReplyActionProps) => { + const { handleOnClick } = useReplyAction(postId); + + return ( + + + {numberOfReplies > 9 ? '9+' : numberOfReplies} + + ); +}; diff --git a/src/components/messenger/feed/components/post/actions/reply/useReplyAction.ts b/src/components/messenger/feed/components/post/actions/reply/useReplyAction.ts new file mode 100644 index 000000000..ede779311 --- /dev/null +++ b/src/components/messenger/feed/components/post/actions/reply/useReplyAction.ts @@ -0,0 +1,16 @@ +import { useHistory, useRouteMatch } from 'react-router-dom'; + +export const useReplyAction = (postId: string) => { + const history = useHistory(); + const route = useRouteMatch(); + + const handleOnClick = () => { + const params = route.params; + const { conversationId } = params; + history.push(`/conversation/${conversationId}/${postId}`); + }; + + return { + handleOnClick, + }; +}; diff --git a/src/components/messenger/feed/components/post/index.tsx b/src/components/messenger/feed/components/post/index.tsx index 370754f16..5224ae894 100644 --- a/src/components/messenger/feed/components/post/index.tsx +++ b/src/components/messenger/feed/components/post/index.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useMemo } from 'react'; +import moment from 'moment'; import { Name, Post as ZUIPost } from '@zero-tech/zui/components/Post'; import { Timestamp } from '@zero-tech/zui/components/Post/components/Timestamp'; import { Avatar } from '@zero-tech/zui/components'; @@ -6,11 +7,16 @@ import { MeowAction } from './actions/meow'; import { featureFlags } from '../../../../../lib/feature-flags'; import { Media, MediaDownloadStatus, MediaType } from '../../../../../store/messages'; import { IconAlertCircle } from '@zero-tech/zui/icons'; +import { ReplyAction } from './actions/reply/reply-action'; +import { formatWeiAmount } from '../../../../../lib/number'; +import classNames from 'classnames'; import styles from './styles.module.scss'; -import { formatWeiAmount } from '../../../../../lib/number'; + +type Variant = 'default' | 'expanded'; export interface PostProps { + className?: string; currentUserId?: string; messageId: string; avatarUrl?: string; @@ -22,12 +28,15 @@ export interface PostProps { ownerUserId?: string; userMeowBalance?: string; reactions?: { [key: string]: number }; + variant?: Variant; + numberOfReplies?: number; loadAttachmentDetails: (payload: { media: Media; messageId: string }) => void; meowPost: (postId: string, meowAmount: string) => void; } export const Post = ({ + className, currentUserId, messageId, avatarUrl, @@ -41,11 +50,14 @@ export const Post = ({ reactions, loadAttachmentDetails, meowPost, + variant = 'default', + numberOfReplies = 0, }: PostProps) => { const [isImageLoaded, setIsImageLoaded] = useState(false); const isMeowsEnabled = featureFlags.enableMeows; - const isDisabled = formatWeiAmount(userMeowBalance) <= '0' || ownerUserId === currentUserId; + const isDisabled = + formatWeiAmount(userMeowBalance) <= '0' || ownerUserId?.toLowerCase() === currentUserId?.toLowerCase(); const multilineText = useMemo( () => @@ -139,16 +151,21 @@ export const Post = ({ ); return ( -
-
- -
+
+ {variant === 'default' && ( +
+ +
+ )} {multilineText} {media && renderMedia(media)} + {variant === 'expanded' && ( + {moment(timestamp).format('h:mm A - D MMM YYYY')} + )}
} details={ @@ -167,19 +184,30 @@ export const Post = ({ )} } - options={} + options={variant === 'default' && } actions={ isMeowsEnabled && ( - 0} - /> + + 0} + /> + {featureFlags.enableComments && } + ) } />
); }; + +export const Actions = ({ children, variant }: { children: React.ReactNode; variant: Variant }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/messenger/feed/components/post/styles.module.scss b/src/components/messenger/feed/components/post/styles.module.scss index a0ad7723a..4adfdb5e0 100644 --- a/src/components/messenger/feed/components/post/styles.module.scss +++ b/src/components/messenger/feed/components/post/styles.module.scss @@ -18,6 +18,12 @@ margin-top: 4px; } } + + &[data-variant='expanded'] { + article { + gap: 16px; + } + } } .Post { @@ -114,3 +120,19 @@ border: none; color: theme.$color-failure-11; } + +.Actions { + display: inherit; + gap: inherit; + + width: 100%; + + &[data-variant='expanded'] { + padding: 16px 0; + gap: 16px; + + border: 1px solid rgba(52, 56, 60, 0.5); + border-left: none; + border-right: none; + } +} diff --git a/src/components/messenger/feed/components/posts/index.tsx b/src/components/messenger/feed/components/posts/index.tsx index 88477a194..1defbf03a 100644 --- a/src/components/messenger/feed/components/posts/index.tsx +++ b/src/components/messenger/feed/components/posts/index.tsx @@ -21,6 +21,7 @@ export const Posts = ({ postMessages, loadAttachmentDetails, userMeowBalance, cu userMeowBalance={userMeowBalance} reactions={post.reactions} meowPost={meowPost} + numberOfReplies={post.numberOfReplies} /> ))} diff --git a/src/components/messenger/feed/index.test.tsx b/src/components/messenger/feed/index.test.tsx index 231d78035..4dba96cf4 100644 --- a/src/components/messenger/feed/index.test.tsx +++ b/src/components/messenger/feed/index.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Container as MessengerFeed, Properties } from '.'; import { StoreBuilder, stubConversation } from '../../../store/test/store'; -import { PostInputContainer } from './components/post-input/container'; import { LeaveGroupDialogStatus } from '../../../store/group-management'; import { LeaveGroupDialogContainer } from '../../group-management/leave-group-dialog/container'; @@ -23,13 +22,6 @@ describe(MessengerFeed, () => { return shallow(); }; - it('renders Messenger Feed when isSocialChannel is true', () => { - const channel = stubConversation({ name: 'convo-1', hasLoadedMessages: true, messages: [] }); - - const wrapper = subject({ channel: channel as any, isSocialChannel: true }); - expect(wrapper).toHaveElement(PostInputContainer); - }); - it('does not render Messenger Feed when isSocialChannel is false', () => { const channel = stubConversation({ name: 'convo-1', hasLoadedMessages: true, messages: [] }); diff --git a/src/components/messenger/feed/index.tsx b/src/components/messenger/feed/index.tsx index f4f85a5a1..4fec4f045 100644 --- a/src/components/messenger/feed/index.tsx +++ b/src/components/messenger/feed/index.tsx @@ -1,21 +1,21 @@ import React from 'react'; import { RootState } from '../../../store/reducer'; import { connectContainer } from '../../../store/redux-container'; -import { ScrollbarContainer } from '../../scrollbar-container'; import { PostPayload as PayloadPostMessage } from '../../../store/posts/saga'; import { Channel, denormalize } from '../../../store/channels'; import { sendPost } from '../../../store/posts'; -import { FeedViewContainer } from './feed-view-container/feed-view-container'; -import { PostInputContainer as PostInput } from './components/post-input/container'; import { ConversationHeaderContainer as ConversationHeader } from '../conversation-header/container'; import { LeaveGroupDialogContainer } from '../../group-management/leave-group-dialog/container'; import { LeaveGroupDialogStatus, setLeaveGroupStatus } from '../../../store/group-management'; +import { Switch, Route } from 'react-router-dom'; import { bemClassName } from '../../../lib/bem'; import './styles.scss'; // should move this to a shared location import { Media } from '../../message-input/utils'; +import { PostView } from './post-view-container'; +import { MainFeed } from './components/main-feed'; const cn = bemClassName('messenger-feed'); @@ -109,12 +109,14 @@ export class Container extends React.Component { - - - - - - + + } + /> + + {this.isLeaveGroupDialogOpen && this.renderLeaveGroupDialog()} ); diff --git a/src/components/messenger/feed/lib/useMeowPost.ts b/src/components/messenger/feed/lib/useMeowPost.ts new file mode 100644 index 000000000..07c30dbb4 --- /dev/null +++ b/src/components/messenger/feed/lib/useMeowPost.ts @@ -0,0 +1,14 @@ +import { useDispatch } from 'react-redux'; +import { SagaActionTypes } from '../../../../store/posts'; + +export const useMeowPost = () => { + const dispatch = useDispatch(); + + const meowPost = (postId: string, meowAmount: string) => { + dispatch({ type: SagaActionTypes.MeowPost, payload: { postId, meowAmount } }); + }; + + return { + meowPost, + }; +}; diff --git a/src/components/messenger/feed/post-view-container/back-button/index.tsx b/src/components/messenger/feed/post-view-container/back-button/index.tsx new file mode 100644 index 000000000..23995a80b --- /dev/null +++ b/src/components/messenger/feed/post-view-container/back-button/index.tsx @@ -0,0 +1,30 @@ +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { IconArrowLeft } from '@zero-tech/zui/icons'; + +import styles from './styles.module.scss'; + +interface BackButtonProps { + backToId?: string; +} + +export const BackButton = ({ backToId }: BackButtonProps) => { + const history = useHistory(); + const route = useRouteMatch(); + + const handleOnClick = () => { + const params = route.params; + const { conversationId } = params; + + if (backToId) { + history.push(`/conversation/${conversationId}/${backToId}`); + } else { + history.push(`/conversation/${conversationId}`); + } + }; + + return ( + + ); +}; diff --git a/src/components/messenger/feed/post-view-container/back-button/styles.module.scss b/src/components/messenger/feed/post-view-container/back-button/styles.module.scss new file mode 100644 index 000000000..9c32f4955 --- /dev/null +++ b/src/components/messenger/feed/post-view-container/back-button/styles.module.scss @@ -0,0 +1,15 @@ +.Back { + background: none; + outline: none; + border: none; + + cursor: pointer; + + padding: 16px 0; + + display: flex; + align-items: center; + gap: 4px; + + font-weight: bold; +} diff --git a/src/components/messenger/feed/post-view-container/index.tsx b/src/components/messenger/feed/post-view-container/index.tsx new file mode 100644 index 000000000..f1f1fa5d4 --- /dev/null +++ b/src/components/messenger/feed/post-view-container/index.tsx @@ -0,0 +1,69 @@ +import { usePostView } from './usePostView'; + +import { Post } from '../components/post'; +import { CommentInput } from '../components/comment-input'; +import { IconAlertCircle } from '@zero-tech/zui/icons'; +import { BackButton } from './back-button'; +import { Replies } from './reply-list'; +import { ScrollbarContainer } from '../../../scrollbar-container'; + +import styles from './styles.module.scss'; + +export interface PostViewProps { + postId: string; +} + +export const PostView = ({ postId }: PostViewProps) => { + const { isLoadingPost, meowPost, post, userId, userMeowBalance } = usePostView(postId); + + if (!isLoadingPost && !post) { + return ( + + + Failed to load post + + + ); + } + + return ( + + + {post !== undefined && ( + <> + +
+ {}} + meowPost={meowPost} + currentUserId={userId} + ownerUserId={post.sender?.userId} + userMeowBalance={userMeowBalance} + reactions={post.reactions} + media={post.media} + numberOfReplies={post.numberOfReplies} + /> + +
+ + + )} +
+
+ ); +}; + +const Message = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; + +const Wrapper = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; diff --git a/src/components/messenger/feed/post-view-container/reply-list/index.tsx b/src/components/messenger/feed/post-view-container/reply-list/index.tsx new file mode 100644 index 000000000..859ed2b0c --- /dev/null +++ b/src/components/messenger/feed/post-view-container/reply-list/index.tsx @@ -0,0 +1,40 @@ +import { useReplyList } from './useReplyList'; + +import { Post } from '../../components/post'; +import { Waypoint } from 'react-waypoint'; + +import styles from './styles.module.scss'; + +export const Replies = ({ postId }: { postId: string }) => { + const { fetchNextPage, hasNextPage, isFetchingNextPage, replies, userId, userMeowBalance, meowPost } = + useReplyList(postId); + + return ( +
+
    + {replies?.pages.map((page) => + page.map((reply) => ( +
  1. + {}} + meowPost={meowPost} + currentUserId={userId} + reactions={reply.reactions} + ownerUserId={reply.walletAddress} + userMeowBalance={userMeowBalance} + numberOfReplies={reply.numberOfReplies} + /> +
  2. + )) + )} +
+ {hasNextPage && !isFetchingNextPage && fetchNextPage()} />} +
+ ); +}; diff --git a/src/components/messenger/feed/post-view-container/reply-list/styles.module.scss b/src/components/messenger/feed/post-view-container/reply-list/styles.module.scss new file mode 100644 index 000000000..af5be48b0 --- /dev/null +++ b/src/components/messenger/feed/post-view-container/reply-list/styles.module.scss @@ -0,0 +1,17 @@ +.Replies { + width: 100%; + + ol { + list-style: none; + padding: 0; + margin: 0; + } +} + +.Reply { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--color-greyscale-7); + border-top: none; + padding: 16px; +} diff --git a/src/components/messenger/feed/post-view-container/reply-list/useReplyList.ts b/src/components/messenger/feed/post-view-container/reply-list/useReplyList.ts new file mode 100644 index 000000000..4b2c1022c --- /dev/null +++ b/src/components/messenger/feed/post-view-container/reply-list/useReplyList.ts @@ -0,0 +1,35 @@ +import { useSelector } from 'react-redux'; +import { RootState } from '../../../../../store'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getPostReplies, mapPostToMatrixMessage } from '../../../../../store/posts/utils'; +import { useMeowPost } from '../../lib/useMeowPost'; + +const PAGE_SIZE = 10; + +export const useReplyList = (postId: string) => { + const userId = useSelector((state: RootState) => state.authentication.user.data.id); + const userMeowBalance = useSelector((state: RootState) => state.rewards.meow); + const { meowPost } = useMeowPost(); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ['posts', 'replies', { postId }], + queryFn: async ({ pageParam = 0 }) => { + const res = await getPostReplies(postId, { limit: PAGE_SIZE, skip: pageParam * PAGE_SIZE }); + return res.replies?.map((reply) => mapPostToMatrixMessage(reply)); + }, + getNextPageParam: (lastPage, allPages) => { + return lastPage.length === PAGE_SIZE ? allPages.length : undefined; + }, + initialPageParam: 0, + }); + + return { + fetchNextPage, + hasNextPage, + isFetchingNextPage, + replies: data, + userId, + userMeowBalance, + meowPost, + }; +}; diff --git a/src/components/messenger/feed/post-view-container/styles.module.scss b/src/components/messenger/feed/post-view-container/styles.module.scss new file mode 100644 index 000000000..eaeb45721 --- /dev/null +++ b/src/components/messenger/feed/post-view-container/styles.module.scss @@ -0,0 +1,37 @@ +.Wrapper { + display: flex; + align-items: flex-start; + flex-direction: column; + + box-sizing: border-box; + + margin-top: calc(56px); +} + +.Details { + width: 100%; + display: flex; + flex-direction: column; + box-sizing: border-box; + gap: 16px; + border: 1px solid var(--color-greyscale-7); + padding: 16px; +} + +.Post { + width: 100%; + box-sizing: border-box; + + p { + font-size: 18px; + } +} + +.Message { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 16px; + margin-top: 64px; +} diff --git a/src/components/messenger/feed/post-view-container/usePostView.ts b/src/components/messenger/feed/post-view-container/usePostView.ts new file mode 100644 index 000000000..a3d05b253 --- /dev/null +++ b/src/components/messenger/feed/post-view-container/usePostView.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { useSelector } from 'react-redux'; +import { getPost } from '../../../../store/posts/utils'; +import { mapPostToMatrixMessage } from '../../../../store/posts/utils'; +import { RootState } from '../../../../store'; +import { useMeowPost } from '../lib/useMeowPost'; + +export const usePostView = (postId: string) => { + const userId = useSelector((state: RootState) => state.authentication.user.data.id); + const userMeowBalance = useSelector((state: RootState) => state.rewards.meow); + const { meowPost } = useMeowPost(); + + const { data, isLoading: isLoadingPost } = useQuery({ + queryKey: ['posts', { postId }], + queryFn: async () => { + const res = await getPost(postId); + return mapPostToMatrixMessage(res.post); + }, + }); + + return { + isLoadingPost, + meowPost, + post: data, + userId, + userMeowBalance, + }; +}; diff --git a/src/lib/feature-flags.ts b/src/lib/feature-flags.ts index a53657ca8..56100445c 100644 --- a/src/lib/feature-flags.ts +++ b/src/lib/feature-flags.ts @@ -161,6 +161,14 @@ export class FeatureFlags { set enableLoadMore(value: boolean) { this._setBoolean('enableLoadMore', value); } + + get enableComments() { + return this._getBoolean('enableComments', false); + } + + set enableComments(value: boolean) { + this._setBoolean('enableComments', value); + } } export const featureFlags = new FeatureFlags(); diff --git a/src/lib/web3/rainbowkit/provider.tsx b/src/lib/web3/rainbowkit/provider.tsx index ac3777882..2096fd4cd 100644 --- a/src/lib/web3/rainbowkit/provider.tsx +++ b/src/lib/web3/rainbowkit/provider.tsx @@ -6,7 +6,7 @@ import { getWagmiConfig } from '../wagmi-config'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RainbowKitProvider as RKProvider, darkTheme } from '@rainbow-me/rainbowkit'; -const queryClient = new QueryClient(); +export const queryClient = new QueryClient(); export interface RainbowKitProviderProps { children: ReactNode; diff --git a/src/store/posts/index.ts b/src/store/posts/index.ts index b908ffb76..d8f1725c3 100644 --- a/src/store/posts/index.ts +++ b/src/store/posts/index.ts @@ -14,6 +14,7 @@ export enum SagaActionTypes { MeowPost = 'posts/saga/meowPost', RefetchPosts = 'posts/saga/refetchPosts', PollPosts = 'posts/saga/pollPosts', + FetchPost = 'posts/saga/fetchPost', } export type PostsState = { @@ -21,6 +22,8 @@ export type PostsState = { isSubmitting: boolean; initialCount?: number; count?: number; + loadedPost?: Message; + isLoadingPost: boolean; }; export const initialState: PostsState = { @@ -28,6 +31,7 @@ export const initialState: PostsState = { isSubmitting: false, initialCount: undefined, count: undefined, + isLoadingPost: false, }; export const sendPost = createAction(SagaActionTypes.SendPost); @@ -35,7 +39,7 @@ export const fetchPosts = createAction(SagaActionTypes.FetchPosts); export const meowPost = createAction<{ postId: string; meowAmount: string; - channelId: string; + channelId?: string; }>(SagaActionTypes.MeowPost); export const refetchPosts = createAction<{ channelId: string; @@ -43,6 +47,9 @@ export const refetchPosts = createAction<{ export const pollPosts = createAction<{ channelId: string; }>(SagaActionTypes.PollPosts); +export const fetchPost = createAction<{ + postId: string; +}>(SagaActionTypes.FetchPost); const slice = createSlice({ name: 'posts', @@ -60,8 +67,14 @@ const slice = createSlice({ setCount: (state, action) => { state.count = action.payload; }, + setPost: (state, action) => { + state.loadedPost = action.payload; + }, + setIsLoadingPost: (state, action) => { + state.isLoadingPost = action.payload; + }, }, }); -export const { setError, setIsSubmitting, setInitialCount, setCount } = slice.actions; +export const { setError, setIsSubmitting, setInitialCount, setCount, setPost, setIsLoadingPost } = slice.actions; export const { reducer } = slice; diff --git a/src/store/posts/saga.ts b/src/store/posts/saga.ts index 62a9c62eb..8c4e1670e 100644 --- a/src/store/posts/saga.ts +++ b/src/store/posts/saga.ts @@ -2,7 +2,7 @@ import { takeLatest, call, select, put, delay } from 'redux-saga/effects'; import uniqBy from 'lodash.uniqby'; import BN from 'bn.js'; -import { SagaActionTypes, setCount, setError, setInitialCount } from '.'; +import { SagaActionTypes, setCount, setError, setInitialCount, setIsLoadingPost, setPost } from '.'; import { MediaType } from '../messages'; import { messageSelector, rawMessagesSelector } from '../messages/saga'; import { currentUserSelector } from '../authentication/saga'; @@ -20,9 +20,11 @@ import { uploadPost, meowPost as meowPostApi, getPostsInChannel, + getPost, } from './utils'; import { ethers } from 'ethers'; import { get } from '../../lib/api/rest'; +import { queryClient } from '../../lib/web3/rainbowkit/provider'; export interface Payload { channelId: string; @@ -43,10 +45,27 @@ export interface PostPayload { message?: string; files?: MediaInfo[]; optimisticId?: string; + replyToId?: string; +} + +export function* fetchPost(action) { + yield put(setIsLoadingPost(true)); + yield put(setPost(undefined)); + try { + const { postId } = action.payload; + const res = yield call(getPost, postId); + const post = mapPostToMatrixMessage(res.post); + yield put(setPost(post)); + } catch (error) { + console.error('Error fetching post:', error); + yield put(setPost(undefined)); + } finally { + yield put(setIsLoadingPost(false)); + } } export function* sendPost(action) { - const { channelId, message } = action.payload; + const { channelId, message, replyToId } = action.payload; const channel = yield select(rawChannelSelector(channelId)); @@ -125,6 +144,9 @@ export function* sendPost(action) { formData.append('signedMessage', signedPost); formData.append('zid', userZid); formData.append('walletAddress', connectedAddress); + if (replyToId) { + formData.append('replyTo', replyToId); + } let res; @@ -141,24 +163,31 @@ export function* sendPost(action) { const existingPosts = yield select(rawMessagesSelector(channelId)); const filteredPosts = existingPosts.filter((m) => !m.startsWith('$')); - yield call(receiveChannel, { - id: channelId, - messages: [ - ...filteredPosts, - mapPostToMatrixMessage({ - createdAt, - id: res.id, - text: message, - user: { - profileSummary: { - firstName: user.profileSummary?.firstName, + if (!replyToId) { + yield call(receiveChannel, { + id: channelId, + messages: [ + ...filteredPosts, + mapPostToMatrixMessage({ + createdAt, + id: res.id, + text: message, + user: { + profileSummary: { + firstName: user.profileSummary?.firstName, + }, }, - }, - userId: user.id, - zid: userZid, - }), - ], - }); + userId: user.id, + zid: userZid, + }), + ], + }); + } else { + // We're using a weird combination of react-query and redux-saga here... + // This is temporary until we separate the Feed app from the Messenger app. + queryClient.invalidateQueries({ queryKey: ['posts', { postId: replyToId }] }); + queryClient.invalidateQueries({ queryKey: ['posts', 'replies', { postId: replyToId }] }); + } const initialCount = yield select((state) => state.posts.initialCount); if (initialCount !== undefined) { @@ -231,25 +260,32 @@ function* meowPost(action) { try { const meowAmountWei = ethers.utils.parseEther(meowAmount.toString()); - const existingPost = yield select(messageSelector(postId)); - const meow = new BN(existingPost.reactions.MEOW ?? 0).add(new BN(meowAmount)).toString(); - const updatedPost = { ...existingPost, reactions: { ...existingPost.reactions, MEOW: meow, VOTED: 1 } }; + let existingPost, existingMessages; - const existingMessages = yield select(rawMessagesSelector(channelId)); - const updatedMessages = existingMessages.map((message) => (message === postId ? updatedPost : message)); + if (channelId) { + existingPost = yield select(messageSelector(postId)); + const meow = new BN(existingPost.reactions.MEOW ?? 0).add(new BN(meowAmount)).toString(); + const updatedPost = { ...existingPost, reactions: { ...existingPost.reactions, MEOW: meow, VOTED: 1 } }; - yield call(receiveChannel, { id: channelId, messages: updatedMessages }); - yield call(updateUserMeowBalance, existingPost.sender.userId, meowAmount); - yield call(updateUserMeowBalance, user.id, Number(meowAmount ?? 0) * -1); + existingMessages = yield select(rawMessagesSelector(channelId)); + const updatedMessages = existingMessages.map((message) => (message === postId ? updatedPost : message)); + + yield call(receiveChannel, { id: channelId, messages: updatedMessages }); + yield call(updateUserMeowBalance, existingPost.sender.userId, meowAmount); + yield call(updateUserMeowBalance, user.id, Number(meowAmount ?? 0) * -1); + } const res = yield call(meowPostApi, postId, meowAmountWei.toString()); - if (!res.ok) { + if (!res.ok && channelId) { yield call(receiveChannel, { id: channelId, messages: existingMessages }); yield call(updateUserMeowBalance, existingPost.sender.userId, Number(meowAmount) * -1); yield call(updateUserMeowBalance, user.id, Number(meowAmount)); - throw new Error('Failed to submit post'); + throw new Error('Failed to MEOW post'); } + + queryClient.invalidateQueries({ queryKey: ['posts', { postId }] }); + queryClient.invalidateQueries({ queryKey: ['posts', 'replies', { postId }] }); } catch (e) { console.error(e); } @@ -313,5 +349,6 @@ export function* saga() { yield takeLatest(SagaActionTypes.MeowPost, meowPost); yield takeLatest(SagaActionTypes.RefetchPosts, refetchPosts); yield takeLatest(SagaActionTypes.PollPosts, pollPosts); + yield takeLatest(SagaActionTypes.FetchPost, fetchPost); yield takeLatest(ChannelsEvents.OpenConversation, reset); } diff --git a/src/store/posts/utils.ts b/src/store/posts/utils.ts index 65e3267f6..18f7c30da 100644 --- a/src/store/posts/utils.ts +++ b/src/store/posts/utils.ts @@ -62,6 +62,8 @@ export function mapPostToMatrixMessage(post) { firstName: post.user?.profileSummary?.firstName, displaySubHandle: '0://' + post.zid, }, + replyTo: post.replyToPost, + numberOfReplies: post.replies?.length ?? 0, }; } @@ -74,19 +76,25 @@ export async function uploadPost(formData: FormData, worldZid: string) { return new Promise(async (resolve, reject) => { const endpoint = `/api/v2/posts/channel/${worldZid}`; - return post(endpoint) + let request = post(endpoint) .field('text', formData.get('text')) .field('unsignedMessage', formData.get('unsignedMessage')) .field('signedMessage', formData.get('signedMessage')) .field('zid', formData.get('zid')) - .field('walletAddress', formData.get('walletAddress')) - .end((err, res) => { - if (err) { - reject(new Error(res.body.message ?? 'Failed to upload post')); - } else { - resolve(res.body); - } - }); + .field('walletAddress', formData.get('walletAddress')); + + const replyTo = formData.get('replyTo'); + if (replyTo) { + request = request.field('replyTo', replyTo); + } + + request.end((err, res) => { + if (err) { + reject(new Error(res.body.message ?? 'Failed to upload post')); + } else { + resolve(res.body); + } + }); }); } @@ -123,7 +131,7 @@ export async function getPostsInChannel(channelZna: string, limit: number, skip: const endpoint = `/api/v2/posts/channel/${channelZna}`; try { - const res = await get(endpoint, undefined, { limit, skip }); + const res = await get(endpoint, undefined, { limit, skip, include_replies: true }); if (!res.ok || !res.body) { throw new Error(res); @@ -135,3 +143,25 @@ export async function getPostsInChannel(channelZna: string, limit: number, skip: throw new Error('Failed to fetch posts'); } } + +/** + * Gets a post from the API. + * @param postId the post to fetch + */ +export async function getPost(postId: string) { + const endpoint = `/api/v2/posts/${postId}`; + + try { + const res = await get(endpoint, undefined, { include_replies: true }); + return res.body; + } catch (e) { + console.error('Failed to fetch post', e); + throw new Error('Failed to fetch post'); + } +} + +export async function getPostReplies(postId: string, { limit, skip }: { limit: number; skip: number }) { + const endpoint = `/api/v2/posts/${postId}/replies`; + const res = await get(endpoint, undefined, { limit, skip, include_replies: true }); + return res.body; +}