From 174d8e94e5276a95bc53295784284ba0d137e60c Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:20:33 +0300 Subject: [PATCH 1/8] feat(webapp): add squad top members section --- .../squads/Members/PrivilegedMemberItem.tsx | 16 +++-- .../src/components/squads/SquadPageHeader.tsx | 60 ++++++++++++++-- packages/shared/src/graphql/squads.ts | 14 ++++ packages/shared/src/lib/query.ts | 1 + packages/webapp/__tests__/SquadFeedPage.tsx | 70 ++++++++++++++++--- 5 files changed, 140 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx b/packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx index 7e464ed8354..316bf22211c 100644 --- a/packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx +++ b/packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx @@ -1,18 +1,22 @@ import type { ReactElement } from 'react'; import React from 'react'; import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; -import type { SourceMember } from '../../../graphql/sources'; -import { ProfileTooltip } from '../../profile/ProfileTooltip'; +import type { SourceMember, SourceMemberRole } from '../../../graphql/sources'; +import { SourceMemberRole as SourceMemberRoleEnum } from '../../../graphql/sources'; import { ProfileLink } from '../../profile/ProfileLink'; +import { ProfileTooltip } from '../../profile/ProfileTooltip'; import UserBadge from '../../UserBadge'; -import { getRoleName } from '../../utilities'; interface PrivilegedMemberItemProps { - member: SourceMember; + user: SourceMember['user']; + badge: string; + role?: SourceMemberRole; } export function PrivilegedMemberItem({ - member: { user, role }, + user, + badge, + role = SourceMemberRoleEnum.Member, }: PrivilegedMemberItemProps): ReactElement { return ( @@ -26,7 +30,7 @@ export function PrivilegedMemberItem({ {user.name} - {getRoleName(role)} + {badge} diff --git a/packages/shared/src/components/squads/SquadPageHeader.tsx b/packages/shared/src/components/squads/SquadPageHeader.tsx index 7b0c61a342d..9c220b54a41 100644 --- a/packages/shared/src/components/squads/SquadPageHeader.tsx +++ b/packages/shared/src/components/squads/SquadPageHeader.tsx @@ -1,11 +1,13 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { subDays } from 'date-fns'; import classNames from 'classnames'; import type { BasicSourceMember, Squad } from '../../graphql/sources'; -import { SourcePermissions } from '../../graphql/sources'; +import { SourceMemberRole, SourcePermissions } from '../../graphql/sources'; import { SquadHeaderBar } from './SquadHeaderBar'; import { SquadImage } from './SquadImage'; -import { FlexCentered, FlexCol } from '../utilities'; +import { FlexCentered, FlexCol, getRoleName } from '../utilities'; import SharePostBar from './SharePostBar'; import { verifyPermission } from '../../graphql/squads'; import { Button, ButtonColor, ButtonVariant } from '../buttons/Button'; @@ -32,6 +34,12 @@ import { } from '../typography/Typography'; import { ClickableText } from '../buttons/ClickableText'; import { SquadStack } from './stack/SquadStack'; +import { gqlClient } from '../../graphql/common'; +import { + TOP_MEMBERS_BY_SQUAD_QUERY, + type TopMembersBySquadData, +} from '../../graphql/squads'; +import { RequestKey, StaleTime } from '../../lib/query'; interface SquadPageHeaderProps { squad: Squad; @@ -61,6 +69,22 @@ export function SquadPageHeader({ const listMax = isMobile ? MAX_VISIBLE_PRIVILEGED_MEMBERS_MOBILE : MAX_VISIBLE_PRIVILEGED_MEMBERS_LAPTOP; + const topMembersSince = useMemo( + () => subDays(new Date(), 30).toISOString(), + [], + ); + const { data: topMembersData } = useQuery({ + queryKey: [RequestKey.TopMembersBySquad, squadId, topMembersSince, listMax], + queryFn: async () => + gqlClient.request(TOP_MEMBERS_BY_SQUAD_QUERY, { + sourceId: squadId, + since: topMembersSince, + limit: listMax, + }), + enabled: !!squadId && !!squad.public, + staleTime: StaleTime.OneHour, + }); + const topMembers = topMembersData?.topMembersBySquad ?? []; return (
{squad.privilegedMembers?.slice(0, listMax).map((member) => ( - + ))} {privilegedLength > listMax && (
+ {topMembers.length > 0 && ( + <> + + Top members + +
+ {topMembers.map((member) => ( + + ))} +
+ + )}
diff --git a/packages/shared/src/graphql/squads.ts b/packages/shared/src/graphql/squads.ts index dfe9afaccaa..666b0e94e58 100644 --- a/packages/shared/src/graphql/squads.ts +++ b/packages/shared/src/graphql/squads.ts @@ -24,6 +24,7 @@ import { RequestKey, StaleTime } from '../lib/query'; import { PrivacyOption } from '../components/squads/settings/SquadPrivacySection'; import type { Author } from './comments'; import { OrganizationMemberRole } from '../features/organizations/types'; +import type { UserShortProfile } from '../lib/user'; type BaseSquadForm = Pick< Squad, @@ -289,6 +290,19 @@ export const SQUAD_ANALYTICS_HISTORY_QUERY = gql` } `; +export const TOP_MEMBERS_BY_SQUAD_QUERY = gql` + query TopMembersBySquad($sourceId: ID!, $since: DateTime!, $limit: Int) { + topMembersBySquad(sourceId: $sourceId, since: $since, limit: $limit) { + ...UserShortInfo + } + } + ${USER_SHORT_INFO_FRAGMENT} +`; + +export interface TopMembersBySquadData { + topMembersBySquad: UserShortProfile[]; +} + export const SQUAD_STATIC_FIELDS_QUERY = gql` query Source($handle: ID!) { source(id: $handle) { diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 386d9e3d68c..297343b290c 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -121,6 +121,7 @@ export enum RequestKey { Squad = 'squad', SquadPostRequests = 'squad_post_requests', SquadMembers = 'squad_members', + TopMembersBySquad = 'top_members_by_squad', Search = 'search', SearchHistory = 'searchHistory', ReadingHistory = 'readingHistory', diff --git a/packages/webapp/__tests__/SquadFeedPage.tsx b/packages/webapp/__tests__/SquadFeedPage.tsx index df4ca53aa9e..a4548f5f280 100644 --- a/packages/webapp/__tests__/SquadFeedPage.tsx +++ b/packages/webapp/__tests__/SquadFeedPage.tsx @@ -14,6 +14,7 @@ import ad from '@dailydotdev/shared/__tests__/fixture/ad'; import defaultUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; import defaultFeedPage from '@dailydotdev/shared/__tests__/fixture/feed'; import type { + GraphQLRequest, GraphQLResult, MockedGraphQLResponse, } from '@dailydotdev/shared/__tests__/helpers/graphql'; @@ -31,18 +32,22 @@ import type { BasicSourceMembersData, SquadData, SquadEdgesData, + TopMembersBySquadData, } from '@dailydotdev/shared/src/graphql/squads'; import { BASIC_SQUAD_MEMBERS_QUERY, SQUAD_MEMBERS_QUERY, SQUAD_QUERY, + TOP_MEMBERS_BY_SQUAD_QUERY, } from '@dailydotdev/shared/src/graphql/squads'; -import type { Squad } from '@dailydotdev/shared/src/graphql/sources'; import { + type SourceMember, + type Squad, SourceMemberRole, SourcePermissions, } from '@dailydotdev/shared/src/graphql/sources'; -import { BootApp } from '@dailydotdev/shared/src/lib/boot'; +import { MAX_VISIBLE_PRIVILEGED_MEMBERS_LAPTOP } from '@dailydotdev/shared/src/lib/config'; +import { subDays } from 'date-fns'; import { ActionType, COMPLETE_ACTION_MUTATION, @@ -58,6 +63,7 @@ const defaultSquad: Squad = { ...generateTestSquad(), public: false, }; +const defaultCurrentMember = defaultSquad.currentMember as SourceMember; let requestedSquad: Partial = {}; jest.mock('next/router', () => ({ @@ -81,7 +87,7 @@ beforeEach(() => { const createFeedMock = ( page = defaultFeedPage, query: string = SOURCE_FEED_QUERY, - variables: unknown = { + variables: GraphQLRequest['variables'] = { first: 7, after: '', loggedIn: true, @@ -131,7 +137,7 @@ Object.assign(navigator, { const createSourceMembersMock = ( result = generateMembersResult(), - variables: unknown = { id: defaultSquad.id, first: 5 }, + variables: GraphQLRequest['variables'] = { id: defaultSquad.id, first: 5 }, ): MockedGraphQLResponse => ({ request: { query: SQUAD_MEMBERS_QUERY, variables }, result: { data: result }, @@ -139,14 +145,14 @@ const createSourceMembersMock = ( const createBasicSourceMembersMock = ( result = generateBasicMembersResult(), - variables: unknown = { id: defaultSquad.id, first: 5 }, + variables: GraphQLRequest['variables'] = { id: defaultSquad.id, first: 5 }, ): MockedGraphQLResponse => ({ request: { query: BASIC_SQUAD_MEMBERS_QUERY, variables }, result: { data: result }, }); const createContentPreferenceStatusMock = ( - id: string = defaultSquad.id, + id = defaultSquad.id ?? '', entity: ContentPreferenceType = ContentPreferenceType.Source, ): MockedGraphQLResponse => ({ request: { @@ -160,6 +166,30 @@ const createContentPreferenceStatusMock = ( }, }); +const createTopMembersBySquadMock = ( + result: TopMembersBySquadData = { + topMembersBySquad: [ + { + id: 'u2', + name: 'Top Member', + image: 'https://daily.dev/top-member.png', + username: 'topmember', + permalink: '/topmember', + createdAt: new Date().toISOString(), + reputation: 42, + }, + ], + }, + variables: GraphQLRequest['variables'] = { + sourceId: defaultSquad.id, + since: subDays(new Date(), 30).toISOString(), + limit: MAX_VISIBLE_PRIVILEGED_MEMBERS_LAPTOP, + }, +): MockedGraphQLResponse => ({ + request: { query: TOP_MEMBERS_BY_SQUAD_QUERY, variables }, + result: { data: result }, +}); + let client: QueryClient; const renderComponent = ( @@ -182,7 +212,6 @@ const renderComponent = ( client={client} auth={{ user, squads }} notification={{ - app: BootApp.Webapp, isNotificationsReady: true, unreadCount: 0, }} @@ -190,7 +219,9 @@ const renderComponent = ( {SquadPage.getLayout( , {}, - SquadPage.layoutProps, + SquadPage.layoutProps as unknown as Parameters< + typeof SquadPage.getLayout + >[2], )} , ); @@ -242,6 +273,22 @@ describe('squad page header', () => { const alt = `${defaultSquad.handle}'s logo`; await screen.findByAltText(alt); }); + + it('should show top members below moderated by for public squads', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-04-01T12:00:00.000Z')); + + renderComponent(defaultSquad.handle, [ + createSourceMock(defaultSquad.handle, { public: true }), + createFeedMock(), + createBasicSourceMembersMock(), + createTopMembersBySquadMock(), + ]); + + expect(await screen.findByText('Top members')).toBeInTheDocument(); + expect(screen.getByText('Top Member')).toBeInTheDocument(); + + jest.useRealTimers(); + }); }); Object.assign(navigator, { @@ -323,7 +370,7 @@ describe('squad header bar', () => { it('should copy invitation link', async () => { requestedSquad.public = false; requestedSquad.currentMember = { - ...defaultSquad.currentMember, + ...defaultCurrentMember, permissions: [SourcePermissions.Invite], }; renderComponent(); @@ -348,7 +395,7 @@ describe('squad header bar', () => { it('should copy invitation link for public squad', async () => { requestedSquad.public = true; requestedSquad.currentMember = { - ...defaultSquad.currentMember, + ...defaultCurrentMember, permissions: [SourcePermissions.Invite], }; renderComponent(); @@ -372,7 +419,7 @@ describe('squad header bar', () => { it('should not copy invitation link when member does not have invite permission', async () => { requestedSquad.currentMember = { - ...defaultSquad.currentMember, + ...defaultCurrentMember, permissions: [], }; renderComponent(); @@ -460,6 +507,7 @@ describe('squad members modal', () => { it('should show all blocked members of the squad when privileged', async () => { requestedSquad.currentMember = { + ...defaultCurrentMember, role: SourceMemberRole.Admin, permissions: [SourcePermissions.ViewBlockedMembers], }; From f2a30cf7001916cb6e3786f4f85a3c547c34a9cb Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:42:17 +0300 Subject: [PATCH 2/8] feat(webapp): hydrate squad top members --- .../shared/src/components/modals/common.tsx | 7 +++ .../src/components/modals/common/types.ts | 1 + .../modals/squads/PrivilegedMembersModal.tsx | 44 +++++---------- .../modals/squads/SquadUsersModal.tsx | 54 +++++++++++++++++++ .../modals/squads/TopMembersModal.tsx | 25 +++++++++ .../squads/Members/PrivilegedMemberItem.tsx | 10 ++-- .../src/components/squads/SquadPageHeader.tsx | 45 +++++++--------- packages/shared/src/graphql/sources.ts | 1 + packages/shared/src/graphql/squads.ts | 35 +++++++++++- packages/shared/src/lib/query.ts | 1 - packages/webapp/__tests__/SquadFeedPage.tsx | 44 ++++++++++++++- 11 files changed, 200 insertions(+), 67 deletions(-) create mode 100644 packages/shared/src/components/modals/squads/SquadUsersModal.tsx create mode 100644 packages/shared/src/components/modals/squads/TopMembersModal.tsx diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 5e890583de3..d7fdd4f8e2c 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -120,6 +120,12 @@ const PrivilegedMemberModal = dynamic( /* webpackChunkName: "privilegedMembersModal" */ './squads/PrivilegedMembersModal' ), ); +const TopMembersModal = dynamic( + () => + import( + /* webpackChunkName: "topMembersModal" */ './squads/TopMembersModal' + ), +); const BookmarkReminderModal = dynamic( () => @@ -481,6 +487,7 @@ export const modals = { [LazyModal.MarketingCta]: MarketingCtaModal, [LazyModal.Share]: ShareModal, [LazyModal.PrivilegedMembers]: PrivilegedMemberModal, + [LazyModal.TopMembers]: TopMembersModal, [LazyModal.BookmarkReminder]: BookmarkReminderModal, [LazyModal.RecoverStreak]: StreakRecoverModal, [LazyModal.SlackIntegration]: SlackIntegrationModal, diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 5dc2743d835..c5dd99b180b 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -46,6 +46,7 @@ export enum LazyModal { MarketingCta = 'marketingCta', Share = 'share', PrivilegedMembers = 'privilegedMembers', + TopMembers = 'topMembers', BookmarkReminder = 'bookmarkReminder', SlackIntegration = 'slackIntegration', ReportSource = 'reportSource', diff --git a/packages/shared/src/components/modals/squads/PrivilegedMembersModal.tsx b/packages/shared/src/components/modals/squads/PrivilegedMembersModal.tsx index 598e372de5e..cfa36854b45 100644 --- a/packages/shared/src/components/modals/squads/PrivilegedMembersModal.tsx +++ b/packages/shared/src/components/modals/squads/PrivilegedMembersModal.tsx @@ -1,16 +1,8 @@ import type { ReactElement } from 'react'; import React from 'react'; -import Link from '../../utilities/Link'; import type { ModalProps } from '../common/Modal'; -import { Modal } from '../common/Modal'; import type { Source } from '../../../graphql/sources'; -import { UserShortInfo } from '../../profile/UserShortInfo'; -import { Origin } from '../../../lib/log'; -import { useSquad } from '../../../hooks'; - -import { generateQueryKey, RequestKey } from '../../../lib/query'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { useSourceContentPreferenceMutationSubscription } from '../../../hooks/contentPreference/useSourceContentPreferenceMutationSubscription'; +import { SquadUsersModal } from './SquadUsersModal'; export interface PrivilegedMembersModalProps extends Omit { @@ -21,30 +13,18 @@ function PrivilegedMembersModal({ source, ...props }: PrivilegedMembersModalProps): ReactElement { - const { user: loggedUser } = useAuthContext(); - const { squad } = useSquad({ handle: source.handle }); - - useSourceContentPreferenceMutationSubscription({ - queryKey: generateQueryKey(RequestKey.Squad, loggedUser, source.handle), - }); - return ( - - - - {squad?.privilegedMembers?.map(({ user, role }) => ( - - - - ))} - - + + squad?.privilegedMembers?.map(({ user, role }) => ({ + ...user, + role, + })) ?? [] + } + /> ); } diff --git a/packages/shared/src/components/modals/squads/SquadUsersModal.tsx b/packages/shared/src/components/modals/squads/SquadUsersModal.tsx new file mode 100644 index 00000000000..b79c23fb7bf --- /dev/null +++ b/packages/shared/src/components/modals/squads/SquadUsersModal.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { UserShortProfile } from '../../../lib/user'; +import type { Source, SourceMemberRole, Squad } from '../../../graphql/sources'; +import { UserShortInfo } from '../../profile/UserShortInfo'; +import type { ModalProps } from '../common/Modal'; +import { Modal } from '../common/Modal'; +import { Origin } from '../../../lib/log'; +import { useSquad } from '../../../hooks'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useSourceContentPreferenceMutationSubscription } from '../../../hooks/contentPreference/useSourceContentPreferenceMutationSubscription'; +import { generateQueryKey, RequestKey } from '../../../lib/query'; + +export interface SquadUsersModalProps extends Omit { + source: Pick; + title: string; + getUsers: ( + squad: Squad | undefined, + ) => Array; +} + +export function SquadUsersModal({ + source, + title, + getUsers, + ...props +}: SquadUsersModalProps): ReactElement { + const { user: loggedUser } = useAuthContext(); + const { squad } = useSquad({ handle: source.handle }); + + useSourceContentPreferenceMutationSubscription({ + queryKey: generateQueryKey(RequestKey.Squad, loggedUser, source.handle), + }); + + const users = getUsers(squad); + + return ( + + + + {users.map((user) => ( + + ))} + + + ); +} diff --git a/packages/shared/src/components/modals/squads/TopMembersModal.tsx b/packages/shared/src/components/modals/squads/TopMembersModal.tsx new file mode 100644 index 00000000000..13f73aa99db --- /dev/null +++ b/packages/shared/src/components/modals/squads/TopMembersModal.tsx @@ -0,0 +1,25 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { ModalProps } from '../common/Modal'; +import type { Source } from '../../../graphql/sources'; +import { SquadUsersModal } from './SquadUsersModal'; + +export interface TopMembersModalProps extends Omit { + source: Pick; +} + +function TopMembersModal({ + source, + ...props +}: TopMembersModalProps): ReactElement { + return ( + squad?.topMembers ?? []} + /> + ); +} + +export default TopMembersModal; diff --git a/packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx b/packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx index 316bf22211c..c049f111981 100644 --- a/packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx +++ b/packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx @@ -9,7 +9,7 @@ import UserBadge from '../../UserBadge'; interface PrivilegedMemberItemProps { user: SourceMember['user']; - badge: string; + badge?: string; role?: SourceMemberRole; } @@ -29,9 +29,11 @@ export function PrivilegedMemberItem({ {user.name} - - {badge} - + {badge && ( + + {badge} + + )}
diff --git a/packages/shared/src/components/squads/SquadPageHeader.tsx b/packages/shared/src/components/squads/SquadPageHeader.tsx index 9c220b54a41..f9eb68882a4 100644 --- a/packages/shared/src/components/squads/SquadPageHeader.tsx +++ b/packages/shared/src/components/squads/SquadPageHeader.tsx @@ -1,7 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { subDays } from 'date-fns'; +import React from 'react'; import classNames from 'classnames'; import type { BasicSourceMember, Squad } from '../../graphql/sources'; import { SourceMemberRole, SourcePermissions } from '../../graphql/sources'; @@ -34,12 +32,6 @@ import { } from '../typography/Typography'; import { ClickableText } from '../buttons/ClickableText'; import { SquadStack } from './stack/SquadStack'; -import { gqlClient } from '../../graphql/common'; -import { - TOP_MEMBERS_BY_SQUAD_QUERY, - type TopMembersBySquadData, -} from '../../graphql/squads'; -import { RequestKey, StaleTime } from '../../lib/query'; interface SquadPageHeaderProps { squad: Squad; @@ -65,26 +57,12 @@ export function SquadPageHeader({ ? formatMonthYearOnly(squad.createdAt) : null; const privilegedLength = squad.privilegedMembers?.length || 0; + const topMembers = squad.topMembers ?? []; + const topMembersLength = topMembers.length; const isMobile = useViewSize(ViewSize.MobileL); const listMax = isMobile ? MAX_VISIBLE_PRIVILEGED_MEMBERS_MOBILE : MAX_VISIBLE_PRIVILEGED_MEMBERS_LAPTOP; - const topMembersSince = useMemo( - () => subDays(new Date(), 30).toISOString(), - [], - ); - const { data: topMembersData } = useQuery({ - queryKey: [RequestKey.TopMembersBySquad, squadId, topMembersSince, listMax], - queryFn: async () => - gqlClient.request(TOP_MEMBERS_BY_SQUAD_QUERY, { - sourceId: squadId, - since: topMembersSince, - limit: listMax, - }), - enabled: !!squadId && !!squad.public, - staleTime: StaleTime.OneHour, - }); - const topMembers = topMembersData?.topMembersBySquad ?? []; return (
- {topMembers.map((member) => ( + {topMembers.slice(0, listMax).map((member) => ( ))} + {topMembersLength > listMax && ( + + )}
)} diff --git a/packages/shared/src/graphql/sources.ts b/packages/shared/src/graphql/sources.ts index 04bdd038569..d3ba47e3594 100644 --- a/packages/shared/src/graphql/sources.ts +++ b/packages/shared/src/graphql/sources.ts @@ -79,6 +79,7 @@ export interface Squad extends Source { public: boolean; type: SourceType.Squad; members?: Connection; + topMembers?: UserShortProfile[]; membersCount: number; description: string; memberPostingRole: SourceMemberRole; diff --git a/packages/shared/src/graphql/squads.ts b/packages/shared/src/graphql/squads.ts index 666b0e94e58..83c74da16d5 100644 --- a/packages/shared/src/graphql/squads.ts +++ b/packages/shared/src/graphql/squads.ts @@ -1,4 +1,5 @@ import { gql } from 'graphql-request'; +import { subDays } from 'date-fns'; import { SHARED_POST_INFO_FRAGMENT, SOURCE_BASE_FRAGMENT, @@ -303,6 +304,27 @@ export interface TopMembersBySquadData { topMembersBySquad: UserShortProfile[]; } +export const MAX_TOP_MEMBERS_BY_SQUAD = 10; + +const getTopMembersBySquadSince = (): string => + subDays(new Date(), 30).toISOString(); + +export async function getTopMembersBySquad( + sourceId: string, + limit = MAX_TOP_MEMBERS_BY_SQUAD, +): Promise { + const res = await gqlClient.request( + TOP_MEMBERS_BY_SQUAD_QUERY, + { + sourceId, + since: getTopMembersBySquadSince(), + limit, + }, + ); + + return res.topMembersBySquad; +} + export const SQUAD_STATIC_FIELDS_QUERY = gql` query Source($handle: ID!) { source(id: $handle) { @@ -500,7 +522,18 @@ export async function getSquad(handle: string): Promise { const res = await gqlClient.request(SQUAD_QUERY, { handle: handle.toLowerCase(), }); - return res.source; + const squad = res.source; + + if (!squad.public || !squad.id) { + return squad; + } + + const topMembers = await getTopMembersBySquad(squad.id); + + return { + ...squad, + topMembers, + }; } export const squadAnalyticsQueryOptions = ({ diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 297343b290c..386d9e3d68c 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -121,7 +121,6 @@ export enum RequestKey { Squad = 'squad', SquadPostRequests = 'squad_post_requests', SquadMembers = 'squad_members', - TopMembersBySquad = 'top_members_by_squad', Search = 'search', SearchHistory = 'searchHistory', ReadingHistory = 'readingHistory', diff --git a/packages/webapp/__tests__/SquadFeedPage.tsx b/packages/webapp/__tests__/SquadFeedPage.tsx index a4548f5f280..c1a8c6d8eba 100644 --- a/packages/webapp/__tests__/SquadFeedPage.tsx +++ b/packages/webapp/__tests__/SquadFeedPage.tsx @@ -36,6 +36,7 @@ import type { } from '@dailydotdev/shared/src/graphql/squads'; import { BASIC_SQUAD_MEMBERS_QUERY, + MAX_TOP_MEMBERS_BY_SQUAD, SQUAD_MEMBERS_QUERY, SQUAD_QUERY, TOP_MEMBERS_BY_SQUAD_QUERY, @@ -183,7 +184,7 @@ const createTopMembersBySquadMock = ( variables: GraphQLRequest['variables'] = { sourceId: defaultSquad.id, since: subDays(new Date(), 30).toISOString(), - limit: MAX_VISIBLE_PRIVILEGED_MEMBERS_LAPTOP, + limit: MAX_TOP_MEMBERS_BY_SQUAD, }, ): MockedGraphQLResponse => ({ request: { query: TOP_MEMBERS_BY_SQUAD_QUERY, variables }, @@ -198,6 +199,7 @@ const renderComponent = ( createSourceMock(handle), createFeedMock(), createBasicSourceMembersMock(), + createTopMembersBySquadMock(), ], user: LoggedUser = defaultUser, squads = [defaultSquad], @@ -289,6 +291,38 @@ describe('squad page header', () => { jest.useRealTimers(); }); + + it('should show top members overflow in a modal', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-04-01T12:00:00.000Z')); + + renderComponent(defaultSquad.handle, [ + createSourceMock(defaultSquad.handle, { public: true }), + createFeedMock(), + createBasicSourceMembersMock(), + createTopMembersBySquadMock({ + topMembersBySquad: Array.from({ length: 4 }, (_, index) => ({ + id: `u${index + 2}`, + name: `Top Member ${index + 1}`, + image: `https://daily.dev/top-member-${index + 1}.png`, + username: `topmember${index + 1}`, + permalink: `/topmember${index + 1}`, + createdAt: new Date().toISOString(), + reputation: 42 + index, + })), + }), + ]); + + expect(await screen.findByText('Top Member 1')).toBeInTheDocument(); + expect(screen.queryByText('Top Member 4')).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByText(`+${4 - MAX_VISIBLE_PRIVILEGED_MEMBERS_LAPTOP}`), + ); + + expect(await screen.findByText('Top Member 4')).toBeInTheDocument(); + + jest.useRealTimers(); + }); }); Object.assign(navigator, { @@ -398,7 +432,12 @@ describe('squad header bar', () => { ...defaultCurrentMember, permissions: [SourcePermissions.Invite], }; - renderComponent(); + renderComponent(defaultSquad.handle, [ + createSourceMock(), + createFeedMock(), + createBasicSourceMembersMock(), + createTopMembersBySquadMock(), + ]); const invite = await screen.findByText('Invitation link'); mockGraphQL({ @@ -437,6 +476,7 @@ describe('squad header bar', () => { createSourceMock(defaultSquad.handle), createFeedMock(), createBasicSourceMembersMock(), + createTopMembersBySquadMock(), createContentPreferenceStatusMock( defaultSquad.id, ContentPreferenceType.Source, From 82a4881ca93f985834cb2b26ba1d075d4b238fb9 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:55:49 +0300 Subject: [PATCH 3/8] feat(webapp): add squad seo user links --- .../webapp/pages/squads/[handle]/index.tsx | 115 +++++++++++++++--- 1 file changed, 101 insertions(+), 14 deletions(-) diff --git a/packages/webapp/pages/squads/[handle]/index.tsx b/packages/webapp/pages/squads/[handle]/index.tsx index 78d45b395a3..79493bb5130 100644 --- a/packages/webapp/pages/squads/[handle]/index.tsx +++ b/packages/webapp/pages/squads/[handle]/index.tsx @@ -17,9 +17,11 @@ import { BaseFeedPage, FeedPageLayoutList, } from '@dailydotdev/shared/src/components/utilities'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; import type { SquadStaticData } from '@dailydotdev/shared/src/graphql/squads'; import { getSquadMembers, + getSquad, getSquadStaticFields, } from '@dailydotdev/shared/src/graphql/squads'; import type { @@ -51,7 +53,10 @@ import { getPathnameWithQuery } from '@dailydotdev/shared/src/lib'; import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; import { usePrivateSourceJoin } from '@dailydotdev/shared/src/hooks/source/usePrivateSourceJoin'; import { GET_REFERRING_USER_QUERY } from '@dailydotdev/shared/src/graphql/users'; -import type { PublicProfile } from '@dailydotdev/shared/src/lib/user'; +import type { + PublicProfile, + UserShortProfile, +} from '@dailydotdev/shared/src/lib/user'; import { ToastSubject, useToastNotification, @@ -156,8 +161,70 @@ interface SourcePageProps extends DynamicSeoProps { initialData?: SquadStaticData; referringUser?: Pick; jsonLd?: string; + seoUsers?: SquadSeoUsers; } +type SquadSeoUser = Pick; + +interface SquadSeoUsers { + privilegedMembers: SquadSeoUser[]; + topMembers: SquadSeoUser[]; +} + +const getSeoSquadUsers = (squad?: Squad): SquadSeoUsers | undefined => { + if (!squad?.public) { + return undefined; + } + + return { + privilegedMembers: + squad.privilegedMembers?.map(({ user }) => ({ + id: user.id, + name: user.name, + permalink: user.permalink, + })) ?? [], + topMembers: + squad.topMembers?.map(({ id, name, permalink }) => ({ + id, + name, + permalink, + })) ?? [], + }; +}; + +const SquadSeoLinks = ({ + seoUsers, +}: { + seoUsers?: SquadSeoUsers; +}): ReactElement | null => { + if (!seoUsers) { + return null; + } + + return ( + <> + {seoUsers.privilegedMembers.length > 0 && ( +
+ {seoUsers.privilegedMembers.map((member) => ( + + Posts by {member.name} + + ))} +
+ )} + {seoUsers.topMembers.length > 0 && ( +
+ {seoUsers.topMembers.map((member) => ( + + Posts by {member.name} + + ))} +
+ )} + + ); +}; + const PageComponent = (props: ProtectedPageProps & { squad: Squad }) => { const { squad, children, ...restProtectedPageProps } = props; @@ -172,6 +239,7 @@ const SquadPage = ({ handle, initialData, jsonLd, + seoUsers, }: SourcePageProps): ReactElement => { const router = useRouter(); const { openModal } = useLazyModal(); @@ -318,18 +386,37 @@ const SquadPage = ({ }, [shouldManageSlack, squad, openModal, router]); const privateSourceJoin = usePrivateSourceJoin(); + const initialSeoUsers = getSeoSquadUsers(initialData as Squad | undefined); + const resolvedSeoUsers = + getSeoSquadUsers(squad) ?? seoUsers ?? initialSeoUsers; + const seoContent = ( + <> + {jsonLd && ( + +