diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml index 9dc03278a..9de4ac6dc 100644 --- a/.github/workflows/build-deploy.yml +++ b/.github/workflows/build-deploy.yml @@ -60,6 +60,7 @@ jobs: GH_NEXT_PUBLIC_CONTEST_CHAT_ID=0x0106b70599fea6682ec0de3c6ab248d4 GH_NEXT_PUBLIC_CONTEST_NAME=MEMECOIN CONTEST GH_NEXT_PUBLIC_CONTEST_END_TIME=1720796400739 + GH_NEXT_PUBLIC_TIME_CONSTRAINT=300000 GH_NEXT_PUBLIC_AMP_ID=40d4174295c7edf657fc3bedf2748549 GH_NEXT_PUBLIC_COMMUNITY_HUB_ID=12455 GH_NEXT_PUBLIC_GA_ID=G-TP1XEFNHQD @@ -113,6 +114,7 @@ jobs: GH_NEXT_PUBLIC_CONTEST_CHAT_ID=0x850f0f5c0c244eba16425a464b0becfc GH_NEXT_PUBLIC_CONTEST_NAME=MEMECOIN CONTEST GH_NEXT_PUBLIC_CONTEST_END_TIME=1720796400739 + GH_NEXT_PUBLIC_TIME_CONSTRAINT=5000 GH_NEXT_PUBLIC_TELEGRAM_NOTIFICATION_BOT=https://t.me/g_notif_staging_bot/ GH_TELEGRAM_BOT_TOKEN="7038999347:AAGBgXTWcXpR4vZPW9A8_ia9PkWOpeyDeWA" # without base path diff --git a/ci.env b/ci.env index c5b6d4605..a9f4e29ef 100644 --- a/ci.env +++ b/ci.env @@ -7,6 +7,7 @@ NEXT_PUBLIC_MAIN_CHAT_ID='$GH_NEXT_PUBLIC_MAIN_CHAT_ID' NEXT_PUBLIC_CONTEST_CHAT_ID='$GH_NEXT_PUBLIC_CONTEST_CHAT_ID' NEXT_PUBLIC_CONTEST_NAME='$GH_NEXT_PUBLIC_CONTEST_NAME' NEXT_PUBLIC_CONTEST_END_TIME='$GH_NEXT_PUBLIC_CONTEST_END_TIME' +NEXT_PUBLIC_TIME_CONSTRAINT='$GH_NEXT_PUBLIC_TIME_CONSTRAINT' NEXT_PUBLIC_BASE_PATH='$GH_NEXT_PUBLIC_BASE_PATH' NEXT_PUBLIC_NEYNAR_CLIENT_ID='$GH_NEXT_PUBLIC_NEYNAR_CLIENT_ID' NEXT_PUBLIC_TELEGRAM_BOT_ID='$GH_NEXT_PUBLIC_TELEGRAM_BOT_ID' diff --git a/docker/Dockerfile b/docker/Dockerfile index 076cc58ae..c9dfe4402 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -14,6 +14,7 @@ ARG GH_NEXT_PUBLIC_MAIN_CHAT_ID ARG GH_NEXT_PUBLIC_CONTEST_CHAT_ID ARG GH_NEXT_PUBLIC_CONTEST_NAME ARG GH_NEXT_PUBLIC_CONTEST_END_TIME +ARG GH_NEXT_PUBLIC_TIME_CONSTRAINT ARG GH_NEXT_PUBLIC_BASE_PATH ARG GH_NEXT_PUBLIC_NEYNAR_CLIENT_ID ARG GH_NEXT_PUBLIC_TELEGRAM_BOT_ID @@ -42,6 +43,7 @@ ENV NEXTAUTH_URL=${GH_NEXTAUTH_URL} \ NEXT_PUBLIC_CONTEST_CHAT_ID=${GH_NEXT_PUBLIC_CONTEST_CHAT_ID} \ NEXT_PUBLIC_CONTEST_NAME=${GH_NEXT_PUBLIC_CONTEST_NAME} \ NEXT_PUBLIC_CONTEST_END_TIME=${GH_NEXT_PUBLIC_CONTEST_END_TIME} \ + NEXT_PUBLIC_TIME_CONSTRAINT=${GH_NEXT_PUBLIC_TIME_CONSTRAINT} \ NEXT_PUBLIC_BASE_PATH=${GH_NEXT_PUBLIC_BASE_PATH} \ NEXT_PUBLIC_NEYNAR_CLIENT_ID=${GH_NEXT_PUBLIC_NEYNAR_CLIENT_ID} \ NEXT_PUBLIC_TELEGRAM_BOT_ID=${GH_NEXT_PUBLIC_TELEGRAM_BOT_ID} \ @@ -100,6 +102,7 @@ ARG GH_NEXT_PUBLIC_MAIN_CHAT_ID ARG GH_NEXT_PUBLIC_CONTEST_CHAT_ID ARG GH_NEXT_PUBLIC_CONTEST_NAME ARG GH_NEXT_PUBLIC_CONTEST_END_TIME +ARG GH_NEXT_PUBLIC_TIME_CONSTRAINT ARG GH_NEXT_PUBLIC_BASE_PATH ARG GH_NEXT_PUBLIC_NEYNAR_CLIENT_ID ARG GH_NEXT_PUBLIC_TELEGRAM_BOT_ID @@ -128,6 +131,7 @@ ENV NEXTAUTH_URL=${GH_NEXTAUTH_URL} \ NEXT_PUBLIC_CONTEST_CHAT_ID=${GH_NEXT_PUBLIC_CONTEST_CHAT_ID} \ NEXT_PUBLIC_CONTEST_NAME=${GH_NEXT_PUBLIC_CONTEST_NAME} \ NEXT_PUBLIC_CONTEST_END_TIME=${GH_NEXT_PUBLIC_CONTEST_END_TIME} \ + NEXT_PUBLIC_TIME_CONSTRAINT=${GH_NEXT_PUBLIC_TIME_CONSTRAINT} \ NEXT_PUBLIC_BASE_PATH=${GH_NEXT_PUBLIC_BASE_PATH} \ NEXT_PUBLIC_NEYNAR_CLIENT_ID=${GH_NEXT_PUBLIC_NEYNAR_CLIENT_ID} \ NEXT_PUBLIC_TELEGRAM_BOT_ID=${GH_NEXT_PUBLIC_TELEGRAM_BOT_ID} \ diff --git a/src/@types/subsocial.d.ts b/src/@types/subsocial.d.ts index e1292cb4e..e29dc63a6 100644 --- a/src/@types/subsocial.d.ts +++ b/src/@types/subsocial.d.ts @@ -101,6 +101,7 @@ declare module '@subsocial/api/types' { blockchainSyncFailed?: boolean dataType?: 'persistent' | 'optimistic' | 'offChain' parentPostId?: string | null + approvedInRootPost?: boolean }, PostContent > & { requestedId?: string } diff --git a/src/assets/emojis/check.png b/src/assets/emojis/check.png index f4ff0a401..f47b42376 100644 Binary files a/src/assets/emojis/check.png and b/src/assets/emojis/check.png differ diff --git a/src/assets/emojis/time.png b/src/assets/emojis/time.png new file mode 100644 index 000000000..559d538d6 Binary files /dev/null and b/src/assets/emojis/time.png differ diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 777f902df..b7d6d7759 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -20,6 +20,8 @@ export const buttonStyles = cva('relative transition', { muted: 'bg-background-lightest !ring-background-lightest text-text-muted', transparent: 'bg-transparent border border-transparent', redOutline: 'bg-transparent border border-text-red !ring-text-red', + greenOutline: + 'bg-transparent border border-green-500 !ring-green-500 text-green-500', landingPrimary: 'bg-gradient-to-r from-[#DB4646] to-[#F9A11E] text-white hover:!ring-white/50 focus-visible:!ring-white/50', landingPrimaryOutline: diff --git a/src/components/MediaLoader.tsx b/src/components/MediaLoader.tsx index 862c68a4b..11e8be167 100644 --- a/src/components/MediaLoader.tsx +++ b/src/components/MediaLoader.tsx @@ -97,12 +97,13 @@ export default function MediaLoader({ alt={props.alt || ''} className={cx( commonProps.className, - 'absolute inset-0 m-0 h-full w-full p-0' + 'absolute inset-0 m-0 h-full max-h-96 w-full scale-110 object-cover p-0 blur-lg' )} /> setIsOpenAccountModal(false)} > - {/* {messageId && ( - openDonateExtension(), - }, - ]} - /> - )} */} ) @@ -52,21 +39,30 @@ export default function ProfilePreviewModalWrapper({ export function ProfilePreviewModalName({ messageId, + chatId, + hubId, + enableProfileModal = true, ...props -}: NameProps & { messageId?: string }) { +}: NameProps & { + messageId: string + chatId: string + hubId: string + enableProfileModal?: boolean +}) { + const { openModal } = useProfilePostsModal() + return ( - - {(onClick) => ( - { - onClick(e) - props.onClick?.(e) - }} - className={cx('cursor-pointer', props.className)} - address={props.address} - /> - )} - + { + if (enableProfileModal) { + e.preventDefault() + openModal({ messageId, chatId, hubId, address: props.address }) + props.onClick?.(e) + } + }} + className={cx('cursor-pointer', props.className)} + address={props.address} + /> ) } diff --git a/src/components/chats/ChatItem/ChatItem.tsx b/src/components/chats/ChatItem/ChatItem.tsx index e98548c09..6f1be24b3 100644 --- a/src/components/chats/ChatItem/ChatItem.tsx +++ b/src/components/chats/ChatItem/ChatItem.tsx @@ -1,7 +1,5 @@ import AddressAvatar from '@/components/AddressAvatar' -import ProfilePreviewModalWrapper from '@/components/ProfilePreviewModalWrapper' -import { isMessageSent } from '@/services/subsocial/commentIds/optimistic' -import { useMessageData } from '@/stores/message' +import { useProfilePostsModal } from '@/stores/profile-posts-modal' import { cx } from '@/utils/class-names' import { PostData } from '@subsocial/api/types' import { ComponentProps } from 'react' @@ -9,7 +7,6 @@ import { ScrollToMessage } from '../ChatList/hooks/useScrollToMessage' import ChatItemMenus from './ChatItemMenus' import ChatItemWithExtension from './ChatItemWithExtension' import Embed, { useCanRenderEmbed } from './Embed' -import { getMessageStatusById } from './MessageStatusIndicator' import DefaultChatItem from './variants/DefaultChatItem' import EmojiChatItem, { shouldRenderEmojiChatItem, @@ -21,9 +18,12 @@ export type ChatItemProps = Omit, 'children'> & { messageBubbleId?: string scrollToMessage?: ScrollToMessage enableChatMenu?: boolean + enableProfileModal?: boolean chatId: string hubId: string bg?: 'background-light' | 'background' + showApproveButton?: boolean + menuIdPrefix?: string } export default function ChatItem({ @@ -35,28 +35,24 @@ export default function ChatItem({ chatId, hubId, bg = 'background-light', + showApproveButton, + enableProfileModal = true, + menuIdPrefix, ...props }: ChatItemProps) { - const setReplyTo = useMessageData((state) => state.setReplyTo) - - const messageId = message.id - const { ownerId, dataType } = message.struct + const { ownerId, id: messageId } = message.struct const { body, extensions, link } = message.content || {} - - const setMessageAsReply = () => { - if (!isMessageSent(messageId, dataType)) return - setReplyTo(messageId) - } + const { openModal } = useProfilePostsModal() const canRenderEmbed = useCanRenderEmbed(link ?? '') + if (showApproveButton && message.struct.approvedInRootPost) return null + if (!body && (!extensions || extensions.length === 0)) return null const isEmojiOnly = shouldRenderEmojiChatItem(body ?? '') const ChatItemContentVariant = isEmojiOnly ? EmojiChatItem : DefaultChatItem - const messageStatus = getMessageStatusById(message) - return ( <>
{!isMyMessage && ( - - {(onClick) => ( - - )} - + { + e.preventDefault() + + if (enableProfileModal) { + openModal({ chatId, hubId, messageId, address: ownerId }) + } + }} + address={ownerId} + className='flex-shrink-0 cursor-pointer' + /> )} setMessageAsReply()} {...referenceProps} id={messageBubbleId} > @@ -105,7 +103,9 @@ export default function ChatItem({ isMyMessage={isMyMessage} chatId={chatId} hubId={hubId} + enableProfileModal={enableProfileModal} bg={bg} + showApproveButton={showApproveButton} /> ) : ( diff --git a/src/components/chats/ChatItem/ChatItemMenus.tsx b/src/components/chats/ChatItem/ChatItemMenus.tsx index f227d6a65..f741c5522 100644 --- a/src/components/chats/ChatItem/ChatItemMenus.tsx +++ b/src/components/chats/ChatItem/ChatItemMenus.tsx @@ -47,6 +47,7 @@ import usePinnedMessage from '../hooks/usePinnedMessage' export type ChatItemMenusProps = { messageId: string + menuIdPrefix?: string chatId: string hubId: string children: FloatingMenusProps['children'] @@ -57,40 +58,29 @@ type ModalState = 'metadata' | 'moderate' | 'hide' | null export default function ChatItemMenus({ messageId, + menuIdPrefix, children, chatId, hubId, enableChatMenu = true, }: ChatItemMenusProps) { - // const canSendMessage = useCanSendMessage(hubId, chatId) const myAddress = useMyMainAddress() - const isOpen = useChatMenu((state) => state.openedChatId === messageId) + const menuId = `${menuIdPrefix || ''}${messageId}` + const isOpen = useChatMenu((state) => state.openedChatId === menuId) const setIsOpenChatMenu = useChatMenu((state) => state.setOpenedChatId) const isMessageOwner = useIsOwnerOfPost(messageId) const { data: post } = getPostQuery.useQuery(messageId) const ownerId = post?.struct.ownerId ?? '' - const { ref, inView } = useInView({ triggerOnce: true }) - // const { evmAddress } = useLinkedEvmAddress(ownerId, { enabled: inView }) - // const refSearchParam = useReferralSearchParam() + const { ref } = useInView({ triggerOnce: true }) - // const router = useRouter() - - // const address = useMyMainAddress() const { data: message } = getPostQuery.useQuery(messageId) const [modalState, setModalState] = useState(null) const { mutate: moderate } = useModerateWithSuccessToast(messageId, chatId) const sendEvent = useSendEvent() - // const openDonateExtension = useOpenDonateExtension( - // message?.id, - // message?.struct.ownerId ?? '' - // ) - - // const setReplyTo = useMessageData((state) => state.setReplyTo) - // const setMessageToEdit = useMessageData((state) => state.setMessageToEdit) const { isAuthorized } = useAuthorizedForModeration(chatId) const { data: reasons } = getModerationReasonsQuery.useQuery(null) @@ -102,40 +92,7 @@ export default function ChatItemMenus({ const pinUnpinMenu = usePinUnpinMenuItem(chatId, messageId) const getChatMenus = (): FloatingMenusProps['menus'] => { - const menus: FloatingMenusProps['menus'] = [ - // { - // text: 'Copy Text', - // icon: MdContentCopy, - // onClick: () => { - // copyToClipboard(message?.content?.body ?? '') - // toast.custom((t) => ( - // - // )) - // }, - // }, - // { - // text: 'Copy Message Link', - // icon: FiLink, - // onClick: () => { - // const messageLink = urlJoin( - // getCurrentUrlOrigin(), - // env.NEXT_PUBLIC_BASE_PATH, - // '/message', - // `/${messageId}`, - // refSearchParam - // ) - // copyToClipboard(messageLink) - // toast.custom((t) => ( - // - // )) - // }, - // }, - // { - // text: 'Show Metadata', - // icon: RiDatabase2Line, - // onClick: () => setModalState('metadata'), - // }, - ] + const menus: FloatingMenusProps['menus'] = [] const hideMenu: FloatingMenusProps['menus'][number] = { text: 'Hide', @@ -191,56 +148,7 @@ export default function ChatItemMenus({ if (isOptimisticMessage) return menus - // const donateMenuItem: FloatingMenusProps['menus'][number] = { - // text: 'Donate', - // icon: RiCopperCoinLine, - // onClick: () => { - // sendEventWithRef(myAddress ?? '', (refId) => { - // sendEvent('click_donate', { postId: messageId }, { ref: refId }) - // }) - // if (!address) { - // useLoginModal.getState().setIsOpen(true) - // return - // } - - // sendEvent('open_donate_action_modal', { hubId, chatId }) - // openDonateExtension() - // }, - // } - // const replyItem: FloatingMenusProps['menus'][number] = { - // text: 'Reply', - // icon: LuReply, - // onClick: () => { - // sendEventWithRef(myAddress ?? '', (refId) => { - // sendEvent( - // 'click_reply', - // { - // eventSource: 'message_menu', - // postId: messageId, - // }, - // { ref: refId } - // ) - // }) - // setReplyTo(messageId) - // }, - // } - // const editItem: FloatingMenusProps['menus'][number] = { - // text: 'Edit', - // icon: LuPencil, - // onClick: () => setMessageToEdit(messageId), - // } - // const showDonateMenuItem = canSendMessage && !isMessageOwner && evmAddress - - // if (showDonateMenuItem) menus.unshift(donateMenuItem) if (pinUnpinMenu) menus.unshift(pinUnpinMenu) - // if (canSendMessage && isMessageOwner) menus.unshift(editItem) - // if (message) - // menus.unshift({ - // text: 'Share', - // icon: GrShareOption, - // submenus: getShareMessageMenus(message), - // }) - // if (canSendMessage) menus.unshift(replyItem) return menus } @@ -344,7 +252,7 @@ export default function ChatItemMenus({ if (closestButton?.classList.contains('superlike') && isOpen) { return } - setIsOpenChatMenu(isOpen ? messageId : null) + setIsOpenChatMenu(isOpen ? menuId : null) }, }} > diff --git a/src/components/chats/ChatItem/ChatItemWithExtension.tsx b/src/components/chats/ChatItem/ChatItemWithExtension.tsx index f0afd3c3f..af19fa594 100644 --- a/src/components/chats/ChatItem/ChatItemWithExtension.tsx +++ b/src/components/chats/ChatItem/ChatItemWithExtension.tsx @@ -4,7 +4,9 @@ import { } from '@/components/extensions/config' import { ExtensionChatItemProps } from '@/components/extensions/types' -export default function ChatItemWithExtension(props: ExtensionChatItemProps) { +export default function ChatItemWithExtension( + props: ExtensionChatItemProps & { enableProfileModal?: boolean } +) { const extensionId = props.message.content?.extensions?.[0] .id as MessageExtensionIds diff --git a/src/components/chats/ChatItem/MessageStatusIndicator.tsx b/src/components/chats/ChatItem/MessageStatusIndicator.tsx index c3abba0d6..40610198f 100644 --- a/src/components/chats/ChatItem/MessageStatusIndicator.tsx +++ b/src/components/chats/ChatItem/MessageStatusIndicator.tsx @@ -1,10 +1,7 @@ -import Button from '@/components/Button' import { useSendEvent } from '@/stores/analytics' import { cx } from '@/utils/class-names' import { PostData } from '@subsocial/api/types' -import { SyntheticEvent, useState } from 'react' import { IoCheckmarkDoneOutline, IoCheckmarkOutline } from 'react-icons/io5' -import CheckMarkExplanationModal from './CheckMarkExplanationModal' export type MessageStatus = 'sending' | 'offChain' | 'optimistic' | 'blockchain' @@ -16,23 +13,18 @@ export default function MessageStatusIndicator({ message, }: MessageStatusIndicatorProps) { const sendEvent = useSendEvent() - const [isOpenCheckmarkModal, setIsOpenCheckmarkModal] = useState(false) + // const [isOpenCheckmarkModal, setIsOpenCheckmarkModal] = useState(false) const messageStatus = getMessageStatusById(message) - const onCheckMarkClick = (e: SyntheticEvent) => { - e.stopPropagation() - sendEvent('click check_mark_button', { type: messageStatus }) - setIsOpenCheckmarkModal(true) - } + // const onCheckMarkClick = (e: SyntheticEvent) => { + // e.stopPropagation() + // sendEvent('click check_mark_button', { type: messageStatus }) + // setIsOpenCheckmarkModal(true) + // } return ( - + /> */} + ) } diff --git a/src/components/chats/ChatItem/profilePosts/ProfilePostsList.tsx b/src/components/chats/ChatItem/profilePosts/ProfilePostsList.tsx new file mode 100644 index 000000000..3050d3bf6 --- /dev/null +++ b/src/components/chats/ChatItem/profilePosts/ProfilePostsList.tsx @@ -0,0 +1,169 @@ +import Container from '@/components/Container' +import Loading from '@/components/Loading' +import ScrollableContainer from '@/components/ScrollableContainer' +import useAuthorizedForModeration from '@/hooks/useAuthorizedForModeration' +import { useConfigContext } from '@/providers/config/ConfigProvider' +import { getPostQuery } from '@/services/api/query' +import { getPostMetadataQuery } from '@/services/datahub/posts/query' +import { useSendEvent } from '@/stores/analytics' +import { useMyAccount, useMyMainAddress } from '@/stores/my-account' +import { cx } from '@/utils/class-names' +import { sendMessageToParentWindow } from '@/utils/window' +import { ComponentProps, Fragment, useEffect, useId, useRef } from 'react' +import InfiniteScroll from 'react-infinite-scroll-component' +import CenterChatNotice from '../../ChatList/CenterChatNotice' +import ChatItemWithMenu from '../../ChatList/ChatItemWithMenu' +import ChatTopNotice from '../../ChatList/ChatTopNotice' +import { usePaginatedMessageIdsByAccount } from '../../hooks/usePaginatedMessageIds' + +export type ChatListProps = ComponentProps<'div'> & { + asContainer?: boolean + scrollContainerRef?: React.RefObject + scrollableContainerClassName?: string + address: string + hubId: string + chatId: string + newMessageNoticeClassName?: string + topElement?: React.ReactNode +} + +export default function ProfilePostsList(props: ChatListProps) { + const isInitialized = useMyAccount((state) => state.isInitialized) + + return ( + + ) +} + +// If using bigger threshold, the scroll will be janky, but if using 0 threshold, it sometimes won't trigger `next` callback +const SCROLL_THRESHOLD = 20 + +function ProfilePostsListContent({ + asContainer, + scrollableContainerClassName, + hubId, + address, + chatId, + scrollContainerRef: _scrollContainerRef, + newMessageNoticeClassName, + ...props +}: ChatListProps) { + const sendEvent = useSendEvent() + const { enableBackButton } = useConfigContext() + const { data: postMetadata } = getPostMetadataQuery.useQuery(chatId) + + const scrollableContainerId = useId() + + const innerScrollContainerRef = useRef(null) + const scrollContainerRef = _scrollContainerRef || innerScrollContainerRef + + const innerRef = useRef(null) + + const { isAuthorized } = useAuthorizedForModeration(chatId) + + const { messageIds, hasMore, loadMore, totalDataCount, currentPage } = + usePaginatedMessageIdsByAccount({ + hubId, + chatId, + account: address, + isModerator: isAuthorized, + }) + + useEffect(() => { + sendMessageToParentWindow('totalMessage', (totalDataCount ?? 0).toString()) + }, [totalDataCount]) + + const myAddress = useMyMainAddress() + const { data: chat } = getPostQuery.useQuery(chatId) + const isMyChat = chat?.struct.ownerId === myAddress + + const Component = asContainer ? Container<'div'> : 'div' + + const renderedMessageQueries = getPostQuery.useQueries(messageIds) + + return ( +
+ {totalDataCount === 0 && ( + 0 + ? 'Loading messages...' + : undefined + } + className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2' + /> + )} + + +
+ { + loadMore() + sendEvent('load_more_messages', { currentPage }) + }} + className={cx( + 'relative flex w-full flex-col-reverse !overflow-hidden pb-2', + // need to have enough room to open message menu + 'min-h-[400px]' + )} + hasMore={hasMore} + inverse + scrollableTarget={scrollableContainerId} + loader={} + endMessage={ + renderedMessageQueries.length === 0 ? null : ( + + ) + } + scrollThreshold={`${SCROLL_THRESHOLD}px`} + > + {renderedMessageQueries.map(({ data: message }, index) => { + // bottom message is the first element, because the flex direction is reversed + if (!message) return null + + return ( + + + + ) + })} + +
+
+
+
+ ) +} diff --git a/src/components/chats/ChatItem/profilePosts/ProfileProstsListModal.tsx b/src/components/chats/ChatItem/profilePosts/ProfileProstsListModal.tsx new file mode 100644 index 000000000..292f3b89c --- /dev/null +++ b/src/components/chats/ChatItem/profilePosts/ProfileProstsListModal.tsx @@ -0,0 +1,131 @@ +import AddressAvatar from '@/components/AddressAvatar' +import Button from '@/components/Button' +import Name from '@/components/Name' +import useAuthorizedForModeration from '@/hooks/useAuthorizedForModeration' +import { getModerationReasonsQuery } from '@/services/datahub/moderation/query' +import { getPaginatedPostIdsByPostIdAndAccount } from '@/services/datahub/posts/queryByAccount' +import { useSendEvent } from '@/stores/analytics' +import { useProfilePostsModal } from '@/stores/profile-posts-modal' +import { cx } from '@/utils/class-names' +import { Transition } from '@headlessui/react' +import { createPortal } from 'react-dom' +import { HiOutlineChevronLeft } from 'react-icons/hi2' +import SkeletonFallback from '../../../SkeletonFallback' +import { useModerateWithSuccessToast } from '../ChatItemMenus' +import ProfilePostsList from './ProfilePostsList' + +const ProfilePostsListModal = () => { + const { + isOpen, + closeModal, + messageId = '', + chatId = '', + hubId = '', + address = '', + } = useProfilePostsModal() + + const { mutate: moderate } = useModerateWithSuccessToast(messageId, chatId) + const sendEvent = useSendEvent() + const { isAuthorized } = useAuthorizedForModeration(chatId) + + const { data: reasons } = getModerationReasonsQuery.useQuery(null) + const firstReasonId = reasons?.[0].id + + const { data, isLoading } = + getPaginatedPostIdsByPostIdAndAccount.useInfiniteQuery(chatId, address) + + const totalPostsCount = data?.pages[0].totalData || 0 + + const onBlockUserClick = () => { + sendEvent('block_user', { hubId, chatId }) + moderate({ + callName: 'synth_moderation_block_resource', + args: { + reasonId: firstReasonId, + resourceId: address, + ctxPostIds: ['*'], + ctxAppIds: ['*'], + }, + chatId, + }) + + closeModal() + } + + return createPortal( + <> + + +
+
+
+ + +
+ + + Memes: + + {totalPostsCount} + + +
+
+ + {isAuthorized && ( + + )} +
+
+ +
+
+
+ , + document.body + ) +} + +export default ProfilePostsListModal diff --git a/src/components/chats/ChatItem/variants/DefaultChatItem.tsx b/src/components/chats/ChatItem/variants/DefaultChatItem.tsx index f9bbaf003..528ab0792 100644 --- a/src/components/chats/ChatItem/variants/DefaultChatItem.tsx +++ b/src/components/chats/ChatItem/variants/DefaultChatItem.tsx @@ -17,7 +17,9 @@ import MessageStatusIndicator from '../MessageStatusIndicator' import RepliedMessagePreview from '../RepliedMessagePreview' import { ChatItemContentProps } from './types' -export type DefaultChatItemProps = ChatItemContentProps +export type DefaultChatItemProps = ChatItemContentProps & { + enableProfileModal?: boolean +} export default function DefaultChatItem({ chatId, @@ -25,6 +27,7 @@ export default function DefaultChatItem({ message, isMyMessage, scrollToMessage, + enableProfileModal = true, bg = 'background', ...props }: DefaultChatItemProps) { @@ -75,6 +78,9 @@ export default function DefaultChatItem({ labelingData={{ chatId }} messageId={messageId} address={ownerId} + chatId={chatId} + hubId={hubId} + enableProfileModal={enableProfileModal} className={cx('text-sm font-medium text-text-secondary')} /> {/* */} diff --git a/src/components/chats/ChatItem/variants/EmojiChatItem.tsx b/src/components/chats/ChatItem/variants/EmojiChatItem.tsx index 090e523b8..f590a2d61 100644 --- a/src/components/chats/ChatItem/variants/EmojiChatItem.tsx +++ b/src/components/chats/ChatItem/variants/EmojiChatItem.tsx @@ -8,7 +8,9 @@ import MessageStatusIndicator from '../MessageStatusIndicator' import RepliedMessagePreview from '../RepliedMessagePreview' import { ChatItemContentProps } from './types' -export type EmojiChatItemProps = ChatItemContentProps +export type EmojiChatItemProps = ChatItemContentProps & { + enableProfileModal?: boolean +} const EMOJI_FONT_SIZE = { min: 32, @@ -28,6 +30,7 @@ export default function EmojiChatItem({ isMyMessage, scrollToMessage, chatId, + enableProfileModal = true, hubId, ...props }: EmojiChatItemProps) { @@ -69,6 +72,9 @@ export default function EmojiChatItem({ labelingData={{ chatId }} messageId={messageId} address={ownerId} + chatId={chatId} + hubId={hubId} + enableProfileModal={enableProfileModal} className={cx('mr-2 text-sm font-medium text-text-secondary')} /> {/* */} diff --git a/src/components/chats/ChatList/ChatItemContainer.tsx b/src/components/chats/ChatList/ChatItemContainer.tsx index 051a6e6ff..b56819d85 100644 --- a/src/components/chats/ChatList/ChatItemContainer.tsx +++ b/src/components/chats/ChatList/ChatItemContainer.tsx @@ -11,12 +11,23 @@ import ChatItem, { ChatItemProps } from '../ChatItem' export type ChatItemContainerProps = Omit & { containerProps?: ComponentProps<'div'> + enableProfileModal?: boolean + showBlockedMessage?: boolean chatId: string hubId: string + showApproveButton?: boolean } function ChatItemContainer( - { containerProps, chatId, hubId, ...props }: ChatItemContainerProps, + { + containerProps, + chatId, + hubId, + showBlockedMessage, + enableProfileModal = true, + showApproveButton, + ...props + }: ChatItemContainerProps, ref: any ) { const { message } = props @@ -37,7 +48,8 @@ function ChatItemContainer( const { body, extensions } = content || {} const myAddress = useMyMainAddress() - if (isMessageBlocked || (!body && !extensions)) return null + if ((isMessageBlocked && !showBlockedMessage) || (!body && !extensions)) + return null const ownerId = message.struct.ownerId const senderAddress = ownerId ?? '' @@ -62,7 +74,9 @@ function ChatItemContainer( {...props} chatId={chatId} isMyMessage={isMyMessage} + enableProfileModal={enableProfileModal} hubId={hubId} + showApproveButton={showApproveButton} />
) diff --git a/src/components/chats/ChatList/ChatItemWithMenu.tsx b/src/components/chats/ChatList/ChatItemWithMenu.tsx index b1ec7d685..aaec84476 100644 --- a/src/components/chats/ChatList/ChatItemWithMenu.tsx +++ b/src/components/chats/ChatList/ChatItemWithMenu.tsx @@ -12,14 +12,22 @@ export type ChatItemWithMenuProps = { message: PostData | null | undefined chatId: string hubId: string - scrollToMessage: ScrollToMessage + enableProfileModal?: boolean + showBlockedMessage?: boolean + scrollToMessage?: ScrollToMessage + showApproveButton?: boolean + menuIdPrefix?: string } function InnerChatItemWithMenu({ message, chatItemClassName, chatId, hubId, + enableProfileModal = true, + showBlockedMessage, scrollToMessage, + showApproveButton, + menuIdPrefix, }: ChatItemWithMenuProps) { return message ? ( {(config) => { const { referenceProps, toggleDisplay } = config || {} @@ -45,8 +54,12 @@ function InnerChatItemWithMenu({ hubId={hubId} chatId={chatId} message={message} + showBlockedMessage={showBlockedMessage} messageBubbleId={getMessageElementId(message.id)} + enableProfileModal={enableProfileModal} scrollToMessage={scrollToMessage} + showApproveButton={showApproveButton} + menuIdPrefix={menuIdPrefix} /> ) diff --git a/src/components/chats/ChatList/ChatList.tsx b/src/components/chats/ChatList/ChatList.tsx index 1da34e94b..b341a7c55 100644 --- a/src/components/chats/ChatList/ChatList.tsx +++ b/src/components/chats/ChatList/ChatList.tsx @@ -10,6 +10,7 @@ import { cx } from '@/utils/class-names' import { sendMessageToParentWindow } from '@/utils/window' import { ComponentProps, Fragment, useEffect, useId, useRef } from 'react' import InfiniteScroll from 'react-infinite-scroll-component' +import ProfilePostsListModal from '../ChatItem/profilePosts/ProfileProstsListModal' import usePaginatedMessageIds from '../hooks/usePaginatedMessageIds' import usePinnedMessage from '../hooks/usePinnedMessage' import CenterChatNotice from './CenterChatNotice' @@ -28,6 +29,7 @@ export type ChatListProps = ComponentProps<'div'> & { scrollableContainerClassName?: string hubId: string chatId: string + onlyDisplayUnapprovedMessages?: boolean newMessageNoticeClassName?: string topElement?: React.ReactNode } @@ -54,6 +56,7 @@ function ChatListContent({ chatId, scrollContainerRef: _scrollContainerRef, newMessageNoticeClassName, + onlyDisplayUnapprovedMessages, ...props }: ChatListProps) { const sendEvent = useSendEvent() @@ -77,6 +80,7 @@ function ChatListContent({ } = usePaginatedMessageIds({ hubId, chatId, + onlyDisplayUnapprovedMessages, }) const lastFocusedTime = useLastFocusedMessageTime(chatId, messageIds[0] ?? '') @@ -130,7 +134,8 @@ function ChatListContent({ 0 + (postMetadata?.totalCommentsCount ?? 0) > 0 && + !onlyDisplayUnapprovedMessages ? 'Loading messages...' : undefined } @@ -206,6 +211,7 @@ function ChatListContent({ hubId={hubId} message={message} scrollToMessage={scrollToMessage} + showApproveButton={onlyDisplayUnapprovedMessages} /> ) @@ -226,6 +232,7 @@ function ChatListContent({ newMessageNoticeClassName={newMessageNoticeClassName} /> + ) } diff --git a/src/components/chats/ChatList/ChatTopNotice.tsx b/src/components/chats/ChatList/ChatTopNotice.tsx index 797082944..39229b9ca 100644 --- a/src/components/chats/ChatList/ChatTopNotice.tsx +++ b/src/components/chats/ChatList/ChatTopNotice.tsx @@ -1,13 +1,15 @@ import { cx } from '@/utils/class-names' import { ComponentProps } from 'react' -export type ChatTopNoticeProps = ComponentProps<'div'> +export type ChatTopNoticeProps = ComponentProps<'div'> & { + label?: string +} -export default function ChatTopNotice({ ...props }: ChatTopNoticeProps) { +export default function ChatTopNotice({ label, ...props }: ChatTopNoticeProps) { return (
- You have reached the first message in this chat! + {label ? label : 'You have reached the first message in this chat!'}
) diff --git a/src/components/chats/ChatRoom/ChatRoom.tsx b/src/components/chats/ChatRoom/ChatRoom.tsx index 3c1d0aade..739d3d1ae 100644 --- a/src/components/chats/ChatRoom/ChatRoom.tsx +++ b/src/components/chats/ChatRoom/ChatRoom.tsx @@ -22,6 +22,7 @@ export type ChatRoomProps = ComponentProps<'div'> & { chatId: string hubId: string topElement?: ReactNode + onlyDisplayUnapprovedMessages?: boolean } export default function ChatRoom({ @@ -32,6 +33,7 @@ export default function ChatRoom({ chatId, hubId, topElement, + onlyDisplayUnapprovedMessages, ...props }: ChatRoomProps) { const replyTo = useMessageData((state) => state.replyTo) @@ -41,6 +43,7 @@ export default function ChatRoom({
{topElement} - +
+ {(count ?? 0) >= 3 ? '✅' : '🚫'} {count ?? 0} +
+ ) +} diff --git a/src/components/chats/hooks/usePaginatedMessageIds.ts b/src/components/chats/hooks/usePaginatedMessageIds.ts index f4a9bbca3..a7988b852 100644 --- a/src/components/chats/hooks/usePaginatedMessageIds.ts +++ b/src/components/chats/hooks/usePaginatedMessageIds.ts @@ -3,6 +3,8 @@ import { PaginatedPostsData, getPaginatedPostIdsByPostId, } from '@/services/datahub/posts/query' +import { getPaginatedPostIdsByPostIdAndAccount } from '@/services/datahub/posts/queryByAccount' +import { useMyMainAddress } from '@/stores/my-account' import { useMemo } from 'react' type PaginatedData = { @@ -18,14 +20,31 @@ type PaginatedData = { type PaginatedConfig = { hubId: string chatId: string + onlyDisplayUnapprovedMessages?: boolean } export default function usePaginatedMessageIds({ chatId, hubId, + onlyDisplayUnapprovedMessages, }: PaginatedConfig): PaginatedData { + const myAddress = useMyMainAddress() ?? '' + // because from server it doesn't have access to myAddress, so we need to use the data without users' unapproved posts as placeholder + const { data: placeholderData } = + getPaginatedPostIdsByPostId.useInfiniteQuery({ + postId: chatId, + onlyDisplayUnapprovedMessages: !!onlyDisplayUnapprovedMessages, + myAddress: '', + }) const { data, fetchNextPage, isLoading } = - getPaginatedPostIdsByPostId.useInfiniteQuery(chatId) + getPaginatedPostIdsByPostId.useInfiniteQuery( + { + postId: chatId, + onlyDisplayUnapprovedMessages: !!onlyDisplayUnapprovedMessages, + myAddress, + }, + { enabled: !!myAddress, placeholderData } + ) const page = data?.pages let lastPage: PaginatedPostsData | null = null @@ -37,7 +56,9 @@ export default function usePaginatedMessageIds({ } const flattenedIds = useMemo(() => { - return data?.pages?.map((page) => page.data).flat() || [] + return Array.from( + new Set(data?.pages?.map((page) => page.data).flat() || []) + ) }, [data?.pages]) const filteredPageIds = useFilterBlockedMessageIds( @@ -56,3 +77,47 @@ export default function usePaginatedMessageIds({ allIds: filteredPageIds, } } + +type PaginatedByAccountConfig = PaginatedConfig & { + account: string + isModerator: boolean +} + +export function usePaginatedMessageIdsByAccount({ + account, + chatId, + hubId, + isModerator, +}: PaginatedByAccountConfig): PaginatedData { + const { data, fetchNextPage, isLoading } = + getPaginatedPostIdsByPostIdAndAccount.useInfiniteQuery(chatId, account) + + const page = data?.pages + let lastPage: PaginatedPostsData | null = null + if (page && page.length > 0) { + const last = page[page.length - 1] + if (last) { + lastPage = last + } + } + + const flattenedIds = useMemo(() => { + return data?.pages?.map((page) => page.data).flat() || [] + }, [data?.pages]) + + const filteredPageIds = useFilterBlockedMessageIds( + hubId, + chatId, + flattenedIds + ) + + return { + currentPage: lastPage?.page ?? 1, + messageIds: isModerator ? flattenedIds : filteredPageIds, + loadMore: fetchNextPage, + totalDataCount: data?.pages?.[0].totalData || 0, + hasMore: lastPage?.hasMore ?? true, + isLoading, + allIds: isModerator ? flattenedIds : filteredPageIds, + } +} diff --git a/src/components/extensions/common/CommonChatItem.tsx b/src/components/extensions/common/CommonChatItem.tsx index d34d2533d..f9b8ce7f5 100644 --- a/src/components/extensions/common/CommonChatItem.tsx +++ b/src/components/extensions/common/CommonChatItem.tsx @@ -5,16 +5,20 @@ import { useModerateWithSuccessToast } from '@/components/chats/ChatItem/ChatIte import ChatRelativeTime from '@/components/chats/ChatItem/ChatRelativeTime' import MessageStatusIndicator from '@/components/chats/ChatItem/MessageStatusIndicator' import RepliedMessagePreview from '@/components/chats/ChatItem/RepliedMessagePreview' +import UnapprovedMemeCount from '@/components/chats/UnapprovedMemeCount' import { getRepliedMessageId } from '@/components/chats/utils' import SuperLike from '@/components/content-staking/SuperLike' import useAuthorizedForModeration from '@/hooks/useAuthorizedForModeration' +import useIsMessageBlocked from '@/hooks/useIsMessageBlocked' import { getSuperLikeCountQuery } from '@/services/datahub/content-staking/query' import { getModerationReasonsQuery } from '@/services/datahub/moderation/query' +import { useApproveUser } from '@/services/datahub/posts/mutation' import { isMessageSent } from '@/services/subsocial/commentIds/optimistic' import { useMyMainAddress } from '@/stores/my-account' import { cx } from '@/utils/class-names' import { getTimeRelativeToNow } from '@/utils/date' import Linkify from 'linkify-react' +import { useInView } from 'react-intersection-observer' import { ExtensionChatItemProps } from '../types' type DerivativesData = { @@ -40,6 +44,7 @@ type CommonChatItemProps = ExtensionChatItemProps & { textColor?: string bg?: 'background' | 'background-light' showSuperLikeWhenZero?: boolean + enableProfileModal?: boolean } const defaultMyMessageConfig: MyMessageConfig = { @@ -59,11 +64,14 @@ export default function CommonChatItem({ textColor, className, isMyMessage: _isMyMessage, + enableProfileModal = true, showSuperLikeWhenZero, chatId, hubId, bg = 'background', + showApproveButton, }: CommonChatItemProps) { + const { inView, ref } = useInView() const myAddress = useMyMainAddress() const { isAuthorized } = useAuthorizedForModeration(chatId) const { mutate: moderate, isLoading: loadingModeration } = @@ -81,6 +89,19 @@ export default function CommonChatItem({ const relativeTime = getTimeRelativeToNow(createdAtTime) const isSent = isMessageSent(message.id, dataType) + const isMessageBlockedInCurrentHub = useIsMessageBlocked( + hubId, + message, + chatId + ) + const isMessageBlockedInOriginalHub = useIsMessageBlocked( + message.struct.spaceId ?? '', + message, + chatId + ) + const isMessageBlocked = + isMessageBlockedInCurrentHub || isMessageBlockedInOriginalHub + const childrenElement = typeof children === 'function' ? children({ isMyMessage, relativeTime, isSent }) @@ -132,13 +153,18 @@ export default function CommonChatItem({ (myMessageConfig.children === 'bottom' || (myMessageConfig.children === 'middle' && !body)) + if (showApproveButton) { + othersMessage.checkMark = 'top' + } + return (
{isMyMessage && myMessageConfig.checkMark === 'adaptive-inside' && (
{myMessageCheckMarkElement( @@ -178,6 +204,7 @@ export default function CommonChatItem({ 'flex items-baseline gap-2 overflow-hidden px-2.5 first:pt-1.5', othersMessage.checkMark !== 'top' && 'justify-between' )} + ref={ref} > + {showApproveButton && inView && ( + + )} {/* */} {othersMessage.checkMark === 'top' && otherMessageCheckMarkElement()} @@ -252,11 +285,17 @@ export default function CommonChatItem({ )} {isAuthorized && ( -
+
+ {showApproveButton && ( + + )}
)} {!isMyMessage && othersMessage.children === 'bottom' && childrenElement} @@ -283,14 +328,53 @@ export default function CommonChatItem({ myMessageConfig.children === 'bottom' && childrenElement} - + {showApproveButton ? ( +
+ ) : message.struct.approvedInRootPost ? ( + + ) : ( +
+
+ ⌛ Pending Review +
+
+ )}
) } + +function ApproveButton({ + ownerId, + chatId, +}: { + chatId: string + ownerId: string +}) { + const { mutate, isLoading } = useApproveUser() + return ( + + ) +} diff --git a/src/components/extensions/types.ts b/src/components/extensions/types.ts index 1ef502baa..e08e2df20 100644 --- a/src/components/extensions/types.ts +++ b/src/components/extensions/types.ts @@ -7,7 +7,9 @@ export type ExtensionChatItemProps = { scrollToMessage?: (messageId: string) => Promise chatId: string hubId: string + enableProfileModal?: boolean bg?: 'background' | 'background-light' + showApproveButton?: boolean } export type RepliedMessagePreviewPartProps = { diff --git a/src/components/modals/GlobalModals.tsx b/src/components/modals/GlobalModals.tsx index 5e5edb11c..61bdac9ff 100644 --- a/src/components/modals/GlobalModals.tsx +++ b/src/components/modals/GlobalModals.tsx @@ -1,5 +1,6 @@ import { useMessageData } from '@/stores/message' import BlockedModal from '../moderation/BlockedModal' +import MemeOnReviewModal from './MemeOnReviewModal' import PostMemeThresholdModal from './PostMemeThresholdModal' export default function GlobalModals() { @@ -14,6 +15,11 @@ export default function GlobalModals() { isOpen={isOpenMessageModal === 'not-enough-balance'} closeModal={() => setOpenMessageModal('')} /> + setOpenMessageModal('')} + /> setOpenMessageModal('')} diff --git a/src/components/modals/MemeOnReviewModal.tsx b/src/components/modals/MemeOnReviewModal.tsx new file mode 100644 index 000000000..536ff2dfb --- /dev/null +++ b/src/components/modals/MemeOnReviewModal.tsx @@ -0,0 +1,52 @@ +import Check from '@/assets/emojis/check.png' +import Time from '@/assets/emojis/time.png' +import { MIN_MEME_FOR_REVIEW } from '@/constants/chat' +import { getTokenomicsMetadataQuery } from '@/services/datahub/content-staking/query' +import { getUnapprovedMemesCountQuery } from '@/services/datahub/posts/query' +import { useMyMainAddress } from '@/stores/my-account' +import Image from 'next/image' +import Button from '../Button' +import Modal, { ModalFunctionalityProps } from './Modal' + +export default function MemeOnReviewModal({ + chatId, + ...props +}: ModalFunctionalityProps & { chatId: string }) { + const myAddress = useMyMainAddress() ?? '' + const { data: tokenomics } = getTokenomicsMetadataQuery.useQuery(null) + const { data: count } = getUnapprovedMemesCountQuery.useQuery( + { address: myAddress, chatId }, + { + enabled: props.isOpen, + } + ) + const remaining = MIN_MEME_FOR_REVIEW - (count ?? 0) + + const description = + remaining > 0 + ? `${ + tokenomics?.socialActionPrice.createCommentPoints + } points have been used. We received your meme! We need at least ${remaining} more meme${ + remaining > 1 ? 's' : '' + } from you to mark you as a verified creator.` + : `${ + tokenomics?.socialActionPrice.createCommentPoints + } points have been used. We received ${ + count ?? 0 + } memes from you! Now we need a bit of time to finish review you as a verified creator.` + + return ( + +
+ 0 ? Time : Check} + alt='' + className='h-28 w-28' + /> + +
+
+ ) +} diff --git a/src/constants/chat-rules.ts b/src/constants/chat-rules.ts deleted file mode 100644 index fd0a576ea..000000000 --- a/src/constants/chat-rules.ts +++ /dev/null @@ -1 +0,0 @@ -export const TIME_CONSTRAINT = 5 * 60 * 1000 // 5 mins diff --git a/src/constants/chat.ts b/src/constants/chat.ts index 5c3a35228..ac0e4b000 100644 --- a/src/constants/chat.ts +++ b/src/constants/chat.ts @@ -7,3 +7,5 @@ const CUSTOM_CHAT_MAX_LENGTH: Record = { export function getMaxMessageLength(chatId: string) { return CUSTOM_CHAT_MAX_LENGTH[chatId] ?? DEFAULT_MAX_MESSAGE_LENGTH } + +export const MIN_MEME_FOR_REVIEW = 3 diff --git a/src/env.mjs b/src/env.mjs index da997bde6..449bea3c7 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -44,6 +44,7 @@ export const env = createEnv({ NEXT_PUBLIC_CONTEST_CHAT_ID: z.string().default(''), NEXT_PUBLIC_CONTEST_NAME: z.string().default(''), NEXT_PUBLIC_CONTEST_END_TIME: z.string().default('').transform(Number), + NEXT_PUBLIC_TIME_CONSTRAINT: z.string().default('').transform(Number), NEXT_PUBLIC_BASE_PATH: z.string().default(''), NEXT_PUBLIC_TELEGRAM_BOT_ID: z.string().default(''), NEXT_PUBLIC_TELEGRAM_BOT_USERNAME: z.string().default(''), @@ -85,6 +86,7 @@ export const env = createEnv({ NEXT_PUBLIC_CONTEST_CHAT_ID: process.env.NEXT_PUBLIC_CONTEST_CHAT_ID, NEXT_PUBLIC_CONTEST_NAME: process.env.NEXT_PUBLIC_CONTEST_NAME, NEXT_PUBLIC_CONTEST_END_TIME: process.env.NEXT_PUBLIC_CONTEST_END_TIME, + NEXT_PUBLIC_TIME_CONSTRAINT: process.env.NEXT_PUBLIC_TIME_CONSTRAINT, NEXT_PUBLIC_TELEGRAM_BOT_ID: process.env.NEXT_PUBLIC_TELEGRAM_BOT_ID, NEXT_PUBLIC_TELEGRAM_BOT_USERNAME: process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME, diff --git a/src/modules/chat/HomePage/ChatContent.tsx b/src/modules/chat/HomePage/ChatContent.tsx index 5cc21ae7c..96f7de982 100644 --- a/src/modules/chat/HomePage/ChatContent.tsx +++ b/src/modules/chat/HomePage/ChatContent.tsx @@ -11,6 +11,7 @@ import Meme2EarnIntroModal, { import Modal, { ModalFunctionalityProps } from '@/components/modals/Modal' import { env } from '@/env.mjs' import useIsAddressBlockedInChat from '@/hooks/useIsAddressBlockedInChat' +import useIsModerationAdmin from '@/hooks/useIsModerationAdmin' import useLinkedEvmAddress from '@/hooks/useLinkedEvmAddress' import usePostMemeThreshold from '@/hooks/usePostMemeThreshold' import PointsWidget from '@/modules/points/PointsWidget' @@ -35,13 +36,20 @@ type Props = { className?: string } +const chatIdsBasedOnSelectedTab = { + all: env.NEXT_PUBLIC_MAIN_CHAT_ID, + contest: env.NEXT_PUBLIC_CONTEST_CHAT_ID, + 'not-approved': env.NEXT_PUBLIC_MAIN_CHAT_ID, + 'not-approved-contest': env.NEXT_PUBLIC_CONTEST_CHAT_ID, +} + export default function ChatContent({ className }: Props) { const { query } = useRouter() let [selectedTab, setSelectedTab] = useLocalStorage( 'memes-tab', 'all' ) - if (selectedTab !== 'all' && selectedTab !== 'contest') { + if (!tabStates.includes(selectedTab)) { selectedTab = 'all' } @@ -61,13 +69,15 @@ export default function ChatContent({ className }: Props) { selectedTab === 'contest' && serverTime && env.NEXT_PUBLIC_CONTEST_END_TIME < serverTime + const isCannotPost = + isContestEnded || + selectedTab === 'not-approved' || + selectedTab === 'not-approved-contest' - const chatId = - selectedTab === 'all' - ? env.NEXT_PUBLIC_MAIN_CHAT_ID - : env.NEXT_PUBLIC_CONTEST_CHAT_ID - - const isContest = selectedTab === 'contest' + const chatId = chatIdsBasedOnSelectedTab[selectedTab] + const isContest = chatId === env.NEXT_PUBLIC_CONTEST_CHAT_ID + const shouldShowUnapproved = + selectedTab === 'not-approved' || selectedTab === 'not-approved-contest' return ( <> @@ -79,8 +89,9 @@ export default function ChatContent({ className }: Props) { chatId={chatId} hubId={env.NEXT_PUBLIC_MAIN_SPACE_ID} className='overflow-hidden' + onlyDisplayUnapprovedMessages={shouldShowUnapproved} customAction={ - isContestEnded ? ( + isCannotPost ? ( <> ) : (
@@ -95,7 +106,7 @@ export default function ChatContent({ className }: Props) { Contest Rules ) : ( <> - + Rules )} @@ -114,7 +125,13 @@ export default function ChatContent({ className }: Props) { ) } -type TabState = 'all' | 'contest' +const tabStates = [ + 'all', + 'contest', + 'not-approved', + 'not-approved-contest', +] as const +type TabState = (typeof tabStates)[number] function TabButton({ selectedTab, setSelectedTab, @@ -136,7 +153,7 @@ function TabButton({ variant={isSelected ? 'primary' : 'transparent'} className={cx( 'h-10 py-0 text-sm', - size === 'sm' ? 'h-8' : 'h-10', + size === 'sm' ? 'px-2' : 'h-10', isSelected ? 'bg-background-primary/30' : '', className )} @@ -154,50 +171,81 @@ function Tabs({ selectedTab: TabState setSelectedTab: (tab: TabState) => void }) { + const isAdmin = useIsModerationAdmin() const { data: serverTime, isLoading } = getServerTimeQuery.useQuery(null) const daysLeft = dayjs(env.NEXT_PUBLIC_CONTEST_END_TIME).diff( dayjs(serverTime ?? undefined), 'days' ) + const tabSize: 'sm' | 'md' = isAdmin ? 'sm' : 'md' + return ( -
+
+ {isAdmin && ( + <> + + Pending + + + Pending Contest + + + )} - All memes + {isAdmin ? 'Approved' : 'All memes'} - {env.NEXT_PUBLIC_CONTEST_NAME} - - {(() => { - if (isLoading || !serverTime) return - if (env.NEXT_PUBLIC_CONTEST_END_TIME < serverTime) - return Contest ended - if (daysLeft === 0) { - const hoursLeft = dayjs(env.NEXT_PUBLIC_CONTEST_END_TIME).diff( - dayjs(serverTime ?? undefined), - 'hours' - ) - if (hoursLeft < 1) { - return Less than an hour left - } - return {hoursLeft} hours left - } - return ( - - {daysLeft} day{daysLeft > 1 ? 's' : ''} left - - ) - })()} - + {!isAdmin ? ( + <> + {env.NEXT_PUBLIC_CONTEST_NAME} + + {(() => { + if (isLoading || !serverTime) + return + if (env.NEXT_PUBLIC_CONTEST_END_TIME < serverTime) + return Contest ended + if (daysLeft === 0) { + const hoursLeft = dayjs( + env.NEXT_PUBLIC_CONTEST_END_TIME + ).diff(dayjs(serverTime ?? undefined), 'hours') + if (hoursLeft < 1) { + return Less than an hour left + } + return {hoursLeft} hours left + } + return ( + + {daysLeft} day{daysLeft > 1 ? 's' : ''} left + + ) + })()} + + + ) : ( + Contest + )}
) @@ -225,8 +273,21 @@ function PostMemeButton({ const myAddress = useMyMainAddress() ?? '' const { data, isLoading } = getBalanceQuery.useQuery(myAddress) - const { data: timeLeftFromApi, isLoading: loadingTimeLeft } = - getTimeLeftUntilCanPostQuery.useQuery(myAddress) + + const { + data: timeLeftFromApi, + isLoading: loadingTimeLeft, + refetch, + } = getTimeLeftUntilCanPostQuery.useQuery(myAddress) + useEffect(() => { + const listener = () => { + if (document.visibilityState === 'visible') refetch() + } + document.addEventListener('visibilitychange', listener, false) + return () => { + document.removeEventListener('visibilitychange', listener) + } + }, [refetch]) const { threshold, isLoading: loadingThreshold } = usePostMemeThreshold(chatId) @@ -305,12 +366,12 @@ function PostMemeButton({ > {!isTimeConstrained ? ( <> - + Post Meme ) : ( <> - {/* */} + {/* */} Posting available in: {countdownText(timeLeft)} )} diff --git a/src/modules/points/PointsWidget.tsx b/src/modules/points/PointsWidget.tsx index 832ebca5c..203d431f7 100644 --- a/src/modules/points/PointsWidget.tsx +++ b/src/modules/points/PointsWidget.tsx @@ -10,6 +10,7 @@ import Button from '@/components/Button' import Card from '@/components/Card' import LinkText from '@/components/LinkText' import Name from '@/components/Name' +import Toast from '@/components/Toast' import LinkEvmAddressModal from '@/components/modals/LinkEvmAddressModal' import RewardPerDayModal from '@/components/modals/RewardPerDayModal' import SubsocialProfileModal from '@/components/subsocial-profile/SubsocialProfileModal' @@ -19,6 +20,7 @@ import { useSendEvent } from '@/stores/analytics' import { useMyMainAddress } from '@/stores/my-account' import { truncateAddress } from '@/utils/account' import { cx } from '@/utils/class-names' +import { copyToClipboard } from '@/utils/strings' import { allowWindowScroll, preventWindowScroll } from '@/utils/window' import { Transition } from '@headlessui/react' import Image from 'next/image' @@ -30,7 +32,9 @@ import { useHotkeys } from 'react-hotkeys-hook' import { FaChevronDown } from 'react-icons/fa' import { HiChevronRight, HiOutlineChevronLeft, HiXMark } from 'react-icons/hi2' import { IoIosArrowForward, IoIosStats } from 'react-icons/io' +import { MdContentCopy } from 'react-icons/md' import { RiPencilFill } from 'react-icons/ri' +import { toast } from 'sonner' import { LeaderboardContent } from '../telegram/StatsPage/LeaderboardSection' import LikeCount from './LikePreview' import Points from './PointsPreview' @@ -271,9 +275,25 @@ const UserStatsSection = ({ My EVM Address - - {truncateAddress(evmAddress ?? '')} - +
+ + {truncateAddress(evmAddress ?? '')} + + +
{/* My EVM Address - - {truncateAddress(evmAddress ?? '')} - +
+ + {truncateAddress(evmAddress ?? '')} + + +
{/* { return ( - + ) } -const ChatsContent = () => { - return -} - export default MemesPage diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index cf0f57d4e..40515b99b 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,6 +1,7 @@ import ErrorBoundary from '@/components/ErrorBoundary' import HeadConfig, { HeadConfigProps } from '@/components/HeadConfig' import Spinner from '@/components/Spinner' +import Toast from '@/components/Toast' import GlobalModals from '@/components/modals/GlobalModals' import { ReferralUrlChanger } from '@/components/referral/ReferralUrlChanger' import { env } from '@/env.mjs' @@ -10,6 +11,7 @@ import useSaveTappedPointsAndEnergy, { } from '@/modules/telegram/TapPage/useSaveTappedPointsAndEnergy' import { ConfigProvider } from '@/providers/config/ConfigProvider' import EvmProvider from '@/providers/evm/EvmProvider' +import { getDatahubHealthQuery } from '@/services/datahub/health/query' import { getLinkedIdentityQuery } from '@/services/datahub/identity/query' import { increaseEnergyValue } from '@/services/datahub/leaderboard/points-balance/optimistic' import { FULL_ENERGY_VALUE } from '@/services/datahub/leaderboard/points-balance/query' @@ -33,7 +35,7 @@ import type { AppProps } from 'next/app' import Script from 'next/script' import React, { useEffect, useRef, useState } from 'react' import { isDesktop } from 'react-device-detect' -import { Toaster } from 'sonner' +import { Toaster, toast } from 'sonner' import urlJoin from 'url-join' export type AppCommonProps = { @@ -139,6 +141,7 @@ function AppContent({ Component, pageProps }: AppProps) { +
@@ -262,3 +265,32 @@ function SessionAccountChecker() { return null } + +function DatahubHealthChecker() { + const { data } = getDatahubHealthQuery.useQuery(null, { + refetchInterval: 10_000, + }) + const currentId = useRef('') + useEffect(() => { + if (typeof data !== 'boolean') return + if (!data) { + if (currentId.current) return + const id = toast.custom( + (t) => ( + + ), + { duration: Infinity, dismissible: false } + ) + currentId.current = id + } else { + toast.dismiss(currentId.current) + currentId.current = '' + } + }, [data]) + + return null +} diff --git a/src/pages/api/datahub/post.ts b/src/pages/api/datahub/post.ts index f24a1143c..10d59d04c 100644 --- a/src/pages/api/datahub/post.ts +++ b/src/pages/api/datahub/post.ts @@ -5,6 +5,7 @@ import { UpdatePostOptimisticInput, } from '@/server/datahub-queue/generated' import { + approveUser, createPostData, getCanAccountDo, updatePostData, @@ -59,6 +60,10 @@ export type ApiDatahubPostMutationBody = action: 'update-post' payload: UpdatePostOptimisticInput } + | { + action: 'approve-user' + payload: UpdatePostOptimisticInput + } export type ApiDatahubPostResponse = ApiResponse<{ callId?: string }> const POST_handler = handlerWrapper({ @@ -101,6 +106,8 @@ function datahubPostActionMapping(data: ApiDatahubPostMutationBody) { return createPostData(data.payload) case 'update-post': return updatePostData(data.payload) + case 'approve-user': + return approveUser(data.payload) default: throw new Error('Unknown action') } diff --git a/src/pages/tg/index.tsx b/src/pages/tg/index.tsx index a6c641aae..5d7d34577 100644 --- a/src/pages/tg/index.tsx +++ b/src/pages/tg/index.tsx @@ -14,10 +14,14 @@ async function prefetchChatData(client: QueryClient, chatId: string) { const firstPageData = await getPaginatedPostIdsByPostId.fetchFirstPageQuery( client, - chatId, + { postId: chatId, onlyDisplayUnapprovedMessages: false, myAddress: '' }, 1 ) - getPaginatedPostIdsByPostId.invalidateFirstQuery(client, chatId) + getPaginatedPostIdsByPostId.invalidateFirstQuery(client, { + postId: chatId, + onlyDisplayUnapprovedMessages: false, + myAddress: '', + }) const ownerIds = firstPageData.data .map((id) => { const post = getPostQuery.getQueryData(client, id) diff --git a/src/server/datahub-queue/generated.ts b/src/server/datahub-queue/generated.ts index 393d49d66..d2b6db69b 100644 --- a/src/server/datahub-queue/generated.ts +++ b/src/server/datahub-queue/generated.ts @@ -194,6 +194,7 @@ export type Mutation = { moderationInitModerator: IngestDataResponseDto; moderationUnblockResource: IngestDataResponseDto; socialProfileAddReferrerId: IngestDataResponseDto; + socialProfileSetActionPermissions: IngestDataResponseDto; updatePostBlockchainSyncStatus: IngestDataResponseDto; updatePostOptimistic: IngestDataResponseDto; updateSpaceOffChain: IngestDataResponseDto; @@ -315,6 +316,11 @@ export type MutationSocialProfileAddReferrerIdArgs = { }; +export type MutationSocialProfileSetActionPermissionsArgs = { + args: SocialProfileAddReferrerIdInput; +}; + + export type MutationUpdatePostBlockchainSyncStatusArgs = { updatePostBlockchainSyncStatusInput: UpdatePostBlockchainSyncStatusInput; }; @@ -437,6 +443,7 @@ export enum SocialCallName { SynthModerationInitModerator = 'synth_moderation_init_moderator', SynthModerationUnblockResource = 'synth_moderation_unblock_resource', SynthSocialProfileAddReferrerId = 'synth_social_profile_add_referrer_id', + SynthSocialProfileSetActionPermissions = 'synth_social_profile_set_action_permissions', SynthUpdatePostTxFailed = 'synth_update_post_tx_failed', SynthUpdatePostTxRetry = 'synth_update_post_tx_retry', UpdatePost = 'update_post', @@ -571,6 +578,13 @@ export type UpdatePostOptimisticMutationVariables = Exact<{ export type UpdatePostOptimisticMutation = { __typename?: 'Mutation', updatePostOptimistic: { __typename?: 'IngestDataResponseDto', processed: boolean, callId?: string | null, message?: string | null } }; +export type ApproveUserMutationVariables = Exact<{ + input: UpdatePostOptimisticInput; +}>; + + +export type ApproveUserMutation = { __typename?: 'Mutation', updatePostOptimistic: { __typename?: 'IngestDataResponseDto', processed: boolean, callId?: string | null, message?: string | null } }; + export type SetReferrerIdMutationVariables = Exact<{ setReferrerIdInput: SocialProfileAddReferrerIdInput; }>; @@ -704,6 +718,15 @@ export const UpdatePostOptimistic = gql` } } `; +export const ApproveUser = gql` + mutation ApproveUser($input: UpdatePostOptimisticInput!) { + updatePostOptimistic(updatePostOptimisticInput: $input) { + processed + callId + message + } +} + `; export const SetReferrerId = gql` mutation SetReferrerId($setReferrerIdInput: SocialProfileAddReferrerIdInput!) { socialProfileAddReferrerId(args: $setReferrerIdInput) { diff --git a/src/server/datahub-queue/post.ts b/src/server/datahub-queue/post.ts index 06dcba39a..d80e7f1ae 100644 --- a/src/server/datahub-queue/post.ts +++ b/src/server/datahub-queue/post.ts @@ -1,5 +1,7 @@ import { gql } from 'graphql-request' import { + ApproveUserMutation, + ApproveUserMutationVariables, CanAccountDoArgsInput, CreatePostOffChainInput, CreatePostOffChainMutation, @@ -83,3 +85,27 @@ export async function updatePostData(input: UpdatePostOptimisticInput) { throwErrorIfNotProcessed(res.updatePostOptimistic, 'Failed to update post') return res.updatePostOptimistic.callId } + +// TODO: change this if the new mutation is created +const APPROVE_USER_MUTATION = gql` + mutation ApproveUser($input: UpdatePostOptimisticInput!) { + updatePostOptimistic(updatePostOptimisticInput: $input) { + processed + callId + message + } + } +` +export async function approveUser(input: UpdatePostOptimisticInput) { + const res = await datahubQueueRequest< + ApproveUserMutation, + ApproveUserMutationVariables + >({ + document: APPROVE_USER_MUTATION, + variables: { + input, + }, + }) + throwErrorIfNotProcessed(res.updatePostOptimistic, 'Failed to approve user') + return res.updatePostOptimistic.callId +} diff --git a/src/services/datahub/events/subscription.tsx b/src/services/datahub/events/subscription.tsx index 8ac96e027..f30e3fd02 100644 --- a/src/services/datahub/events/subscription.tsx +++ b/src/services/datahub/events/subscription.tsx @@ -19,6 +19,7 @@ import { SubscribeEventsSubscriptionVariables, } from '../generated-query' import { callIdToPostIdMap } from '../posts/mutation' +import { getProfileQuery } from '../profiles/query' import { getGamificationTasksErrorQuery } from '../tasks/query' import { datahubSubscription } from '../utils' @@ -51,6 +52,7 @@ const SUBSCRIBE_EVENTS = gql` msg code callId + extension } } } @@ -205,6 +207,26 @@ async function processSubscriptionEvent( break } + if ( + eventData.meta.callName === + SocialCallName.SynthSocialProfileSetActionPermissions + ) { + const profile = getProfileQuery.getQueryData( + client, + eventData.meta.extension?.updatedCreatorAddress ?? '' + ) + toast.custom((t) => ( + + )) + return + } + if (eventData.meta.callName === SocialCallName.SynthGamificationClaimTask) { claimTaskErrorStore.set(eventData.meta.code) diff --git a/src/services/datahub/generalStats/query.ts b/src/services/datahub/generalStats/query.ts index 94f0d81aa..1839717b8 100644 --- a/src/services/datahub/generalStats/query.ts +++ b/src/services/datahub/generalStats/query.ts @@ -8,9 +8,6 @@ import { datahubQueryRequest } from '../utils' const generalStatsId = 'generalStatsId' -export const getGeneralStatsData = () => - getGeneralStatsQuery.useQuery(generalStatsId) - const GET_GENERAL_STATS = gql` query GetGeneralStats { activeStakingTotalActivityMetricsForFixedPeriod( diff --git a/src/services/datahub/generated-query.ts b/src/services/datahub/generated-query.ts index da025dd97..2f3347afd 100644 --- a/src/services/datahub/generated-query.ts +++ b/src/services/datahub/generated-query.ts @@ -497,6 +497,7 @@ export type FindPostsFilter = { AND?: InputMaybe> OR?: InputMaybe> activeStaking?: InputMaybe + approvedInRootPost?: InputMaybe createdAtTime?: InputMaybe /** Datetime as ISO 8601 string */ createdAtTimeGt?: InputMaybe @@ -575,6 +576,8 @@ export type FindTasksResponseDto = { export type FindTasksWithFilterArgs = { filter: FindTasksFilter offset?: InputMaybe + orderBy?: InputMaybe + orderDirection?: InputMaybe pageSize?: InputMaybe } @@ -597,6 +600,7 @@ export type GamificationTask = { completedAt?: Maybe createdAt?: Maybe id: Scalars['String']['output'] + index: Scalars['Int']['output'] linkedIdentity: LinkedIdentity metadata?: Maybe name: GamificationTaskName @@ -617,6 +621,7 @@ export type GamificationTaskMetadata = { likesNumberToAchieve?: Maybe referralsNumberToAchieve?: Maybe telegramChannelToJoin?: Maybe + twitterChannelToJoin?: Maybe userExternalProvider?: Maybe userExternalProviderId?: Maybe } @@ -966,6 +971,7 @@ export type Post = { activeStaking: Scalars['Boolean']['output'] activeStakingSuperLikes?: Maybe> activeStakingSuperLikesCount?: Maybe + approvedInRootPost: Scalars['Boolean']['output'] /** is off-chain data CID backed up in blockchain */ backupInBlockchain?: Maybe blockchainSyncFailed: Scalars['Boolean']['output'] @@ -1550,6 +1556,7 @@ export enum SocialCallName { SynthModerationInitModerator = 'synth_moderation_init_moderator', SynthModerationUnblockResource = 'synth_moderation_unblock_resource', SynthSocialProfileAddReferrerId = 'synth_social_profile_add_referrer_id', + SynthSocialProfileSetActionPermissions = 'synth_social_profile_set_action_permissions', SynthUpdatePostTxFailed = 'synth_update_post_tx_failed', SynthUpdatePostTxRetry = 'synth_update_post_tx_retry', UpdatePost = 'update_post', @@ -1570,6 +1577,7 @@ export type SocialProfile = { activeStakingTrial: Scalars['Boolean']['output'] activeStakingTrialFinishedAtTime?: Maybe activeStakingTrialStartedAtTime?: Maybe + allowedCreateCommentRootPostIds: Array balances?: Maybe entranceDailyRewardSequences?: Maybe< Array @@ -2226,6 +2234,7 @@ export type SubscribeEventsSubscription = { msg?: string | null code: ServiceMessageStatusCode callId?: string | null + extension?: any | null } } } @@ -2615,6 +2624,7 @@ export type DatahubPostFragmentFragment = { createdAtTime?: any | null title?: string | null body?: string | null + approvedInRootPost: boolean createdByAccount: { __typename?: 'Account'; id: string } space?: { __typename?: 'Space'; id: string } | null ownedByAccount: { __typename?: 'Account'; id: string } @@ -2647,6 +2657,7 @@ export type GetPostsQuery = { createdAtTime?: any | null title?: string | null body?: string | null + approvedInRootPost: boolean createdByAccount: { __typename?: 'Account'; id: string } space?: { __typename?: 'Space'; id: string } | null ownedByAccount: { __typename?: 'Account'; id: string } @@ -2680,6 +2691,7 @@ export type GetOptimisticPostsQuery = { createdAtTime?: any | null title?: string | null body?: string | null + approvedInRootPost: boolean createdByAccount: { __typename?: 'Account'; id: string } space?: { __typename?: 'Space'; id: string } | null ownedByAccount: { __typename?: 'Account'; id: string } @@ -2712,6 +2724,7 @@ export type GetCommentIdsInPostIdQuery = { createdAtTime?: any | null title?: string | null body?: string | null + approvedInRootPost: boolean createdByAccount: { __typename?: 'Account'; id: string } space?: { __typename?: 'Space'; id: string } | null ownedByAccount: { __typename?: 'Account'; id: string } @@ -2776,6 +2789,7 @@ export type GetOwnedPostsQuery = { createdAtTime?: any | null title?: string | null body?: string | null + approvedInRootPost: boolean createdByAccount: { __typename?: 'Account'; id: string } space?: { __typename?: 'Space'; id: string } | null ownedByAccount: { __typename?: 'Account'; id: string } @@ -2807,6 +2821,7 @@ export type GetPostsBySpaceIdQuery = { createdAtTime?: any | null title?: string | null body?: string | null + approvedInRootPost: boolean createdByAccount: { __typename?: 'Account'; id: string } space?: { __typename?: 'Space'; id: string } | null ownedByAccount: { __typename?: 'Account'; id: string } @@ -2836,6 +2851,16 @@ export type GetLastPostedMemeQuery = { } } +export type GetUnapprovedMemesCountQueryVariables = Exact<{ + address: Scalars['String']['input'] + postId: Scalars['String']['input'] +}> + +export type GetUnapprovedMemesCountQuery = { + __typename?: 'Query' + posts: { __typename?: 'FindPostsResponseDto'; total?: number | null } +} + export type SubscribePostSubscriptionVariables = Exact<{ [key: string]: never }> export type SubscribePostSubscription = { @@ -2849,6 +2874,8 @@ export type SubscribePostSubscription = { persistentId?: string | null optimisticId?: string | null dataType: DataType + approvedInRootPost: boolean + createdAtTime?: any | null rootPost?: { __typename?: 'Post'; persistentId?: string | null } | null } } @@ -2971,6 +2998,7 @@ export const DatahubPostFragment = gql` } title body + approvedInRootPost ownedByAccount { id } @@ -3201,6 +3229,7 @@ export const SubscribeEvents = gql` msg code callId + extension } } } @@ -3583,7 +3612,7 @@ export const GetLastPostedMeme = gql` query GetLastPostedMeme($address: String!) { posts( args: { - filter: { createdByAccountAddress: $address } + filter: { createdByAccountAddress: $address, approvedInRootPost: true } pageSize: 1 orderBy: "createdAtTime" orderDirection: DESC @@ -3595,6 +3624,21 @@ export const GetLastPostedMeme = gql` } } ` +export const GetUnapprovedMemesCount = gql` + query GetUnapprovedMemesCount($address: String!, $postId: String!) { + posts( + args: { + filter: { + createdByAccountAddress: $address + approvedInRootPost: false + rootPostId: $postId + } + } + ) { + total + } + } +` export const SubscribePost = gql` subscription SubscribePost { post { @@ -3604,6 +3648,8 @@ export const SubscribePost = gql` persistentId optimisticId dataType + approvedInRootPost + createdAtTime rootPost { persistentId } diff --git a/src/services/datahub/health/query.ts b/src/services/datahub/health/query.ts new file mode 100644 index 000000000..993a73dd0 --- /dev/null +++ b/src/services/datahub/health/query.ts @@ -0,0 +1,21 @@ +import { env } from '@/env.mjs' +import { createQuery } from '@/subsocial-query' +import axios from 'axios' +import urlJoin from 'url-join' + +export const getDatahubHealthQuery = createQuery({ + key: 'datahubHealth', + fetcher: async () => { + try { + const res = await axios.get( + urlJoin( + env.NEXT_PUBLIC_DATAHUB_QUERY_URL.replace(/\/graphql\/?$/, ''), + '/healthcheck/status' + ) + ) + return res.data.operational as boolean + } catch { + return false + } + }, +}) diff --git a/src/services/datahub/mappers.ts b/src/services/datahub/mappers.ts index 4a6f82b0d..5bda75898 100644 --- a/src/services/datahub/mappers.ts +++ b/src/services/datahub/mappers.ts @@ -51,6 +51,7 @@ export const mapDatahubPostFragment = ( repliesCount: 0, sharesCount: 0, spaceId: '', + approvedInRootPost: post.approvedInRootPost ?? false, isUpdated: false, rootPostId: post.rootPost?.persistentId ?? '', parentPostId: null, diff --git a/src/services/datahub/posts/fetcher.ts b/src/services/datahub/posts/fetcher.ts index 99e370a80..ed882c778 100644 --- a/src/services/datahub/posts/fetcher.ts +++ b/src/services/datahub/posts/fetcher.ts @@ -21,6 +21,7 @@ export const DATAHUB_POST_FRAGMENT = gql` } title body + approvedInRootPost ownedByAccount { id } diff --git a/src/services/datahub/posts/mutation.ts b/src/services/datahub/posts/mutation.ts index b4b02bf81..e935c2ecb 100644 --- a/src/services/datahub/posts/mutation.ts +++ b/src/services/datahub/posts/mutation.ts @@ -1,5 +1,5 @@ import { getMaxMessageLength } from '@/constants/chat' -import { TIME_CONSTRAINT } from '@/constants/chat-rules' +import { env } from '@/env.mjs' import { ApiDatahubPostMutationBody, ApiDatahubPostResponse, @@ -14,6 +14,7 @@ import { import { SendMessageParams } from '@/services/subsocial/commentIds/types' import { getCurrentWallet } from '@/services/subsocial/hooks' import { getMyMainAddress } from '@/stores/my-account' +import mutationWrapper from '@/subsocial-query/base' import { ParentPostIdWrapper, ReplyWrapper } from '@/utils/ipfs' import { LocalStorage } from '@/utils/storage' import { TAGS_REGEX } from '@/utils/strings' @@ -22,6 +23,7 @@ import { PostContent } from '@subsocial/api/types' import { CreatePostCallParsedArgs, PostKind, + SocialCallDataArgs, UpdatePostCallParsedArgs, socialCallName, } from '@subsocial/data-hub-sdk' @@ -255,7 +257,7 @@ export function useSendMessage( getTimeLeftUntilCanPostQuery.setQueryData( queryClient, myAddress, - TIME_CONSTRAINT + env.NEXT_PUBLIC_TIME_CONSTRAINT ) } }, @@ -276,6 +278,7 @@ export function useSendMessage( const myAddress = getMyMainAddress() if (queryClient && myAddress) { + lastSentMessageStorage.remove() getTimeLeftUntilCanPostQuery.setQueryData(queryClient, myAddress, 0) } }, @@ -295,3 +298,22 @@ export function useSendMessage( }, }) } + +type ApproveUserArgs = + SocialCallDataArgs<'synth_social_profile_set_action_permissions'> +async function approveUser(args: ApproveUserArgs) { + const input = await createSignedSocialDataEvent( + socialCallName.synth_social_profile_set_action_permissions, + { ...getCurrentWallet(), args }, + args + ) + + await apiInstance.post( + '/api/datahub/post', + { + action: 'approve-user', + payload: input as any, + } + ) +} +export const useApproveUser = mutationWrapper(approveUser) diff --git a/src/services/datahub/posts/query.ts b/src/services/datahub/posts/query.ts index 454dc42d6..9375b4065 100644 --- a/src/services/datahub/posts/query.ts +++ b/src/services/datahub/posts/query.ts @@ -1,5 +1,5 @@ import { CHAT_PER_PAGE } from '@/constants/chat' -import { TIME_CONSTRAINT } from '@/constants/chat-rules' +import { env } from '@/env.mjs' import { getPostQuery, getServerTime } from '@/services/api/query' import { queryClient } from '@/services/provider' import { QueryConfig, createQuery, poolQuery } from '@/subsocial-query' @@ -26,6 +26,8 @@ import { GetPostMetadataQueryVariables, GetPostsBySpaceIdQuery, GetPostsBySpaceIdQueryVariables, + GetUnapprovedMemesCountQuery, + GetUnapprovedMemesCountQueryVariables, GetUnreadCountQuery, GetUnreadCountQueryVariables, QueryOrder, @@ -52,19 +54,29 @@ export type PaginatedPostsData = { hasMore: boolean totalData: number } +type Data = { + postId: string + onlyDisplayUnapprovedMessages: boolean + myAddress: string +} async function getPaginatedPostIdsByRootPostId({ page, postId, client, + onlyDisplayUnapprovedMessages, + myAddress, }: { - postId: string page: number client?: QueryClient | null -}): Promise { +} & Data): Promise { if (!postId || !client) return { data: [], page, hasMore: false, totalData: 0 } - const oldIds = getPaginatedPostIdsByPostId.getFirstPageData(client, postId) + const oldIds = getPaginatedPostIdsByPostId.getFirstPageData(client, { + postId, + onlyDisplayUnapprovedMessages, + myAddress, + }) const firstPageDataLength = oldIds?.length || CHAT_PER_PAGE // only first page that has dynamic content, where its length can increase from: @@ -81,9 +93,24 @@ async function getPaginatedPostIdsByRootPostId({ document: GET_COMMENT_IDS_IN_POST_ID, variables: { args: { - filter: { - rootPostId: postId, - }, + filter: + myAddress && !onlyDisplayUnapprovedMessages + ? { + OR: [ + { + rootPostId: postId, + approvedInRootPost: !onlyDisplayUnapprovedMessages, + }, + { + rootPostId: postId, + createdByAccountAddress: myAddress, + }, + ], + } + : { + rootPostId: postId, + approvedInRootPost: !onlyDisplayUnapprovedMessages, + }, orderBy: 'createdAtTime', orderDirection: QueryOrder.Desc, pageSize: CHAT_PER_PAGE, @@ -159,27 +186,27 @@ async function getPaginatedPostIdsByRootPostId({ } } const COMMENT_IDS_QUERY_KEY = 'comments' -const getQueryKey = (postId: string) => [COMMENT_IDS_QUERY_KEY, postId] +const getQueryKey = (data: Data) => [COMMENT_IDS_QUERY_KEY, data] export const getPaginatedPostIdsByPostId = { getQueryKey, - getFirstPageData: (client: QueryClient, postId: string) => { - const cachedData = client?.getQueryData(getQueryKey(postId)) + getFirstPageData: (client: QueryClient, data: Data) => { + const cachedData = client?.getQueryData(getQueryKey(data)) return ((cachedData as any)?.pages?.[0] as PaginatedPostsData | undefined) ?.data }, fetchFirstPageQuery: async ( client: QueryClient | null, - postId: string, + data: Data, page = 1 ) => { const res = await getPaginatedPostIdsByRootPostId({ - postId, + ...data, page, client, }) if (!client) return res - client.setQueryData(getQueryKey(postId), { + client.setQueryData(getQueryKey(data), { pageParams: [1], pages: [res], }) @@ -187,10 +214,10 @@ export const getPaginatedPostIdsByPostId = { }, setQueryFirstPageData: ( client: QueryClient, - postId: string, + data: Data, updater: (oldIds?: string[]) => string[] | undefined | null ) => { - client.setQueryData(getQueryKey(postId), (oldData: any) => { + client.setQueryData(getQueryKey(data), (oldData: any) => { const firstPage = oldData?.pages?.[0] as PaginatedPostsData | undefined const newPages = [...(oldData?.pages ?? [])] const newFirstPageMessageIds = updater(firstPage?.data) @@ -205,23 +232,23 @@ export const getPaginatedPostIdsByPostId = { } }) }, - invalidateFirstQuery: (client: QueryClient, postId: string) => { - client.invalidateQueries(getQueryKey(postId), { + invalidateFirstQuery: (client: QueryClient, data: Data) => { + client.invalidateQueries(getQueryKey(data), { refetchPage: (_, index) => index === 0, }) }, useInfiniteQuery: ( - postId: string, + data: Data, config?: QueryConfig ): UseInfiniteQueryResult => { const client = useQueryClient() return useInfiniteQuery({ ...config, - queryKey: getQueryKey(postId), + queryKey: getQueryKey(data), queryFn: async ({ pageParam = 1, queryKey }) => { const [_, postId] = queryKey const res = await getPaginatedPostIdsByRootPostId({ - postId, + ...data, page: pageParam, client, }) @@ -231,7 +258,7 @@ export const getPaginatedPostIdsByPostId = { client.setQueryData<{ pageParams: number[] pages: PaginatedPostsData[] - }>(getQueryKey(postId), (oldData) => { + }>(getQueryKey(data), (oldData) => { if ( !oldData || !Array.isArray(oldData.pageParams) || @@ -456,7 +483,7 @@ const GET_LAST_POSTED_MEME = gql` query GetLastPostedMeme($address: String!) { posts( args: { - filter: { createdByAccountAddress: $address } + filter: { createdByAccountAddress: $address, approvedInRootPost: true } pageSize: 1 orderBy: "createdAtTime" orderDirection: DESC @@ -498,8 +525,8 @@ async function getTimeLeftUntilCanPost(address: string) { } if (!lastPosted) return Infinity - const timeLeft = lastPosted + TIME_CONSTRAINT - serverTime - return Math.min(Math.max(timeLeft, 0), TIME_CONSTRAINT) + const timeLeft = lastPosted + env.NEXT_PUBLIC_TIME_CONSTRAINT - serverTime + return Math.min(Math.max(timeLeft, 0), env.NEXT_PUBLIC_TIME_CONSTRAINT) } export const getTimeLeftUntilCanPostQuery = createQuery({ key: 'lastPostedMeme', @@ -508,3 +535,35 @@ export const getTimeLeftUntilCanPostQuery = createQuery({ enabled: !!address, }), }) + +const GET_UNAPPROVED_MEMES_COUNT = gql` + query GetUnapprovedMemesCount($address: String!, $postId: String!) { + posts( + args: { + filter: { + createdByAccountAddress: $address + approvedInRootPost: false + rootPostId: $postId + } + } + ) { + total + } + } +` +export const getUnapprovedMemesCountQuery = createQuery({ + key: 'unapprovedMemesCount', + fetcher: async ({ address, chatId }: { chatId: string; address: string }) => { + const res = await datahubQueryRequest< + GetUnapprovedMemesCountQuery, + GetUnapprovedMemesCountQueryVariables + >({ + document: GET_UNAPPROVED_MEMES_COUNT, + variables: { address, postId: chatId }, + }) + return res.posts.total ?? 0 + }, + defaultConfigGenerator: (params) => ({ + enabled: !!params?.address && !!params.chatId, + }), +}) diff --git a/src/services/datahub/posts/queryByAccount.ts b/src/services/datahub/posts/queryByAccount.ts new file mode 100644 index 000000000..5add26377 --- /dev/null +++ b/src/services/datahub/posts/queryByAccount.ts @@ -0,0 +1,259 @@ +import { CHAT_PER_PAGE } from '@/constants/chat' +import { getPostQuery } from '@/services/api/query' +import { + commentIdsOptimisticEncoder, + isClientGeneratedOptimisticId, +} from '@/services/subsocial/commentIds/optimistic' +import { PostData } from '@subsocial/api/types' +import { + QueryClient, + QueryClientConfig, + UseInfiniteQueryResult, + useInfiniteQuery, + useQueryClient, +} from '@tanstack/react-query' +import { gql } from 'graphql-request' +import { + GetCommentIdsInPostIdQuery, + GetCommentIdsInPostIdQueryVariables, + QueryOrder, +} from '../generated-query' +import { mapDatahubPostFragment } from '../mappers' +import { datahubQueryRequest } from '../utils' +import { DATAHUB_POST_FRAGMENT } from './fetcher' +import { PaginatedPostsData } from './query' + +const GET_COMMENT_IDS_IN_POST_ID = gql` + ${DATAHUB_POST_FRAGMENT} + query GetCommentIdsInPostId($args: FindPostsWithFilterArgs!) { + posts(args: $args) { + data { + ...DatahubPostFragment + } + total + } + } +` +async function getPaginatedPostIdsByRootPostIdAndAccount({ + page, + postId, + account, + client, +}: { + postId: string + page: number + account: string + client?: QueryClient | null +}): Promise { + if (!postId || !client) + return { data: [], page, hasMore: false, totalData: 0 } + + const oldIds = getPaginatedPostIdsByPostIdAndAccount.getFirstPageData( + client, + postId, + account + )?.data + const firstPageDataLength = oldIds?.length || CHAT_PER_PAGE + + // only first page that has dynamic content, where its length can increase from: + // - subscription + // - invalidation + // so the offset has to accommodate the length of the current first page + let offset = Math.max((page - 2) * CHAT_PER_PAGE + firstPageDataLength, 0) + if (page === 1) offset = 0 + + const res = await datahubQueryRequest< + GetCommentIdsInPostIdQuery, + GetCommentIdsInPostIdQueryVariables + >({ + document: GET_COMMENT_IDS_IN_POST_ID, + variables: { + args: { + filter: { + rootPostId: postId, + createdByAccountAddress: account, + }, + orderBy: 'createdAtTime', + orderDirection: QueryOrder.Desc, + pageSize: CHAT_PER_PAGE, + offset, + }, + }, + }) + const optimisticIds = new Set() + const ids: string[] = [] + const messages: PostData[] = [] + res.posts.data.forEach((post) => { + optimisticIds.add('') + + const id = post.id + ids.push(id) + + const mapped = mapDatahubPostFragment(post) + messages.push(mapped) + getPostQuery.setQueryData(client, id, mapped) + }) + const totalData = res.posts.total ?? 0 + const hasMore = offset + ids.length < totalData + + const idsSet = new Set(ids) + + // only adding the client optimistic ids, and unincluded ids if refetching first page + // for fetching first page, no ids that has been fetched will be removed + // ex: first fetch: id 1-50, and after invalidation, there is new data id 0 + // the result should be id 0-50 instead of 0-49 + let unincludedOptimisticIds: string[] = [] + let unincludedFirstPageIds: string[] = [] + if (page === 1 && oldIds) { + const oldOptimisticIds = [] + + let unincludedIdsIndex = oldIds.length + + // for example, if the page size is 3, and there is 1 new id + // ids: [new, old1, old2], and oldIds: [old1, old2, old3] + // so we need to get the unincludedOldIds from the end of the array (old3) + let hasFoundIncludedId = false + + for (let i = oldIds.length - 1; i >= 0; i--) { + const id = oldIds[i] + + if (isClientGeneratedOptimisticId(id)) { + oldOptimisticIds.unshift(id) + } + + if (hasFoundIncludedId) continue + + if (!idsSet.has(id)) { + unincludedIdsIndex = i + } else { + hasFoundIncludedId = true + } + } + + unincludedFirstPageIds = oldIds.slice(unincludedIdsIndex) + unincludedOptimisticIds = oldOptimisticIds.filter( + (id) => !optimisticIds.has(commentIdsOptimisticEncoder.decode(id)) + ) + } + + return { + data: [ + ...unincludedOptimisticIds, + ...messages.map(({ id }) => id), + ...unincludedFirstPageIds, + ], + page, + hasMore, + totalData, + } +} + +const COMMENT_IDS_QUERY_KEY = 'commentsByAccount' +const getQueryKey = (postId: string, account: string) => [ + COMMENT_IDS_QUERY_KEY, + postId, + account, +] +export const getPaginatedPostIdsByPostIdAndAccount = { + getQueryKey, + getFirstPageData: (client: QueryClient, postId: string, account: string) => { + const cachedData = client?.getQueryData(getQueryKey(postId, account)) + return (cachedData as any)?.pages?.[0] as PaginatedPostsData | undefined + }, + fetchFirstPageQuery: async ( + client: QueryClient | null, + postId: string, + account: string, + page = 1 + ) => { + const res = await getPaginatedPostIdsByRootPostIdAndAccount({ + postId, + page, + account, + client, + }) + if (!client) return res + + client.setQueryData(getQueryKey(postId, account), { + pageParams: [1], + pages: [res], + }) + return res + }, + setQueryFirstPageData: ( + client: QueryClient, + postId: string, + updater: (oldIds?: string[]) => string[] | undefined | null, + account: string + ) => { + client.setQueryData(getQueryKey(postId, account), (oldData: any) => { + const firstPage = oldData?.pages?.[0] as PaginatedPostsData | undefined + const newPages = [...(oldData?.pages ?? [])] + const newFirstPageMessageIds = updater(firstPage?.data) + newPages.splice(0, 1, { + ...firstPage, + data: newFirstPageMessageIds, + totalData: newFirstPageMessageIds?.length ?? 0, + } as PaginatedPostsData) + return { + pageParams: [...(oldData?.pageParams ?? [])], + pages: [...newPages], + } + }) + }, + invalidateFirstQuery: ( + client: QueryClient, + postId: string, + account: string + ) => { + client.invalidateQueries(getQueryKey(postId, account), { + refetchPage: (_, index) => index === 0, + }) + }, + useInfiniteQuery: ( + postId: string, + account: string, + config?: QueryClientConfig + ): UseInfiniteQueryResult => { + const client = useQueryClient() + return useInfiniteQuery({ + ...config, + queryKey: getQueryKey(postId, account), + queryFn: async ({ pageParam = 1, queryKey }) => { + const [_, postId] = queryKey + const res = await getPaginatedPostIdsByRootPostIdAndAccount({ + postId, + page: pageParam, + account, + client, + }) + + // hotfix because in offchain chat (/offchain/18634) its not updating cache when first invalidated from server + if (pageParam === 1) { + client.setQueryData<{ + pageParams: number[] + pages: PaginatedPostsData[] + }>(getQueryKey(postId, account), (oldData) => { + if ( + !oldData || + !Array.isArray(oldData.pageParams) || + !Array.isArray(oldData.pages) + ) + return oldData + const pages = [...oldData.pages] + pages.splice(0, 1, res) + return { + ...oldData, + pageParams: [...oldData.pageParams], + pages, + } + }) + } + + return res + }, + getNextPageParam: (lastPage) => + lastPage.hasMore ? lastPage.page + 1 : undefined, + }) + }, +} diff --git a/src/services/datahub/posts/subscription.tsx b/src/services/datahub/posts/subscription.tsx index 577f148e2..320394606 100644 --- a/src/services/datahub/posts/subscription.tsx +++ b/src/services/datahub/posts/subscription.tsx @@ -1,8 +1,11 @@ import Toast from '@/components/Toast' +import { MIN_MEME_FOR_REVIEW } from '@/constants/chat' import { getPostQuery } from '@/services/api/query' import { commentIdsOptimisticEncoder } from '@/services/subsocial/commentIds/optimistic' -import { getMyMainAddress } from '@/stores/my-account' +import { useMessageData } from '@/stores/message' +import { getMyMainAddress, useMyMainAddress } from '@/stores/my-account' import { useSubscriptionState } from '@/stores/subscription' +import { cx } from '@/utils/class-names' import { QueryClient, useQueryClient } from '@tanstack/react-query' import { gql } from 'graphql-request' import { useEffect, useRef } from 'react' @@ -13,11 +16,18 @@ import { SubscribePostSubscription, } from '../generated-query' import { datahubSubscription, isDatahubAvailable } from '../utils' -import { getPaginatedPostIdsByPostId, getPostMetadataQuery } from './query' +import { lastSentMessageStorage } from './mutation' +import { + getPaginatedPostIdsByPostId, + getPostMetadataQuery, + getTimeLeftUntilCanPostQuery, + getUnapprovedMemesCountQuery, +} from './query' // Note: careful when using this in several places, if you have 2 places, the first one will be the one subscribing // the subscription will only be one, but if the first place is unmounted, it will unsubscribe, making all other places unsubscribed too export function useDatahubPostSubscriber(subscribedPostId?: string) { + const myAddress = useMyMainAddress() const queryClient = useQueryClient() const unsubRef = useRef<(() => void) | undefined>() const subState = useSubscriptionState( @@ -32,14 +42,15 @@ export function useDatahubPostSubscriber(subscribedPostId?: string) { if (!isDatahubAvailable) return const listener = () => { - if (document.visibilityState === 'visible') { + if (document.visibilityState === 'visible' && myAddress) { unsubRef.current = subscription(queryClient) // invalidate first page so it will refetch after the websocket connection is disconnected previously when the user is not in the tab if (subscribedPostId) { - getPaginatedPostIdsByPostId.invalidateFirstQuery( - queryClient, - subscribedPostId - ) + getPaginatedPostIdsByPostId.invalidateFirstQuery(queryClient, { + postId: subscribedPostId, + onlyDisplayUnapprovedMessages: false, + myAddress, + }) } } else { if ( @@ -55,7 +66,7 @@ export function useDatahubPostSubscriber(subscribedPostId?: string) { document.removeEventListener('visibilitychange', listener) unsubRef.current?.() } - }, [queryClient, subscribedPostId]) + }, [queryClient, subscribedPostId, myAddress]) } const SUBSCRIBE_POST = gql` @@ -67,6 +78,8 @@ const SUBSCRIBE_POST = gql` persistentId optimisticId dataType + approvedInRootPost + createdAtTime rootPost { persistentId } @@ -149,38 +162,156 @@ async function processMessage( getPostQuery.invalidate(queryClient, newestId) } else { if (dataFromPersistentId) { + getPostQuery.setQueryData(queryClient, newestId, (oldData) => { + if (!oldData) return oldData + return { + ...oldData, + struct: { + ...oldData.struct, + approvedInRootPost: eventData.entity.approvedInRootPost, + }, + } + }) await getPostQuery.invalidate(queryClient, newestId) } else { await getPostQuery.fetchQuery(queryClient, newestId) } } + const newPost = getPostQuery.getQueryData(queryClient, newestId) + const myAddress = getMyMainAddress() + + const rootPostId = entity.rootPost?.persistentId + const ownerId = newPost?.struct.ownerId + const isCurrentOwner = ownerId === myAddress + if (isCreationEvent) { - const newPost = getPostQuery.getQueryData(queryClient, newestId) const tokenomics = await getTokenomicsMetadataQuery.fetchQuery( queryClient, null ) - const myAddress = getMyMainAddress() - if (newPost?.struct.ownerId === myAddress && isCreationEvent) { - toast.custom((t) => ( - - )) + if (isCreationEvent && newPost) { + if (newPost.struct.approvedInRootPost) { + if (isCurrentOwner) { + toast.custom((t) => ( + ( + + )} + t={t} + title='Meme Sent!' + description={`${tokenomics.socialActionPrice.createCommentPoints} points have been used. More memes, more fun!`} + /> + )) + } + } else { + // to not wait for another query to run the other synchronous actions below + processUnapprovedMeme() + async function processUnapprovedMeme() { + if (ownerId) { + const cachedCount = getUnapprovedMemesCountQuery.getQueryData( + queryClient, + { address: ownerId, chatId: rootPostId ?? '' } + ) + if (typeof cachedCount === 'number') { + getUnapprovedMemesCountQuery.setQueryData( + queryClient, + { address: ownerId, chatId: rootPostId ?? '' }, + (count) => (count ?? 0) + 1 + ) + } else if (isCurrentOwner) { + await getUnapprovedMemesCountQuery.fetchQuery(queryClient, { + address: ownerId, + chatId: rootPostId ?? '', + }) + } + } + if (isCurrentOwner) { + // reset timer because its unapproved meme + getTimeLeftUntilCanPostQuery.setQueryData(queryClient, myAddress, 0) + lastSentMessageStorage.remove() + + const count = getUnapprovedMemesCountQuery.getQueryData( + queryClient, + { address: myAddress, chatId: rootPostId ?? '' } + ) + if (count === 1 || count === 3) { + useMessageData.getState().setOpenMessageModal('on-review') + } else { + const remaining = Math.max(MIN_MEME_FOR_REVIEW - (count ?? 0), 0) + const title = 'Under review' + const description = + remaining > 0 + ? `${ + tokenomics.socialActionPrice.createCommentPoints + } points have been used. We received your meme! We need at least ${remaining} more meme${ + remaining > 1 ? 's' : '' + } from you to mark you as a verified creator.` + : `${ + tokenomics.socialActionPrice.createCommentPoints + } points have been used. We received ${ + count ?? 0 + } memes from you! Now we need a bit of time to finish review you as a verified creator.` + toast.custom((t) => ( + ( + + )} + title={title} + description={description} + /> + )) + } + } + } + } } } - const rootPostId = entity.rootPost?.persistentId if (!rootPostId) return + if (isCreationEvent && !entity.approvedInRootPost && isCurrentOwner) { + getPaginatedPostIdsByPostId.setQueryFirstPageData( + queryClient, + { + postId: rootPostId, + onlyDisplayUnapprovedMessages: false, + myAddress: getMyMainAddress() ?? '', + }, + (oldData) => { + if (!oldData) return [newestId] + const oldIdsSet = new Set(oldData) + if (oldIdsSet.has(newestId)) return oldData + + const newIds = [...oldData] + const index = oldData.findIndex((id) => { + const data = getPostQuery.getQueryData(queryClient, id) + if (!data) return false + if (data.struct.createdAtTime <= eventData.entity.createdAtTime) { + newIds.unshift(newestId) + return true + } + return false + }) + if (index !== -1 || oldData.length <= 0) { + newIds.splice(index, 0, newestId) + } + + return newIds + } + ) + } + getPaginatedPostIdsByPostId.setQueryFirstPageData( queryClient, - rootPostId, + { + postId: rootPostId, + onlyDisplayUnapprovedMessages: !newPost?.struct.approvedInRootPost, + myAddress: getMyMainAddress() ?? '', + }, (oldData) => { - if (!oldData) return oldData + if (!oldData) return [newestId] const oldIdsSet = new Set(oldData) if (oldIdsSet.has(newestId)) return oldData @@ -205,7 +336,22 @@ async function processMessage( return newIds } - newIds.unshift(newestId) + const index = oldData.findIndex((id) => { + const data = getPostQuery.getQueryData(queryClient, id) + if (!data) return false + if ( + new Date(data.struct.createdAtTime) <= + new Date(eventData.entity.createdAtTime) + ) { + newIds.unshift(newestId) + return true + } + return false + }) + if (index !== -1 || oldData.length <= 0) { + newIds.splice(index, 0, newestId) + } + return newIds } ) diff --git a/src/services/subsocial/commentIds/optimistic.ts b/src/services/subsocial/commentIds/optimistic.ts index 6f5528348..9718c380a 100644 --- a/src/services/subsocial/commentIds/optimistic.ts +++ b/src/services/subsocial/commentIds/optimistic.ts @@ -10,6 +10,7 @@ import { getPaginatedPostIdsByPostId, getPostMetadataQuery, } from '@/services/datahub/posts/query' +import { getMyMainAddress } from '@/stores/my-account' import type { SendMessageParams } from './types' export const commentIdsOptimisticEncoder = { @@ -46,7 +47,11 @@ export function addOptimisticData({ } as unknown as PostData) getPaginatedPostIdsByPostId.setQueryFirstPageData( client, - params.chatId, + { + onlyDisplayUnapprovedMessages: false, + postId: params.chatId, + myAddress: getMyMainAddress() || '', + }, (oldData) => { return [newId, ...(oldData ?? [])] } diff --git a/src/stores/message.ts b/src/stores/message.ts index 45527aef7..04447ba16 100644 --- a/src/stores/message.ts +++ b/src/stores/message.ts @@ -23,7 +23,7 @@ type State = { unreadMessage: UnreadMessage - isOpenMessageModal: 'not-enough-balance' | 'blocked' | '' + isOpenMessageModal: 'not-enough-balance' | 'on-review' | 'blocked' | '' currentChatId: string } diff --git a/src/stores/profile-posts-modal.ts b/src/stores/profile-posts-modal.ts new file mode 100644 index 000000000..5e5c8dd5f --- /dev/null +++ b/src/stores/profile-posts-modal.ts @@ -0,0 +1,33 @@ +import { create, createSelectors } from './utils' + +type State = { + isOpen: boolean + chatId?: string + address?: string + hubId?: string + messageId?: string +} + +type Actions = { + closeModal: () => void + openModal: (config?: Omit) => void +} + +const initialState: State = { + isOpen: false, + chatId: undefined, + address: undefined, + hubId: undefined, + messageId: undefined, +} + +const useProfilePostsModalBase = create()((set) => ({ + ...initialState, + openModal: (config) => { + set({ isOpen: true, ...config }) + }, + closeModal: () => { + set(initialState) + }, +})) +export const useProfilePostsModal = createSelectors(useProfilePostsModalBase) diff --git a/yarn.lock b/yarn.lock index 1496f407d..e50c98ee6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3991,9 +3991,9 @@ integrity sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg== "@neynar/nodejs-sdk@^1.19.3": - version "1.36.1" - resolved "https://registry.yarnpkg.com/@neynar/nodejs-sdk/-/nodejs-sdk-1.36.1.tgz#36744e659bc3fe310846a86d604a54c2292e9224" - integrity sha512-aQ7o9LjRuEK68JK4Z5++QpH0bThZESKK5nCGF0SfnghyvDxx7pIrqeo6vSioxy434iukUvqraEQD5LzOOKl/2Q== + version "1.37.1" + resolved "https://registry.yarnpkg.com/@neynar/nodejs-sdk/-/nodejs-sdk-1.37.1.tgz#b252380d7c3d2fe47eb1805a7be3b4ce5766e427" + integrity sha512-4Sec4nBE0ePXIctPzppb290prSQGVPzcdiA90+/BarAd8W9q4kDqRLJl8GryHY53+vpdBEwEVACB50VdRmFSpw== dependencies: "@openapitools/openapi-generator-cli" "^2.7.0" axios "^1.6.2" @@ -5595,8 +5595,8 @@ integrity sha512-jHMVmIAjkhSzswZEid2xhWtQb7yyux/8Am/i5xBQfDw6azwSPdr1nfXCKaIYs5R/NRcqi543V9roDQQQogTaKQ== "@subsocial/data-hub-sdk@dappforce/subsocial-data-hub-sdk#staging": - version "0.0.86" - resolved "https://codeload.github.com/dappforce/subsocial-data-hub-sdk/tar.gz/c64c800724e51c5b5a84bbaa902eb85e18751c33" + version "0.0.87" + resolved "https://codeload.github.com/dappforce/subsocial-data-hub-sdk/tar.gz/9eddb0d03c5bde8658afb4fa0705c34e0c8d5e20" dependencies: "@neynar/nodejs-sdk" "^1.19.3" "@subsocial/api" "^0.8.13" @@ -5838,74 +5838,74 @@ "@svgr/plugin-jsx" "^6.5.1" "@svgr/plugin-svgo" "^6.5.1" -"@swc/core-darwin-arm64@1.6.7": - version "1.6.7" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.6.7.tgz#e98a0da9635297728a97faf7f4e11c46f8dfbb46" - integrity sha512-sNb+ghP2OhZyUjS7E5Mf3PqSvoXJ5gY6GBaH2qp8WQxx9VL7ozC4HVo6vkeFJBN5cmYqUCLnhrM3HU4W+7yMSA== - -"@swc/core-darwin-x64@1.6.7": - version "1.6.7" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.6.7.tgz#fccd389046a8fe0d8b294f9657b3861046fcd3bb" - integrity sha512-LQwYm/ATYN5fYSYVPMfComPiFo5i8jh75h1ASvNWhXtS+/+k1dq1zXTJWZRuojd5NXgW3bb6mJtJ2evwYIgYbA== - -"@swc/core-linux-arm-gnueabihf@1.6.7": - version "1.6.7" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.6.7.tgz#f384235e5f14870646157017eb06dfbaed0894c0" - integrity sha512-kEDzVhNci38LX3kdY99t68P2CDf+2QFDk5LawVamXH0iN5DRAO/+wjOhxL8KOHa6wQVqKEt5WrhD+Rrvk/34Yw== - -"@swc/core-linux-arm64-gnu@1.6.7": - version "1.6.7" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.6.7.tgz#d2b8c0c6045eecb96bc3f3dfa7fb31b5ab708cdf" - integrity sha512-SyOBUGfl31xLGpIJ/Jd6GKHtkfZyHBXSwFlK7FmPN//MBQLtTBm4ZaWTnWnGo4aRsJwQdXWDKPyqlMBtnIl1nQ== - -"@swc/core-linux-arm64-musl@1.6.7": - version "1.6.7" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.6.7.tgz#6ae2a160ba535b1f4747d35a124f410545092abe" - integrity sha512-1fOAXkDFbRfItEdMZPxT3du1QWYhgToa4YsnqTujjE8EqJW8K27hIcHRIkVuzp7PNhq8nLBg0JpJM4g27EWD7g== - -"@swc/core-linux-x64-gnu@1.6.7": - version "1.6.7" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.6.7.tgz#6ebcf76fa868321c3b079e5c668c137b9b91df49" - integrity sha512-Gp7uCwPsNO5ATxbyvfTyeNCHUGD9oA+xKMm43G1tWCy+l07gLqWMKp7DIr3L3qPD05TfAVo3OuiOn2abpzOFbw== - -"@swc/core-linux-x64-musl@1.6.7": - version "1.6.7" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.6.7.tgz#41531ef3e1c7123d87b7a7a1b984fa2689032621" - integrity sha512-QeruGBZJ15tadqEMQ77ixT/CYGk20MtlS8wmvJiV+Wsb8gPW5LgCjtupzcLLnoQzDG54JGNCeeZ0l/T8NYsOvA== - -"@swc/core-win32-arm64-msvc@1.6.7": - version "1.6.7" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.6.7.tgz#af0b84a54d01bc3aad12acffa98ebb13fc03c3e6" - integrity sha512-ouRqgSnT95lTCiU/6kJRNS5b1o+p8I/V9jxtL21WUj/JOVhsFmBErqQ0MZyCu514noWiR5BIqOrZXR8C1Knx6Q== - -"@swc/core-win32-ia32-msvc@1.6.7": - version "1.6.7" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.6.7.tgz#c454851c05c26f67d2edc399e1cde9d074744ce4" - integrity sha512-eZAP/EmJ0IcfgAx6B4/SpSjq3aT8gr0ooktfMqw/w0/5lnNrbMl2v+2kvxcneNcF7bp8VNcYZnoHlsP+LvmVbA== - -"@swc/core-win32-x64-msvc@1.6.7": - version "1.6.7" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.6.7.tgz#6ee4a3caf3466971e6b5fb2fba4674924507a2de" - integrity sha512-QOdE+7GQg1UQPS6p0KxzJOh/8GLbJ5zI1vqKArCCB0unFqUfKIjYb2TaH0geEBy3w9qtXxe3ZW6hzxtZSS9lDg== +"@swc/core-darwin-arm64@1.6.13": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.6.13.tgz#dba8f8f747ad32fdb58d5b3aec4f740354d32d1b" + integrity sha512-SOF4buAis72K22BGJ3N8y88mLNfxLNprTuJUpzikyMGrvkuBFNcxYtMhmomO0XHsgLDzOJ+hWzcgjRNzjMsUcQ== + +"@swc/core-darwin-x64@1.6.13": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.6.13.tgz#c120207a9ced298f7382ff711bac10f6541c1c82" + integrity sha512-AW8akFSC+tmPE6YQQvK9S2A1B8pjnXEINg+gGgw0KRUUXunvu1/OEOeC5L2Co1wAwhD7bhnaefi06Qi9AiwOag== + +"@swc/core-linux-arm-gnueabihf@1.6.13": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.6.13.tgz#7b15a1fd32c18dfaf76706632cf8d19146df0d5f" + integrity sha512-f4gxxvDXVUm2HLYXRd311mSrmbpQF2MZ4Ja6XCQz1hWAxXdhRl1gpnZ+LH/xIfGSwQChrtLLVrkxdYUCVuIjFg== + +"@swc/core-linux-arm64-gnu@1.6.13": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.6.13.tgz#066b6e3c805110edb98e5125a222e3d866bf8f68" + integrity sha512-Nf/eoW2CbG8s+9JoLtjl9FByBXyQ5cjdBsA4efO7Zw4p+YSuXDgc8HRPC+E2+ns0praDpKNZtLvDtmF2lL+2Gg== + +"@swc/core-linux-arm64-musl@1.6.13": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.6.13.tgz#43a08bc118f117e485e8a9a23d3cb51fe8b4e301" + integrity sha512-2OysYSYtdw79prJYuKIiux/Gj0iaGEbpS2QZWCIY4X9sGoETJ5iMg+lY+YCrIxdkkNYd7OhIbXdYFyGs/w5LDg== + +"@swc/core-linux-x64-gnu@1.6.13": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.6.13.tgz#0f7358c95f566db6ed8a4249a190043497f41323" + integrity sha512-PkR4CZYJNk5hcd2+tMWBpnisnmYsUzazI1O5X7VkIGFcGePTqJ/bWlfUIVVExWxvAI33PQFzLbzmN5scyIUyGQ== + +"@swc/core-linux-x64-musl@1.6.13": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.6.13.tgz#6e11994ccf858edb3e70d2e8d700a5b1907a68fb" + integrity sha512-OdsY7wryTxCKwGQcwW9jwWg3cxaHBkTTHi91+5nm7hFPpmZMz1HivJrWAMwVE7iXFw+M4l6ugB/wCvpYrUAAjA== + +"@swc/core-win32-arm64-msvc@1.6.13": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.6.13.tgz#b9744644f02eb6519b0fe09031080cbf32174fb1" + integrity sha512-ap6uNmYjwk9M/+bFEuWRNl3hq4VqgQ/Lk+ID/F5WGqczNr0L7vEf+pOsRAn0F6EV+o/nyb3ePt8rLhE/wjHpPg== + +"@swc/core-win32-ia32-msvc@1.6.13": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.6.13.tgz#047302065096883f52b90052d93f9c7e63cdc67b" + integrity sha512-IJ8KH4yIUHTnS/U1jwQmtbfQals7zWPG0a9hbEfIr4zI0yKzjd83lmtS09lm2Q24QBWOCFGEEbuZxR4tIlvfzA== + +"@swc/core-win32-x64-msvc@1.6.13": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.6.13.tgz#efd9706c38aa7dc3515acfa823b8ffa9f4a3c1a6" + integrity sha512-f6/sx6LMuEnbuxtiSL/EkR0Y6qUHFw1XVrh6rwzKXptTipUdOY+nXpKoh+1UsBm/r7H0/5DtOdrn3q5ZHbFZjQ== "@swc/core@^1.3.55": - version "1.6.7" - resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.6.7.tgz#5d113df161fd8ec29ab8837f385240f41315735e" - integrity sha512-BBzORL9qWz5hZqAZ83yn+WNaD54RH5eludjqIOboolFOK/Pw+2l00/H77H4CEBJnzCIBQszsyqtITmrn4evp0g== + version "1.6.13" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.6.13.tgz#a583f614203d2350e6bb7f7c3c9c36c0e6f2a1da" + integrity sha512-eailUYex6fkfaQTev4Oa3mwn0/e3mQU4H8y1WPuImYQESOQDtVrowwUGDSc19evpBbHpKtwM+hw8nLlhIsF+Tw== dependencies: "@swc/counter" "^0.1.3" "@swc/types" "^0.1.9" optionalDependencies: - "@swc/core-darwin-arm64" "1.6.7" - "@swc/core-darwin-x64" "1.6.7" - "@swc/core-linux-arm-gnueabihf" "1.6.7" - "@swc/core-linux-arm64-gnu" "1.6.7" - "@swc/core-linux-arm64-musl" "1.6.7" - "@swc/core-linux-x64-gnu" "1.6.7" - "@swc/core-linux-x64-musl" "1.6.7" - "@swc/core-win32-arm64-msvc" "1.6.7" - "@swc/core-win32-ia32-msvc" "1.6.7" - "@swc/core-win32-x64-msvc" "1.6.7" + "@swc/core-darwin-arm64" "1.6.13" + "@swc/core-darwin-x64" "1.6.13" + "@swc/core-linux-arm-gnueabihf" "1.6.13" + "@swc/core-linux-arm64-gnu" "1.6.13" + "@swc/core-linux-arm64-musl" "1.6.13" + "@swc/core-linux-x64-gnu" "1.6.13" + "@swc/core-linux-x64-musl" "1.6.13" + "@swc/core-win32-arm64-msvc" "1.6.13" + "@swc/core-win32-ia32-msvc" "1.6.13" + "@swc/core-win32-x64-msvc" "1.6.13" "@swc/counter@^0.1.3": version "0.1.3"