diff --git a/.changeset/quick-onions-guess.md b/.changeset/quick-onions-guess.md new file mode 100644 index 000000000..f03afbb2a --- /dev/null +++ b/.changeset/quick-onions-guess.md @@ -0,0 +1,8 @@ +--- +"@lens-protocol/domain": minor +"@lens-protocol/react": minor +"@lens-protocol/react-native": minor +"@lens-protocol/react-web": minor +--- + +**feat:** added hooks to manage profile interests: useAddProfileInterests and useRemoveProfileInterests diff --git a/examples/web/src/App.tsx b/examples/web/src/App.tsx index e46ca615d..d3c5afd7d 100644 --- a/examples/web/src/App.tsx +++ b/examples/web/src/App.tsx @@ -51,6 +51,7 @@ import { UseProfileActionHistory, UseProfileFollowers, UseProfileFollowing, + UseProfileInterests, UseProfileManagers, UseProfiles, UseRecommendProfileToggle, @@ -151,6 +152,7 @@ export function App() { } /> } /> } /> + } /> diff --git a/examples/web/src/profiles/ProfilesPage.tsx b/examples/web/src/profiles/ProfilesPage.tsx index d89f75f7a..1fd499ff3 100644 --- a/examples/web/src/profiles/ProfilesPage.tsx +++ b/examples/web/src/profiles/ProfilesPage.tsx @@ -96,6 +96,11 @@ const profileHooks = [ description: 'Recommend a profile.', path: '/profiles/useRecommendProfileToggle', }, + { + label: 'useProfileInterests', + description: 'Add and remove profile interests.', + path: '/profiles/useProfileInterests', + }, ]; export function ProfilesPage() { diff --git a/examples/web/src/profiles/UseProfileInterests.tsx b/examples/web/src/profiles/UseProfileInterests.tsx new file mode 100644 index 000000000..38e7b1787 --- /dev/null +++ b/examples/web/src/profiles/UseProfileInterests.tsx @@ -0,0 +1,122 @@ +import { + useAddProfileInterests, + useRemoveProfileInterests, + ProfileInterestTypes, + Profile, +} from '@lens-protocol/react-web'; +import { Fragment, useMemo } from 'react'; + +import { RequireProfileSession } from '../components/auth'; + +// Capitalizes each word in a string +function capitalize(label: string): string { + return label.toLowerCase().replace(/\b\w/g, (char) => char.toUpperCase()); +} + +type Interest = { + parent: string; + value: ProfileInterestTypes; + label: string; +}; + +// Processes raw interest types into structured interests array +function createInterests(categories: ProfileInterestTypes[]): Interest[] { + return categories.map((item) => { + const [parent, subcategory] = item.split('__'); + const label = capitalize( + subcategory ? subcategory.replace(/_/g, ' ') : parent.replace(/_/g, ' '), + ); + return { parent, value: item, label }; + }); +} + +type ButtonProps = { + isActive: boolean; + onClick: () => void; + children: React.ReactNode; +}; + +function ToggleButton({ isActive, onClick, children }: ButtonProps) { + const normalStyle = { + backgroundColor: 'transparent', + border: '1px solid grey', + color: '#111', + outline: 'none', + }; + + const activeStyle = { + ...normalStyle, + backgroundColor: '#333', + color: '#eee', + }; + + return ( + + ); +} + +function UseProfileInterestsInner({ profile }: { profile: Profile }) { + const { execute: addInterests } = useAddProfileInterests(); + const { execute: removeInterests } = useRemoveProfileInterests(); + + const groupedInterests = useMemo(() => { + const interests = createInterests(Object.values(ProfileInterestTypes)); + + // Group interests by category + return interests.reduce((acc, interest) => { + acc[interest.parent] = acc[interest.parent] || []; + acc[interest.parent].push(interest); + return acc; + }, {} as Record); + }, []); + + const handleClick = async (interest: ProfileInterestTypes) => { + const request = { + interests: [interest], + }; + + if (profile.interests.includes(interest)) { + await removeInterests(request); + } else { + await addInterests(request); + } + }; + + return ( +
+ {Object.entries(groupedInterests).map(([category, items]) => ( +
+

{capitalize(category.replace(/_/g, ' '))}

+
+ {items.map((item) => ( + + handleClick(item.value)} + isActive={profile.interests.includes(item.value)} + > + {item.label} + {' '} + + ))} +
+
+ ))} +
+ ); +} + +export function UseProfileInterests() { + return ( +
+

+ useAddProfileInterests & useRemoveProfileInterests +

+ + + {({ profile }) => } + +
+ ); +} diff --git a/examples/web/src/profiles/index.ts b/examples/web/src/profiles/index.ts index 315246fd1..999f42aca 100644 --- a/examples/web/src/profiles/index.ts +++ b/examples/web/src/profiles/index.ts @@ -10,6 +10,7 @@ export * from './UseProfile'; export * from './UseProfileActionHistory'; export * from './UseProfileFollowers'; export * from './UseProfileFollowing'; +export * from './UseProfileInterests'; export * from './UseProfileManagers'; export * from './UseProfiles'; export * from './UseRecommendProfileToggle'; diff --git a/packages/domain/src/use-cases/profile/ManageProfileInterests.ts b/packages/domain/src/use-cases/profile/ManageProfileInterests.ts new file mode 100644 index 000000000..6bebae3ba --- /dev/null +++ b/packages/domain/src/use-cases/profile/ManageProfileInterests.ts @@ -0,0 +1,30 @@ +export type ProfileInterestsRequest = { + interests: T[]; +}; + +export interface IProfileInterestsGateway { + add(request: ProfileInterestsRequest): Promise; + remove(request: ProfileInterestsRequest): Promise; +} + +export interface IProfileInterestsPresenter { + add(request: ProfileInterestsRequest): Promise; + remove(request: ProfileInterestsRequest): Promise; +} + +export class ManageProfileInterests { + constructor( + private readonly gateway: IProfileInterestsGateway, + private readonly presenter: IProfileInterestsPresenter, + ) {} + + async add(request: ProfileInterestsRequest) { + void this.gateway.add(request); + await this.presenter.add(request); + } + + async remove(request: ProfileInterestsRequest) { + void this.gateway.remove(request); + await this.presenter.remove(request); + } +} diff --git a/packages/domain/src/use-cases/profile/index.ts b/packages/domain/src/use-cases/profile/index.ts index 50a870255..1c3156114 100644 --- a/packages/domain/src/use-cases/profile/index.ts +++ b/packages/domain/src/use-cases/profile/index.ts @@ -5,6 +5,7 @@ export * from './DismissRecommendedProfiles'; export * from './FollowPolicy'; export * from './FollowProfile'; export * from './LinkHandle'; +export * from './ManageProfileInterests'; export * from './ReportProfile'; export * from './SetProfileMetadata'; export * from './ToggleProfileProperty'; diff --git a/packages/react/src/profile/adapters/ProfileInterestsGateway.ts b/packages/react/src/profile/adapters/ProfileInterestsGateway.ts new file mode 100644 index 000000000..38292cca0 --- /dev/null +++ b/packages/react/src/profile/adapters/ProfileInterestsGateway.ts @@ -0,0 +1,41 @@ +import { + AddProfileInterestsData, + AddProfileInterestsDocument, + AddProfileInterestsVariables, + ProfileInterestTypes, + RemoveProfileInterestsData, + RemoveProfileInterestsDocument, + RemoveProfileInterestsVariables, + SafeApolloClient, +} from '@lens-protocol/api-bindings'; +import { IProfileInterestsGateway } from '@lens-protocol/domain/use-cases/profile'; + +export type ProfileInterestsRequest = { + interests: ProfileInterestTypes[]; +}; + +export class ProfileInterestsGateway implements IProfileInterestsGateway { + constructor(private apolloClient: SafeApolloClient) {} + + async add(request: ProfileInterestsRequest) { + await this.apolloClient.mutate({ + mutation: AddProfileInterestsDocument, + variables: { + request: { + interests: request.interests, + }, + }, + }); + } + + async remove(request: ProfileInterestsRequest) { + await this.apolloClient.mutate({ + mutation: RemoveProfileInterestsDocument, + variables: { + request: { + interests: request.interests, + }, + }, + }); + } +} diff --git a/packages/react/src/profile/adapters/ProfileInterestsPresenter.ts b/packages/react/src/profile/adapters/ProfileInterestsPresenter.ts new file mode 100644 index 000000000..ba275ead4 --- /dev/null +++ b/packages/react/src/profile/adapters/ProfileInterestsPresenter.ts @@ -0,0 +1,33 @@ +import { ProfileInterestTypes } from '@lens-protocol/api-bindings'; +import { ProfileId } from '@lens-protocol/domain/entities'; +import { IProfileInterestsPresenter } from '@lens-protocol/domain/use-cases/profile'; + +import { IProfileCacheManager } from './IProfileCacheManager'; +import { ProfileInterestsRequest } from './ProfileInterestsGateway'; + +export class ProfileInterestsPresenter implements IProfileInterestsPresenter { + constructor( + private readonly profileCacheManager: IProfileCacheManager, + private readonly profileId: ProfileId, + ) {} + + async add(request: ProfileInterestsRequest) { + this.profileCacheManager.update(this.profileId, (current) => { + return { + ...current, + interests: [...current.interests, ...request.interests], + }; + }); + } + + async remove(request: ProfileInterestsRequest) { + this.profileCacheManager.update(this.profileId, (current) => { + return { + ...current, + interests: current.interests.filter( + (interest) => !request.interests.includes(interest as ProfileInterestTypes), + ), + }; + }); + } +} diff --git a/packages/react/src/profile/adapters/useProfileInterestsController.ts b/packages/react/src/profile/adapters/useProfileInterestsController.ts new file mode 100644 index 000000000..caef2029a --- /dev/null +++ b/packages/react/src/profile/adapters/useProfileInterestsController.ts @@ -0,0 +1,43 @@ +import { ManageProfileInterests } from '@lens-protocol/domain/use-cases/profile'; +import { invariant } from '@lens-protocol/shared-kernel'; + +import { SessionType, useSession } from '../../authentication'; +import { useSharedDependencies } from '../../shared'; +import { ProfileInterestsGateway, ProfileInterestsRequest } from './ProfileInterestsGateway'; +import { ProfileInterestsPresenter } from './ProfileInterestsPresenter'; + +export function useProfileInterestsController() { + const { apolloClient, profileCacheManager } = useSharedDependencies(); + const { data: session } = useSession(); + + const add = async (request: ProfileInterestsRequest) => { + invariant( + session?.type === SessionType.WithProfile, + 'You must be authenticated with a profile to use this hook. Use `useLogin` hook to authenticate.', + ); + + const presenter = new ProfileInterestsPresenter(profileCacheManager, session.profile.id); + const gateway = new ProfileInterestsGateway(apolloClient); + const manageInterests = new ManageProfileInterests(gateway, presenter); + + await manageInterests.add(request); + }; + + const remove = async (request: ProfileInterestsRequest) => { + invariant( + session?.type === SessionType.WithProfile, + 'You must be authenticated with a profile to use this hook. Use `useLogin` hook to authenticate.', + ); + + const presenter = new ProfileInterestsPresenter(profileCacheManager, session.profile.id); + const gateway = new ProfileInterestsGateway(apolloClient); + const manageInterests = new ManageProfileInterests(gateway, presenter); + + await manageInterests.remove(request); + }; + + return { + add, + remove, + }; +} diff --git a/packages/react/src/profile/index.ts b/packages/react/src/profile/index.ts index d5d0ebb4e..ae06b6d71 100644 --- a/packages/react/src/profile/index.ts +++ b/packages/react/src/profile/index.ts @@ -1,6 +1,7 @@ /** * Hooks */ +export * from './useAddProfileInterests'; export * from './useBlockedProfiles'; export * from './useLazyProfile'; export * from './useLazyProfiles'; @@ -12,6 +13,7 @@ export * from './useProfileFollowing'; export * from './useProfileManagers'; export * from './useProfiles'; export * from './useRecommendProfileToggle'; +export * from './useRemoveProfileInterests'; export * from './useReportProfile'; export * from './useWhoActedOnPublication'; diff --git a/packages/react/src/profile/useAddProfileInterests.ts b/packages/react/src/profile/useAddProfileInterests.ts new file mode 100644 index 000000000..6a5d6fe8f --- /dev/null +++ b/packages/react/src/profile/useAddProfileInterests.ts @@ -0,0 +1,53 @@ +import { ProfileInterestTypes } from '@lens-protocol/api-bindings'; +import { success } from '@lens-protocol/shared-kernel'; + +import { UseDeferredTask, useDeferredTask } from '../helpers/tasks'; +import { useProfileInterestsController } from './adapters/useProfileInterestsController'; + +export { ProfileInterestTypes }; + +export type AddProfileInterestsArgs = { + interests: ProfileInterestTypes[]; +}; + +/** + * Add profile interests. + * + * You MUST be authenticated via {@link useLogin} to use this hook. + * + * @example + * ```tsx + * function ProfileInterests({ profile }: { profile: Profile }) { + * const { execute: addInterests } = useAddProfileInterests(); + * const { execute: removeInterests } = useRemoveProfileInterests(); + * + * const handleClick = async (interest: ProfileInterestTypes) => { + * const request = { + * interests: [interest], + * }; + * + * if (profile.interests.includes(interest)) { + * await removeInterests(request); + * } else { + * await addInterests(request); + * } + * }; + * + * return ; + * } + * ``` + * + * @category Profiles + * @group Hooks + */ +export function useAddProfileInterests(): UseDeferredTask { + const { add } = useProfileInterestsController(); + + return useDeferredTask(async (request) => { + await add({ + interests: request.interests, + }); + + return success(); + }); +} diff --git a/packages/react/src/profile/useRemoveProfileInterests.ts b/packages/react/src/profile/useRemoveProfileInterests.ts new file mode 100644 index 000000000..5c23f8969 --- /dev/null +++ b/packages/react/src/profile/useRemoveProfileInterests.ts @@ -0,0 +1,57 @@ +import { ProfileInterestTypes } from '@lens-protocol/api-bindings'; +import { success } from '@lens-protocol/shared-kernel'; + +import { UseDeferredTask, useDeferredTask } from '../helpers/tasks'; +import { useProfileInterestsController } from './adapters/useProfileInterestsController'; + +export { ProfileInterestTypes }; + +export type RemoveProfileInterestsArgs = { + interests: ProfileInterestTypes[]; +}; + +/** + * Remove profile interests. + * + * You MUST be authenticated via {@link useLogin} to use this hook. + * + * @example + * ```tsx + * function ProfileInterests({ profile }: { profile: Profile }) { + * const { execute: addInterests } = useAddProfileInterests(); + * const { execute: removeInterests } = useRemoveProfileInterests(); + * + * const handleClick = async (interest: ProfileInterestTypes) => { + * const request = { + * interests: [interest], + * }; + * + * if (profile.interests.includes(interest)) { + * await removeInterests(request); + * } else { + * await addInterests(request); + * } + * }; + * + * return ; + * } + * ``` + * + * @category Profiles + * @group Hooks + */ +export function useRemoveProfileInterests(): UseDeferredTask< + void, + never, + RemoveProfileInterestsArgs +> { + const { remove } = useProfileInterestsController(); + + return useDeferredTask(async (request) => { + await remove({ + interests: request.interests, + }); + + return success(); + }); +}