diff --git a/packages/shared/src/components/Feed.module.css b/packages/shared/src/components/Feed.module.css index 89c074beb5..354c9955d5 100644 --- a/packages/shared/src/components/Feed.module.css +++ b/packages/shared/src/components/Feed.module.css @@ -6,17 +6,32 @@ max-width: calc(20rem * var(--num-cards) + var(--feed-gap) * (var(--num-cards) - 1)); } } +.containerV2 { + max-width: 100%; + + @screen desktopL { + max-width: calc(21.25rem * var(--num-cards) + var(--feed-gap) * (var(--num-cards) - 1)); + } +} .cards { @screen mobileL { max-width: calc(20rem * var(--num-cards) + var(--feed-gap) * (var(--num-cards) - 1)); } } +.cardsV2 { + max-width: 100%; + + @screen desktopL { + max-width: calc(21.25rem * var(--num-cards) + var(--feed-gap) * (var(--num-cards) - 1)); + } +} .feedRow { padding-bottom: var(--feed-gap); } -.cards .feedRow { +.cards .feedRow, +.cardsV2 .feedRow { grid-template-columns: repeat(var(--num-cards), 1fr); grid-gap: var(--feed-gap); diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index fc5e8a3adb..b63b5cde87 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -14,6 +14,7 @@ import type { PostItem, UseFeedOptionalParams } from '../hooks/useFeed'; import useFeed, { isBoostedPostAd } from '../hooks/useFeed'; import type { Post } from '../graphql/posts'; import { PostType } from '../graphql/posts'; +import type { Spaciness } from '../graphql/settings'; import AuthContext from '../contexts/AuthContext'; import FeedContext from '../contexts/FeedContext'; import SettingsContext from '../contexts/SettingsContext'; @@ -59,6 +60,7 @@ import { FeedCardContext } from '../features/posts/FeedCardContext'; import { briefCardFeedFeature, briefFeedEntrypointPage, + featureFeedLayoutV2, } from '../lib/featureManagement'; import type { AwardProps } from '../graphql/njord'; import { getProductsQueryOptions } from '../graphql/njord'; @@ -188,8 +190,15 @@ export default function Feed({ const { user } = useContext(AuthContext); const { isFallback, query: routerQuery } = useRouter(); const { openNewTab, spaciness, loadedSettings } = useContext(SettingsContext); + const { value: isFeedLayoutV2 } = useConditionalFeature({ + feature: featureFeedLayoutV2, + shouldEvaluate: true, + }); const { isListMode } = useFeedLayout(); - const numCards = currentSettings.numCards[spaciness ?? 'eco']; + const effectiveSpaciness: Spaciness = isFeedLayoutV2 + ? 'eco' + : spaciness ?? 'eco'; + const numCards = currentSettings.numCards[effectiveSpaciness]; const isSquadFeed = feedName === OtherFeedPage.Squad; const { shouldUseListFeedLayout } = useFeedLayout(); const trackedFeedFinish = useRef(false); diff --git a/packages/shared/src/components/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index ea3ba62fe4..3bc6207829 100644 --- a/packages/shared/src/components/feeds/FeedContainer.tsx +++ b/packages/shared/src/components/feeds/FeedContainer.tsx @@ -8,6 +8,7 @@ import FeedContext from '../../contexts/FeedContext'; import styles from '../Feed.module.css'; import type { FeedPagesWithMobileLayoutType } from '../../hooks'; import { + useConditionalFeature, useFeedLayout, ToastSubject, useToastNotification, @@ -16,6 +17,7 @@ import { useFeeds, useBoot, } from '../../hooks'; +import { featureFeedLayoutV2 } from '../../lib/featureManagement'; import ConditionalWrapper from '../ConditionalWrapper'; import { useActiveFeedNameContext } from '../../contexts'; import { SharedFeedPage } from '../utilities'; @@ -69,24 +71,34 @@ const cardListClass = { export const getFeedGapPx = { 'gap-2': 8, 'gap-3': 12, + 'gap-4': 16, 'gap-5': 20, 'gap-8': 32, 'gap-12': 48, 'gap-14': 56, }; +/** + * Returns the appropriate gap class based on layout mode and spaciness. + * @param defaultGridGap - Optional override for grid gap (used by feature flags like feed_layout_v2) + */ export const gapClass = ({ isList, isFeedLayoutList, space, + defaultGridGap, }: { isList: boolean; isFeedLayoutList: boolean; space: Spaciness; + defaultGridGap?: string; }): string => { if (isFeedLayoutList) { return ''; } + if (defaultGridGap) { + return defaultGridGap; + } return isList ? listGaps[space] ?? 'gap-2' : gridGaps[space] ?? 'gap-8'; }; @@ -150,6 +162,10 @@ export const FeedContainer = ({ const currentSettings = useContext(FeedContext); const { subject } = useToastNotification(); const { spaciness, loadedSettings } = useContext(SettingsContext); + const { value: isFeedLayoutV2 } = useConditionalFeature({ + feature: featureFeedLayoutV2, + shouldEvaluate: true, + }); const { shouldUseListFeedLayout, isListMode } = useFeedLayout(); const isLaptop = useViewSize(ViewSize.Laptop); const { feedName } = useActiveFeedNameContext(); @@ -157,24 +173,29 @@ export const FeedContainer = ({ feedName, }); const router = useRouter(); - const numCards = currentSettings.numCards[spaciness ?? 'eco']; + const effectiveSpaciness: Spaciness = isFeedLayoutV2 + ? 'eco' + : spaciness ?? 'eco'; + const numCards = currentSettings.numCards[effectiveSpaciness]; const isList = (isHorizontal || isListMode) && !shouldUseListFeedLayout ? false : (isListMode && numCards > 1) || shouldUseListFeedLayout; + const v2GridGap = isFeedLayoutV2 ? 'gap-4' : undefined; const feedGapPx = getFeedGapPx[ gapClass({ isList, isFeedLayoutList: shouldUseListFeedLayout, - space: spaciness, + space: effectiveSpaciness, + defaultGridGap: v2GridGap, }) ]; const style = { '--num-cards': isHorizontal && isListMode && numCards >= 2 ? 2 : numCards, '--feed-gap': `${feedGapPx / 16}rem`, } as CSSProperties; - const cardContainerStyle = { ...getStyle(isList, spaciness) }; + const cardContainerStyle = { ...getStyle(isList, effectiveSpaciness) }; const isFinder = router.pathname === '/search/posts'; const isSearch = showSearch && !isFinder; @@ -222,7 +243,7 @@ export const FeedContainer = ({
@@ -264,7 +285,7 @@ export const FeedContainer = ({ className={classNames( 'relative mx-auto w-full', styles.feed, - !isList && styles.cards, + !isList && (isFeedLayoutV2 ? styles.cardsV2 : styles.cards), )} style={cardContainerStyle} aria-live={subject === ToastSubject.Feed ? 'assertive' : 'off'} @@ -322,7 +343,8 @@ export const FeedContainer = ({ gapClass({ isList, isFeedLayoutList: shouldUseListFeedLayout, - space: spaciness, + space: effectiveSpaciness, + defaultGridGap: v2GridGap, }), cardClass({ isList, numberOfCards: numCards, isHorizontal }), )} diff --git a/packages/shared/src/components/search/SearchResults/SearchResultsLayout.tsx b/packages/shared/src/components/search/SearchResults/SearchResultsLayout.tsx index 1e66179170..6038d5da84 100644 --- a/packages/shared/src/components/search/SearchResults/SearchResultsLayout.tsx +++ b/packages/shared/src/components/search/SearchResults/SearchResultsLayout.tsx @@ -5,6 +5,7 @@ import classNames from 'classnames'; import { PageWidgets } from '../../utilities'; import type { SearchSuggestion } from '../../../graphql/search'; import { SearchProviderEnum } from '../../../graphql/search'; +import type { Spaciness } from '../../../graphql/settings'; import { useSearchResultsLayout } from '../../../hooks/search/useSearchResultsLayout'; import { LogEvent, Origin, TargetType } from '../../../lib/log'; import { useLogContext } from '../../../contexts/LogContext'; @@ -14,10 +15,11 @@ import { SearchResultsSources } from './SearchResultsSources'; import { useSearchProviderSuggestions } from '../../../hooks/search'; import SettingsContext from '../../../contexts/SettingsContext'; import { gapClass } from '../../feeds/FeedContainer'; -import { useFeedLayout } from '../../../hooks'; +import { useConditionalFeature, useFeedLayout } from '../../../hooks'; import { SearchResultsUsers } from './SearchResultsUsers'; import SearchFilterTimeButton from '../SearchFilterTimeButton'; import SearchFilterPostTypeButton from '../SearchFilterPostTypeButton'; +import { featureFeedLayoutV2 } from '../../../lib/featureManagement'; type SearchResultsLayoutProps = PropsWithChildren; @@ -27,6 +29,12 @@ export const SearchResultsLayout = ( const { children } = props; const { isListMode } = useFeedLayout(); const { spaciness } = useContext(SettingsContext); + const { value: isFeedLayoutV2 } = useConditionalFeature({ + feature: featureFeedLayoutV2, + shouldEvaluate: true, + }); + const effectiveSpaciness: Spaciness = isFeedLayoutV2 ? 'eco' : spaciness; + const v2GridGap = isFeedLayoutV2 ? 'gap-4' : undefined; const { isSearchPageLaptop } = useSearchResultsLayout(); const { @@ -103,7 +111,8 @@ export const SearchResultsLayout = ( gapClass({ isList: true, isFeedLayoutList: false, - space: spaciness, + space: effectiveSpaciness, + defaultGridGap: v2GridGap, }), isListMode ? `flex flex-col` diff --git a/packages/shared/src/features/organizations/components/InviteMemberModal.tsx b/packages/shared/src/features/organizations/components/InviteMemberModal.tsx index 851dfe85c9..573ccdfde2 100644 --- a/packages/shared/src/features/organizations/components/InviteMemberModal.tsx +++ b/packages/shared/src/features/organizations/components/InviteMemberModal.tsx @@ -33,7 +33,9 @@ export const InviteMemberModal = ({ const [isCopying, copyLink] = useCopyLink(); const { openModal } = useLazyModal(); - const { organization, referralUrl, seats } = useOrganization(organizationId); + const { organization, referralUrl, seats } = useOrganization(organizationId, { + includeMembers: true, + }); const isMobile = useViewSize(ViewSize.MobileL); diff --git a/packages/shared/src/features/organizations/graphql.ts b/packages/shared/src/features/organizations/graphql.ts index 05bb1a3abe..8567a5ba54 100644 --- a/packages/shared/src/features/organizations/graphql.ts +++ b/packages/shared/src/features/organizations/graphql.ts @@ -25,31 +25,72 @@ export const ORGANIZATION_SHORT_FRAGMENT = gql` } `; -export const ORGANIZATION_FRAGMENT = gql` - fragment OrganizationFragment on Organization { +export const ORGANIZATION_BASE_FRAGMENT = gql` + fragment OrganizationBaseFragment on Organization { ...OrganizationShortFragment seats activeSeats status + } + + ${ORGANIZATION_SHORT_FRAGMENT} +`; +export const ORGANIZATION_FRAGMENT = gql` + fragment OrganizationFragment on Organization { + ...OrganizationBaseFragment members { ...OrganizationMemberFragment } } - ${ORGANIZATION_SHORT_FRAGMENT} + ${ORGANIZATION_BASE_FRAGMENT} ${ORGANIZATION_MEMBER_FRAGMENT} `; -export const USER_ORGANIZATION_FRAGMENT = gql` - fragment UserOrganizationFragment on UserOrganization { +export const USER_ORGANIZATION_BASE_FRAGMENT = gql` + fragment UserOrganizationBaseFragment on UserOrganization { role referralToken - referralUrl seatType } `; +export const USER_ORGANIZATION_FRAGMENT = gql` + fragment UserOrganizationFragment on UserOrganization { + ...UserOrganizationBaseFragment + referralUrl + } + + ${USER_ORGANIZATION_BASE_FRAGMENT} +`; + +export const ORGANIZATIONS_BASE_QUERY = gql` + query OrganizationsBase { + organizations { + ...UserOrganizationBaseFragment + organization { + ...OrganizationShortFragment + } + } + } + ${USER_ORGANIZATION_BASE_FRAGMENT} + ${ORGANIZATION_SHORT_FRAGMENT} +`; + +export const ORGANIZATION_BASE_QUERY = gql` + query OrganizationBase($id: ID!) { + organization(id: $id) { + ...UserOrganizationBaseFragment + organization { + ...OrganizationBaseFragment + } + } + } + ${USER_ORGANIZATION_BASE_FRAGMENT} + ${ORGANIZATION_BASE_FRAGMENT} +`; + export const ORGANIZATIONS_QUERY = gql` query Organizations { organizations { @@ -98,27 +139,27 @@ export const GET_ORGANIZATION_BY_ID_AND_INVITE_TOKEN_QUERY = gql` export const UPDATE_ORGANIZATION_MUTATION = gql` mutation UpdateOrganization($id: ID!, $name: String, $image: Upload) { updateOrganization(id: $id, name: $name, image: $image) { - ...UserOrganizationFragment + ...UserOrganizationBaseFragment organization { - ...OrganizationFragment + ...OrganizationBaseFragment } } } - ${USER_ORGANIZATION_FRAGMENT} - ${ORGANIZATION_FRAGMENT} + ${USER_ORGANIZATION_BASE_FRAGMENT} + ${ORGANIZATION_BASE_FRAGMENT} `; export const JOIN_ORGANIZATION_MUTATION = gql` mutation JoinOrganization($id: ID!, $token: String!) { joinOrganization(id: $id, token: $token) { - ...UserOrganizationFragment + ...UserOrganizationBaseFragment organization { - ...OrganizationFragment + ...OrganizationBaseFragment } } } - ${USER_ORGANIZATION_FRAGMENT} - ${ORGANIZATION_FRAGMENT} + ${USER_ORGANIZATION_BASE_FRAGMENT} + ${ORGANIZATION_BASE_FRAGMENT} `; export const LEAVE_ORGANIZATION_MUTATION = gql` @@ -132,14 +173,14 @@ export const LEAVE_ORGANIZATION_MUTATION = gql` export const UPDATE_ORGANIZATION_SUBSCRIPTION_MUTATION = gql` mutation UpdateOrganizationSubscription($id: ID!, $quantity: Int!) { updateOrganizationSubscription(id: $id, quantity: $quantity) { - ...UserOrganizationFragment + ...UserOrganizationBaseFragment organization { - ...OrganizationFragment + ...OrganizationBaseFragment } } } - ${USER_ORGANIZATION_FRAGMENT} - ${ORGANIZATION_FRAGMENT} + ${USER_ORGANIZATION_BASE_FRAGMENT} + ${ORGANIZATION_BASE_FRAGMENT} `; export const PREVIEW_SUBSCRIPTION_UPDATE_QUERY = gql` diff --git a/packages/shared/src/features/organizations/hooks/useOrganization.ts b/packages/shared/src/features/organizations/hooks/useOrganization.ts index 2afff04600..9240a82186 100644 --- a/packages/shared/src/features/organizations/hooks/useOrganization.ts +++ b/packages/shared/src/features/organizations/hooks/useOrganization.ts @@ -6,6 +6,7 @@ import { DEFAULT_ERROR, gqlClient } from '../../../graphql/common'; import { DELETE_ORGANIZATION_MUTATION, JOIN_ORGANIZATION_MUTATION, + ORGANIZATION_BASE_QUERY, LEAVE_ORGANIZATION_MUTATION, ORGANIZATION_QUERY, REMOVE_ORGANIZATION_MEMBER_MUTATION, @@ -125,13 +126,22 @@ export const joinOrganizationHandler = async ({ export const useOrganization = ( organizationId: string, - queryOptions?: Partial>, + options?: Partial> & { + includeMembers?: boolean; + }, ) => { const router = useRouter(); const { displayToast } = useToastNotification(); const { user, isAuthReady, refetchBoot } = useAuthContext(); + const { includeMembers = false, ...queryOptions } = options || {}; + const queryMode = includeMembers ? 'members' : 'base'; + const query = includeMembers ? ORGANIZATION_QUERY : ORGANIZATION_BASE_QUERY; const enableQuery = !!organizationId && !!user && isAuthReady; - const queryKey = generateOrganizationQueryKey(user, organizationId); + const queryKey = generateOrganizationQueryKey( + user, + organizationId, + queryMode, + ); const queryClient = useQueryClient(); const { data, isFetching } = useQuery({ @@ -139,7 +149,9 @@ export const useOrganization = ( queryFn: async () => { const res = await gqlClient.request<{ organization: UserOrganization; - }>(ORGANIZATION_QUERY, { id: organizationId }); + }>(query, { + id: organizationId, + }); return res?.organization || null; }, diff --git a/packages/shared/src/features/organizations/hooks/useOrganizationSubscription.ts b/packages/shared/src/features/organizations/hooks/useOrganizationSubscription.ts index 5e1308c065..dd2383707f 100644 --- a/packages/shared/src/features/organizations/hooks/useOrganizationSubscription.ts +++ b/packages/shared/src/features/organizations/hooks/useOrganizationSubscription.ts @@ -97,9 +97,16 @@ export const useOrganizationSubscription = ( }, onSuccess: async (res) => { await queryClient.setQueryData( - generateOrganizationQueryKey(user, organizationId), + generateOrganizationQueryKey(user, organizationId, 'base'), () => res, ); + await queryClient.invalidateQueries({ + queryKey: generateOrganizationQueryKey( + user, + organizationId, + 'members', + ), + }); router.push(getOrganizationSettingsUrl(organizationId, 'members')); displayToast('The organization has been updated'); diff --git a/packages/shared/src/features/organizations/hooks/useOrganizations.ts b/packages/shared/src/features/organizations/hooks/useOrganizations.ts index 65b9f8e2d2..97cc184e26 100644 --- a/packages/shared/src/features/organizations/hooks/useOrganizations.ts +++ b/packages/shared/src/features/organizations/hooks/useOrganizations.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { gqlClient } from '../../../graphql/common'; -import { ORGANIZATIONS_QUERY } from '../graphql'; +import { ORGANIZATIONS_BASE_QUERY } from '../graphql'; import type { UserOrganization } from '../types'; import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; import { useAuthContext } from '../../../contexts/AuthContext'; @@ -13,7 +13,7 @@ export const useOrganizations = () => { queryFn: async () => { const data = await gqlClient.request<{ organizations: UserOrganization[]; - }>(ORGANIZATIONS_QUERY); + }>(ORGANIZATIONS_BASE_QUERY); if (!data || !data.organizations) { return []; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index b5650466eb..6099f5ed01 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -135,3 +135,5 @@ export const sharedPostPreviewFeature = new Feature( 'shared_post_preview', false, ); + +export const featureFeedLayoutV2 = new Feature('feed_layout_v2', isDevelopment); diff --git a/packages/webapp/pages/settings/appearance.tsx b/packages/webapp/pages/settings/appearance.tsx index 40b9219469..c7621b1541 100644 --- a/packages/webapp/pages/settings/appearance.tsx +++ b/packages/webapp/pages/settings/appearance.tsx @@ -5,7 +5,11 @@ import dynamic from 'next/dynamic'; import { ThemeSection } from '@dailydotdev/shared/src/components/ProfileMenu/sections/ThemeSection'; import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; -import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; +import { + useViewSize, + ViewSize, + useConditionalFeature, +} from '@dailydotdev/shared/src/hooks'; import { Typography, TypographyColor, @@ -23,6 +27,7 @@ import { import classNames from 'classnames'; import { FlexCol } from '@dailydotdev/shared/src/components/utilities'; import { iOSSupportsAppIconChange } from '@dailydotdev/shared/src/lib/ios'; +import { featureFeedLayoutV2 } from '@dailydotdev/shared/src/lib/featureManagement'; import { AccountPageContainer } from '../../components/layouts/SettingsLayout/AccountPageContainer'; import { getSettingsLayout } from '../../components/layouts/SettingsLayout'; import { defaultSeo } from '../../next-seo'; @@ -64,6 +69,11 @@ const AccountManageSubscriptionPage = (): ReactElement => { toggleAutoDismissNotifications, } = useSettingsContext(); + const { value: isFeedLayoutV2 } = useConditionalFeature({ + feature: featureFeedLayoutV2, + shouldEvaluate: true, + }); + const onLayoutToggle = useCallback( async (enabled: boolean) => { logEvent({ @@ -103,37 +113,39 @@ const AccountManageSubscriptionPage = (): ReactElement => { )} - - - Density - - - {insaneMode && ( - - Not available in list layout + {!isFeedLayoutV2 && ( + + + Density - )} - - - + + {insaneMode && ( + + Not available in list layout + + )} + + + + )} {supportsAppIconChange && } diff --git a/packages/webapp/pages/settings/organization/[orgId]/members.tsx b/packages/webapp/pages/settings/organization/[orgId]/members.tsx index ac353886b6..39d24f04f3 100644 --- a/packages/webapp/pages/settings/organization/[orgId]/members.tsx +++ b/packages/webapp/pages/settings/organization/[orgId]/members.tsx @@ -80,7 +80,9 @@ const OrganizationOptionsMenu = ({ removeOrganizationMember, updateOrganizationMemberRole, toggleOrganizationMemberSeat, - } = useOrganization(router.query.orgId as string); + } = useOrganization(router.query.orgId as string, { + includeMembers: true, + }); const { user, role, seatType } = member || {}; @@ -324,7 +326,9 @@ const Page = (): ReactElement => { isOwner, leaveOrganization, isLeavingOrganization, - } = useOrganization(query.orgId as string); + } = useOrganization(query.orgId as string, { + includeMembers: true, + }); const onLeaveClick = async () => { const options: PromptOptions = {