diff --git a/apps/u3/src/components/community/FarcasterMemberItem.tsx b/apps/u3/src/components/community/FarcasterMemberItem.tsx new file mode 100644 index 00000000..2f360c01 --- /dev/null +++ b/apps/u3/src/components/community/FarcasterMemberItem.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import useFarcasterFollowAction from '@/hooks/social/farcaster/useFarcasterFollowAction'; +import useFarcasterUserData from '@/hooks/social/farcaster/useFarcasterUserData'; +import { MemberEntity } from '@/services/community/types/community'; +import { SocialPlatform } from '@/services/social/types'; +import MemberItem from './MemberItem'; + +const formatFarcasterUserData = (data) => { + const temp: { + [key: string]: { type: number; value: string }[]; + } = {}; + data.forEach((item) => { + if (temp[item.fid]) { + temp[item.fid].push(item); + } else { + temp[item.fid] = [item]; + } + }); + return temp; +}; +export default function FarcasterMemberItem({ + following, + data, +}: { + following: string[]; + data: MemberEntity; +}) { + const { fid, data: memberData } = data; + const [followPending, setFollowPending] = useState(false); + const [unfollowPending, setUnfollowPending] = useState(false); + const [followed, setFollowed] = useState(following.includes(fid)); + + const { followAction, unfollowAction } = useFarcasterFollowAction(); + + const farcasterUserData = useFarcasterUserData({ + fid, + farcasterUserData: formatFarcasterUserData(memberData), + }); + const avatar = farcasterUserData.pfp; + const name = farcasterUserData.display || farcasterUserData.fid; + const { bio } = farcasterUserData; + const handle = farcasterUserData.userName; + return ( + { + setFollowPending(true); + await followAction(Number(fid)); + setFollowed(true); + setFollowPending(false); + }} + unfollowAction={async () => { + setUnfollowPending(true); + await unfollowAction(Number(fid)); + setFollowed(false); + setUnfollowPending(false); + }} + /> + ); +} diff --git a/apps/u3/src/components/community/MemberItem.tsx b/apps/u3/src/components/community/MemberItem.tsx new file mode 100644 index 00000000..56028da6 --- /dev/null +++ b/apps/u3/src/components/community/MemberItem.tsx @@ -0,0 +1,121 @@ +import { ComponentPropsWithRef, useEffect, useMemo } from 'react'; +import ColorButton from '../common/button/ColorButton'; +import { SocialPlatform } from '@/services/social/types'; +import { useXmtpClient } from '@/contexts/message/XmtpClientCtx'; +import useCanMessage from '@/hooks/message/xmtp/useCanMessage'; +import { + farcasterHandleToBioLinkHandle, + lensHandleToBioLinkHandle, +} from '@/utils/profile/biolink'; +import { cn } from '@/lib/utils'; +import NavigateToProfileLink from '../profile/info/NavigateToProfileLink'; + +export type MemberData = { + handle: string; + avatar: string; + name: string; + address: string; + bio: string; + platforms: SocialPlatform[]; + isFollowed: boolean; +}; +export type MemberItemProps = ComponentPropsWithRef<'div'> & { + data: MemberData; + followPending?: boolean; + unfollowPending?: boolean; + followAction?: () => void; + unfollowAction?: () => void; +}; +export default function MemberItem({ + data, + followPending, + unfollowPending, + followAction, + unfollowAction, + className, + ...props +}: MemberItemProps) { + const { setCanEnableXmtp } = useXmtpClient(); + useEffect(() => { + setCanEnableXmtp(true); + }, []); + + const { handle, avatar, name, address, bio, platforms, isFollowed } = data; + const { canMessage } = useCanMessage(address); + const { setMessageRouteParams } = useXmtpClient(); + + const profileIdentity = useMemo(() => { + if (handle.endsWith('.eth')) return handle; + const firstPlatform = platforms?.[0]; + switch (firstPlatform) { + case SocialPlatform.Lens: + return lensHandleToBioLinkHandle(handle); + case SocialPlatform.Farcaster: + return farcasterHandleToBioLinkHandle(handle); + default: + return ''; + } + }, [handle, platforms]); + + const profileUrl = useMemo(() => { + if (profileIdentity) { + return `/u/${profileIdentity}`; + } + return ''; + }, [profileIdentity]); + return ( +
+ + + + +
+
+ + {name} + +
+ + {handle} + + + {bio} + +
+ { + if (isFollowed) { + unfollowAction?.(); + } else { + followAction?.(); + } + }} + > + {(() => { + if (followPending) { + return 'Following'; + } + if (unfollowPending) { + return 'Unfollowing'; + } + if (isFollowed) { + return 'Following'; + } + return 'Follow'; + })()} + +
+ ); +} diff --git a/apps/u3/src/container/community/CommunityLayout.tsx b/apps/u3/src/container/community/CommunityLayout.tsx index 660f2e59..c78f917f 100644 --- a/apps/u3/src/container/community/CommunityLayout.tsx +++ b/apps/u3/src/container/community/CommunityLayout.tsx @@ -4,13 +4,12 @@ import { cn } from '@/lib/utils'; import CommunityMenu from './CommunityMenu'; import { useFarcasterCtx } from '@/contexts/social/FarcasterCtx'; import Loading from '@/components/common/loading/Loading'; -import useLoadCommunityMembers from '@/hooks/community/useLoadCommunityMembers'; -import useLoadCommunityTopMembers from '@/hooks/community/useLoadCommunityTopMembers'; import { CommunityInfo } from '@/services/community/types/community'; import { fetchCommunity } from '@/services/community/api/community'; import CommunityMobileHeader from './CommunityMobileHeader'; import useJoinCommunityAction from '@/hooks/community/useJoinCommunityAction'; import useBrowsingCommunity from '@/hooks/community/useBrowsingCommunity'; +import useLoadCommunityMembersTotalNum from '@/hooks/community/useLoadCommunityMembersTotalNum'; export default function CommunityLayout() { const { channelId } = useParams(); @@ -56,24 +55,17 @@ export default function CommunityLayout() { }; }, [communityInfo, setBrowsingCommunity, clearBrowsingCommunity]); - // members state - const { - members, - pageInfo: membersPageInfo, - firstLoading: membersFirstLoading, - moreLoading: membersMoreLoading, - loadFirst: loadFirstMembers, - loadMore: loadMoreMembers, - } = useLoadCommunityMembers(channelId); - - const { - members: topMembers, - loading: topMembersLoading, - load: loadTopMembers, - } = useLoadCommunityTopMembers(channelId); - const { joined } = useJoinCommunityAction(communityInfo); + const id = communityInfo?.id; + const { totalNum: totalMembers, loadCommunityMembersTotalNum } = + useLoadCommunityMembersTotalNum(); + useEffect(() => { + if (id) { + loadCommunityMembersTotalNum({ id }); + } + }, [id]); + if (communityLoading) {
@@ -102,6 +94,7 @@ export default function CommunityLayout() { className="min-sm:hidden" communityInfo={communityInfo} channelId={channel?.channel_id} + totalMembers={totalMembers} />
diff --git a/apps/u3/src/container/community/CommunityMobileHeader.tsx b/apps/u3/src/container/community/CommunityMobileHeader.tsx index 3cd97ecd..c6b704fb 100644 --- a/apps/u3/src/container/community/CommunityMobileHeader.tsx +++ b/apps/u3/src/container/community/CommunityMobileHeader.tsx @@ -21,10 +21,12 @@ export default function CommunityMobileHeader({ className, communityInfo, channelId, + totalMembers, ...props }: ComponentPropsWithRef<'div'> & { communityInfo: CommunityInfo; channelId: string; + totalMembers: number; }) { const navigate = useNavigate(); const { mainNavs } = getCommunityNavs(channelId, communityInfo); @@ -65,7 +67,8 @@ export default function CommunityMobileHeader({ value={nav.href} className="hover:bg-[#20262F]" > - {nav.title} + {nav.title}{' '} + {nav.href.includes('/members') && `(${totalMembers})`} ); })} diff --git a/apps/u3/src/container/community/MembersLayout.tsx b/apps/u3/src/container/community/MembersLayout.tsx index 8ac5e167..f1f68763 100644 --- a/apps/u3/src/container/community/MembersLayout.tsx +++ b/apps/u3/src/container/community/MembersLayout.tsx @@ -1,8 +1,10 @@ -import { useEffect } from 'react'; import { useOutletContext } from 'react-router-dom'; import InfiniteScroll from 'react-infinite-scroll-component'; -import { useFarcasterCtx } from 'src/contexts/social/FarcasterCtx'; import Loading from 'src/components/common/loading/Loading'; +import { useEffect } from 'react'; +import useLoadCommunityMembers from '@/hooks/community/useLoadCommunityMembers'; +import FarcasterMemberItem from '@/components/community/FarcasterMemberItem'; +import { useFarcasterCtx } from '@/contexts/social/FarcasterCtx'; export default function MembersLayout() { return ( @@ -10,102 +12,61 @@ export default function MembersLayout() {
-
- -
+ {/*
top members
*/}
); } function TotalMembers() { - const { openFarcasterQR } = useFarcasterCtx(); + const { following } = useFarcasterCtx(); + const { totalMembers, communityInfo } = useOutletContext(); + const id = communityInfo?.id; const { - members, - membersPageInfo, - membersFirstLoading, - membersMoreLoading, - loadFirstMembers, - loadMoreMembers, - } = useOutletContext(); - const membersLen = members.length; + communityMembers, + loadCommunityMembers, + loading: membersLoading, + pageInfo, + } = useLoadCommunityMembers(); + useEffect(() => { - if (membersLen === 0) { - loadFirstMembers(); + if (id) { + loadCommunityMembers({ id }); } - }, [membersLen]); + }, [id]); return ( -
-
Total Members ({membersLen})
+
+
+ Total Members ({totalMembers}) +
- {(membersFirstLoading && ( -
- -
- )) || ( - { - if (membersMoreLoading) return; - loadMoreMembers(); - }} - hasMore={membersPageInfo?.hasNextPage || false} - loader={ -
- -
- } - scrollableTarget="total-members-scroll-wrapper" - > -
- {members.map((member) => { - return ( -
-
-
{member.name}
-
- ); - })} + { + if (!id || membersLoading) return; + loadCommunityMembers({ id }); + }} + hasMore={pageInfo?.hasNextPage || false} + loader={ +
+
-
- )} -
-
- ); -} - -function TopMembers() { - const { openFarcasterQR } = useFarcasterCtx(); - const { topMembers, topMembersLoading, loadTopMembers } = - useOutletContext(); - const membersLen = topMembers.length; - useEffect(() => { - if (membersLen === 0) { - loadTopMembers(); - } - }, [membersLen]); - - return ( -
-
✨ Top 10 Community Stars
-
- {topMembersLoading ? ( -
- -
- ) : ( -
- {topMembers.map((member) => { + } + scrollableTarget="total-members-scroll-wrapper" + > +
+ {communityMembers.map((data) => { return ( -
-
-
{member.name}
-
+ ); })}
- )} +
); diff --git a/apps/u3/src/hooks/community/useLoadCommunityMembers.ts b/apps/u3/src/hooks/community/useLoadCommunityMembers.ts index 05b9a872..e04e0f7b 100644 --- a/apps/u3/src/hooks/community/useLoadCommunityMembers.ts +++ b/apps/u3/src/hooks/community/useLoadCommunityMembers.ts @@ -1,60 +1,76 @@ -import { useCallback, useState } from 'react'; -import { - CommunityMembersPageInfo, - fetchCommunityMembers, -} from '@/services/community/api/community'; +import { useCallback, useRef, useState } from 'react'; +import { toast } from 'react-toastify'; +import { fetchCommunityMembers } from '@/services/community/api/community'; import { MemberEntity } from '@/services/community/types/community'; -const PAGE_SIZE = 16; -export default function useLoadCommunityMembers(communityId: string | number) { - const [members, setMembers] = useState([]); - const [pageInfo, setPageInfo] = useState({ - hasNextPage: true, +const PAGE_SIZE = 30; +export const getDefaultCommunityMembersCachedData = () => { + return { + data: [], + pageInfo: { + hasNextPage: true, + }, + nextPageNumber: 1, + }; +}; + +type CommunityMembersCachedData = ReturnType< + typeof getDefaultCommunityMembersCachedData +>; + +type CommunityMembersOpts = { + cachedDataRefValue?: CommunityMembersCachedData; +}; + +export default function useLoadCommunityMembers(opts?: CommunityMembersOpts) { + const { cachedDataRefValue } = opts || {}; + const defaultCachedDataRef = useRef({ + ...getDefaultCommunityMembersCachedData(), }); - const [firstLoading, setFirstLoading] = useState(false); - const [moreLoading, setMoreLoading] = useState(false); + const cachedData = cachedDataRefValue || defaultCachedDataRef.current; - const loadFirst = useCallback(async () => { - setFirstLoading(true); - setMembers([]); - try { - const res = await fetchCommunityMembers(communityId, { - pageSize: PAGE_SIZE, - }); - const data = res.data?.data; - setPageInfo(data.pageInfo); - setMembers(data.members); - } catch (error) { - console.error(error); - } finally { - setFirstLoading(false); - } - }, [communityId]); + const [communityMembers, setCommunityMembers] = useState>( + cachedData.data + ); + const [loading, setLoading] = useState(false); + const [pageInfo, setPageInfo] = useState(cachedData.pageInfo); + + const loadCommunityMembers = useCallback(async (params?: { id?: string }) => { + const { id } = params || {}; - const loadMore = useCallback(async () => { - if (firstLoading || moreLoading) return; - setMoreLoading(true); + if (cachedData.pageInfo.hasNextPage === false) { + return; + } + setLoading(true); try { - const res = await fetchCommunityMembers(communityId, { + const res = await fetchCommunityMembers(id, { pageSize: PAGE_SIZE, - endCursor: pageInfo.endCursor, + pageNumber: cachedData.nextPageNumber, }); - const data = res.data?.data; - setPageInfo(data.pageInfo); - setMembers((prev) => [...prev, ...data.members]); + const { code, msg, data } = res.data; + if (code === 0) { + const newCommunityMembers = data?.members || []; + const hasNextPage = newCommunityMembers.length >= PAGE_SIZE; + setCommunityMembers((prev) => [...prev, ...newCommunityMembers]); + setPageInfo({ hasNextPage }); + cachedData.data = cachedData.data.concat(newCommunityMembers); + cachedData.nextPageNumber += 1; + cachedData.pageInfo.hasNextPage = hasNextPage; + } else { + throw new Error(msg); + } } catch (error) { console.error(error); + toast.error(`Load community members failed: ${error.message}`); } finally { - setMoreLoading(false); + setLoading(false); } - }, [communityId, pageInfo, moreLoading, firstLoading]); + }, []); return { - members, + loading, + communityMembers, + loadCommunityMembers, pageInfo, - firstLoading, - moreLoading, - loadFirst, - loadMore, }; } diff --git a/apps/u3/src/hooks/community/useLoadCommunityMembersTotalNum.ts b/apps/u3/src/hooks/community/useLoadCommunityMembersTotalNum.ts new file mode 100644 index 00000000..da7c5a3e --- /dev/null +++ b/apps/u3/src/hooks/community/useLoadCommunityMembersTotalNum.ts @@ -0,0 +1,42 @@ +import { useCallback, useState } from 'react'; +import { toast } from 'react-toastify'; +import { fetchCommunityMembers } from '@/services/community/api/community'; + +export default function useLoadCommunityMembersTotalNum() { + const [totalNum, setTotalNum] = useState(0); + const [loading, setLoading] = useState(false); + + const loadCommunityMembersTotalNum = useCallback( + async (params?: { id?: string | number }) => { + const { id } = params || {}; + setLoading(true); + try { + const res = await fetchCommunityMembers(id, { + pageSize: 0, + pageNumber: 0, + }); + const { code, msg, data } = res.data; + if (code === 0) { + const num = data?.totalNum || 0; + setTotalNum(num); + } else { + throw new Error(msg); + } + } catch (error) { + console.error(error); + toast.error( + `Load community members total num failed: ${error.message}` + ); + } finally { + setLoading(false); + } + }, + [] + ); + + return { + loading, + totalNum, + loadCommunityMembersTotalNum, + }; +} diff --git a/apps/u3/src/services/community/api/community.ts b/apps/u3/src/services/community/api/community.ts index 5e2dc0d4..99026466 100644 --- a/apps/u3/src/services/community/api/community.ts +++ b/apps/u3/src/services/community/api/community.ts @@ -131,24 +131,21 @@ export function fetchCommunity( }); } -export type CommunityMembersPageInfo = { - endCursor?: number; - hasNextPage: boolean; -}; export type CommunityMembersParams = { pageSize?: number; - endCursor?: number; + pageNumber?: number; + type?: string; }; -export type CommunityMembersResponse = { +export type CommunityMembersData = { members: Array; - pageInfo: CommunityMembersPageInfo; + totalNum: number; }; export function fetchCommunityMembers( id: string | number, params: CommunityMembersParams -): RequestPromise> { +): RequestPromise> { return request({ - url: `/community/${id}/members`, + url: `/topics/${id}/members`, method: 'get', params, }); @@ -159,7 +156,7 @@ export function fetchCommunityTopMembers( id: string | number ): RequestPromise> { return request({ - url: `/community/${id}/members/top`, + url: `/topics/${id}/members/top`, method: 'get', }); } diff --git a/apps/u3/src/services/community/types/community.ts b/apps/u3/src/services/community/types/community.ts index 64fc94ad..48e0a1cd 100644 --- a/apps/u3/src/services/community/types/community.ts +++ b/apps/u3/src/services/community/types/community.ts @@ -40,11 +40,8 @@ export type CommunityStatistics = { }; export type MemberEntity = { - id: number; - name: string; - avatar: string; - address: string; - bio: string; + fid: string; + data: Array<{ fid: string; type: number; value: string }>; }; export type CommunityInfo = CommunityEntity & CommunityStatistics; diff --git a/apps/u3/src/utils/community/getCommunityNavs.ts b/apps/u3/src/utils/community/getCommunityNavs.ts index 4b44235d..1406c9ea 100644 --- a/apps/u3/src/utils/community/getCommunityNavs.ts +++ b/apps/u3/src/utils/community/getCommunityNavs.ts @@ -16,7 +16,7 @@ export default function getCommunityNavs( const mainNavs = [ { title: 'Posts', href: getCommunityPostsPath(channelId) }, { title: 'Links', href: getCommunityLinksPath(channelId) }, - // { title: 'Members', href: `/community/${channelId}/members` }, + { title: 'Members', href: `/community/${channelId}/members` }, ]; const nft = nfts?.length > 0 ? nfts[0] : null; if (nft) {