diff --git a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx index 0762ae8be2e..e5b659853fb 100644 --- a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx +++ b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx @@ -14,6 +14,7 @@ import type { SidebarSectionProps } from './common'; import { useSquadPendingPosts } from '../../../hooks/squads/useSquadPendingPosts'; import { Typography, TypographyColor } from '../../typography/Typography'; import { SourcePostModerationStatus } from '../../../graphql/squads'; +import { SquadFavoriteButton } from '../../squads/SquadFavoriteButton'; export const NetworkSection = ({ isItemsButton, @@ -42,6 +43,7 @@ export const NetworkSection = ({ ), title: name, path: `${webappUrl}squads/${handle}`, + rightIcon: () => , }; }) ?? []; return [ diff --git a/packages/shared/src/components/squads/SquadFavoriteButton.tsx b/packages/shared/src/components/squads/SquadFavoriteButton.tsx new file mode 100644 index 00000000000..80138ee553f --- /dev/null +++ b/packages/shared/src/components/squads/SquadFavoriteButton.tsx @@ -0,0 +1,47 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import classNames from 'classnames'; +import type { Squad } from '../../graphql/sources'; +import { StarIcon } from '../icons'; +import type { IconSize } from '../Icon'; +import { useSquadFavorite } from '../../hooks/squads/useSquadFavorite'; + +interface SquadFavoriteButtonProps { + squad: Squad; + className?: string; + iconSize?: IconSize; +} + +export const SquadFavoriteButton = ({ + squad, + className, + iconSize, +}: SquadFavoriteButtonProps): ReactElement => { + const { toggleFavorite, isPending } = useSquadFavorite(); + const isFavorited = !!squad.favoritedAt; + + const onClick = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + toggleFavorite(squad); + }, + [squad, toggleFavorite], + ); + + return ( + + ); +}; diff --git a/packages/shared/src/graphql/sources.ts b/packages/shared/src/graphql/sources.ts index d3ba47e3594..6f863ca405f 100644 --- a/packages/shared/src/graphql/sources.ts +++ b/packages/shared/src/graphql/sources.ts @@ -88,6 +88,7 @@ export interface Squad extends Source { referralUrl?: string; category?: SourceCategory; moderationPostCount: number; + favoritedAt?: string | null; } interface SourceFlags { diff --git a/packages/shared/src/graphql/squads.ts b/packages/shared/src/graphql/squads.ts index 6a7e00ce3a3..d1ec8546936 100644 --- a/packages/shared/src/graphql/squads.ts +++ b/packages/shared/src/graphql/squads.ts @@ -472,6 +472,14 @@ export const EXPAND_PINNED_POSTS_MUTATION = gql` } `; +export const TOGGLE_FAVORITE_SOURCE_MUTATION = gql` + mutation ToggleFavoriteSource($sourceId: ID!) { + toggleFavoriteSource(sourceId: $sourceId) { + _ + } + } +`; + export const validateSourceHandle = (handle: string, source: Source): boolean => source.handle === handle || source.handle === handle.toLowerCase(); @@ -718,6 +726,16 @@ export const expandPinnedPosts = async ( return res.expandPinnedPosts; }; +export const toggleFavoriteSource = async ( + sourceId: string, +): Promise => { + const res = await gqlClient.request(TOGGLE_FAVORITE_SOURCE_MUTATION, { + sourceId, + }); + + return res.toggleFavoriteSource; +}; + export const verifyPermission = ( squad: Pick, permission: SourcePermissions, diff --git a/packages/shared/src/hooks/squads/useSquadFavorite.ts b/packages/shared/src/hooks/squads/useSquadFavorite.ts new file mode 100644 index 00000000000..c9716d8cd45 --- /dev/null +++ b/packages/shared/src/hooks/squads/useSquadFavorite.ts @@ -0,0 +1,35 @@ +import { useMutation } from '@tanstack/react-query'; +import type { Squad } from '../../graphql/sources'; +import { toggleFavoriteSource } from '../../graphql/squads'; +import { useBoot } from '../useBoot'; + +type UseSquadFavorite = { + toggleFavorite: (squad: Squad) => void; + isPending: boolean; +}; + +export const useSquadFavorite = (): UseSquadFavorite => { + const { updateSquad } = useBoot(); + + const { mutate, isPending } = useMutation({ + mutationFn: (squad: Squad) => { + if (!squad.id) { + throw new Error('Cannot toggle favorite on squad without id'); + } + return toggleFavoriteSource(squad.id); + }, + onMutate: (squad) => { + const previous = squad.favoritedAt ?? null; + updateSquad({ + ...squad, + favoritedAt: previous ? null : new Date().toISOString(), + }); + return { previous }; + }, + onError: (_err, squad, context) => { + updateSquad({ ...squad, favoritedAt: context?.previous ?? null }); + }, + }); + + return { toggleFavorite: mutate, isPending }; +}; diff --git a/packages/shared/src/hooks/useBoot.ts b/packages/shared/src/hooks/useBoot.ts index 4aac0b09a78..1ced7ace25a 100644 --- a/packages/shared/src/hooks/useBoot.ts +++ b/packages/shared/src/hooks/useBoot.ts @@ -21,10 +21,22 @@ type UseBoot = { getPlusEntryData: () => MarketingCta | null; }; -const sortByName = (squads: Squad[]): Squad[] => - [...squads].sort((a, b) => - a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase() ? 1 : -1, - ); +const sortSquads = (squads: Squad[]): Squad[] => + [...squads].sort((a, b) => { + const aFav = !!a.favoritedAt; + const bFav = !!b.favoritedAt; + if (aFav !== bFav) { + return aFav ? -1 : 1; + } + if (aFav && bFav) { + const aTime = new Date(a.favoritedAt as string).getTime(); + const bTime = new Date(b.favoritedAt as string).getTime(); + if (aTime !== bTime) { + return bTime - aTime; + } + } + return a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()); + }); export const useBoot = (): UseBoot => { const router = useRouter(); @@ -40,7 +52,7 @@ export const useBoot = (): UseBoot => { return; } - const squads = sortByName([...currentSquads, squad]); + const squads = sortSquads([...currentSquads, squad]); client.setQueryData(BOOT_QUERY_KEY, { ...bootData, squads }); }; @@ -57,7 +69,7 @@ export const useBoot = (): UseBoot => { ); client.setQueryData(BOOT_QUERY_KEY, { ...bootData, - squads: sortByName(squads ?? []), + squads: sortSquads(squads ?? []), }); }; diff --git a/packages/webapp/pages/squads/discover/my.tsx b/packages/webapp/pages/squads/discover/my.tsx index bbb4901e7ff..6de75f471fd 100644 --- a/packages/webapp/pages/squads/discover/my.tsx +++ b/packages/webapp/pages/squads/discover/my.tsx @@ -2,6 +2,8 @@ import type { ReactElement } from 'react'; import React, { useEffect, useMemo } from 'react'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { SquadList } from '@dailydotdev/shared/src/components/cards/squad/SquadList'; +import { SquadFavoriteButton } from '@dailydotdev/shared/src/components/squads/SquadFavoriteButton'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { useRouter } from 'next/router'; import { squadCategoriesPaths, @@ -60,7 +62,9 @@ const SquadSection = ({ squads, title }: SquadSectionProps): ReactElement => { key={squad.handle} squad={squad} shouldShowCount={false} - /> + > + + ))}