diff --git a/backend/api/src/get-profiles.ts b/backend/api/src/get-profiles.ts index 0395d01e..b1ff001f 100644 --- a/backend/api/src/get-profiles.ts +++ b/backend/api/src/get-profiles.ts @@ -8,7 +8,7 @@ import {OptionTableKey} from "common/profiles/constants"; export type profileQueryType = { limit?: number | undefined, after?: string | undefined, - // Search and filter parameters + userId?: string | undefined, name?: string | undefined, genders?: string[] | undefined, education_levels?: string[] | undefined, @@ -52,6 +52,7 @@ export const loadProfiles = async (props: profileQueryType) => { limit: limitParam, after, name, + userId, genders, education_levels, pref_gender, @@ -300,6 +301,15 @@ export const loadProfiles = async (props: profileQueryType) => { `), lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}), + + // Exclude profiles that the requester has chosen to hide + userId && where( + `NOT EXISTS ( + SELECT 1 FROM hidden_profiles hp + WHERE hp.hider_user_id = $(userId) + AND hp.hidden_user_id = profiles.user_id + )`, {userId} + ), ] let selectCols = 'profiles.*, users.name, users.username, users.data as user' @@ -341,7 +351,7 @@ export const loadProfiles = async (props: profileQueryType) => { export const getProfiles: APIHandler<'get-profiles'> = async (props, auth) => { try { if (!props.skipId) props.skipId = auth.uid - const {profiles, count} = await loadProfiles(props) + const {profiles, count} = await loadProfiles({...props, userId: auth.uid}) return {status: 'success', profiles: profiles, count: count} } catch (error) { console.log(error) diff --git a/backend/api/src/hide-profile.ts b/backend/api/src/hide-profile.ts new file mode 100644 index 00000000..d777f2b5 --- /dev/null +++ b/backend/api/src/hide-profile.ts @@ -0,0 +1,24 @@ +import {APIError, APIHandler} from './helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' + +// Hide a profile for the requesting user by inserting a row into hidden_profiles. +// Idempotent: if the pair already exists, succeed silently. +export const hideProfile: APIHandler<'hide-profile'> = async ( + {hiddenUserId}, + auth +) => { + if (auth.uid === hiddenUserId) + throw new APIError(400, 'You cannot hide yourself') + + const pg = createSupabaseDirectClient() + + // Insert idempotently: do nothing on conflict + await pg.none( + `insert into hidden_profiles (hider_user_id, hidden_user_id) + values ($1, $2) + on conflict (hider_user_id, hidden_user_id) do nothing`, + [auth.uid, hiddenUserId] + ) + + return {status: 'success'} +} diff --git a/backend/api/src/send-search-notifications.ts b/backend/api/src/send-search-notifications.ts index 3fad2f02..7479a398 100644 --- a/backend/api/src/send-search-notifications.ts +++ b/backend/api/src/send-search-notifications.ts @@ -57,6 +57,7 @@ export const sendSearchNotifications = async () => { const props = { ...filters, skipId: row.creator_id, + userId: row.creator_id, lastModificationWithin: '24 hours', shortBio: true, } diff --git a/backend/supabase/hidden_profiles.sql b/backend/supabase/hidden_profiles.sql new file mode 100644 index 00000000..7527efbe --- /dev/null +++ b/backend/supabase/hidden_profiles.sql @@ -0,0 +1,27 @@ +create table if not exists hidden_profiles +( + id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, + created_time timestamptz not null default now(), + hider_user_id TEXT not null, + hidden_user_id TEXT not null +); + +-- Row Level Security +ALTER TABLE hidden_profiles + ENABLE ROW LEVEL SECURITY; + +-- Ensure a user can only hide another user once (idempotent) +create unique index if not exists hidden_profiles_unique_hider_hidden + on hidden_profiles (hider_user_id, hidden_user_id); + +-- Helpful indexes +create index if not exists hidden_profiles_hider_idx on hidden_profiles (hider_user_id); +create index if not exists hidden_profiles_hidden_idx on hidden_profiles (hidden_user_id); + +-- Optional FKs to users table if present in schema +alter table hidden_profiles + add constraint fk_hidden_profiles_hider + foreign key (hider_user_id) references users (id) on delete cascade; +alter table hidden_profiles + add constraint fk_hidden_profiles_hidden + foreign key (hidden_user_id) references users (id) on delete cascade; diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 99337691..8b2dfa17 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -444,6 +444,19 @@ export const API = (_apiTypeCheck = { summary: 'Star or unstar a profile', tag: 'Profiles', }, + 'hide-profile': { + method: 'POST', + authed: true, + rateLimited: true, + props: z.object({ + hiddenUserId: z.string(), + }), + returns: {} as { + status: 'success' + }, + summary: 'Hide a profile for the current user', + tag: 'Profiles', + }, 'get-profiles': { method: 'GET', authed: true, diff --git a/web/components/profile-grid.tsx b/web/components/profile-grid.tsx index cbacb32d..725e6d4b 100644 --- a/web/components/profile-grid.tsx +++ b/web/components/profile-grid.tsx @@ -13,6 +13,10 @@ import {useUser} from "web/hooks/use-user"; import {useT} from "web/lib/locale"; import {useAllChoices} from "web/hooks/use-choices"; import {getSeekingGenderText} from "web/lib/profile/seeking"; +import {Tooltip} from 'web/components/widgets/tooltip' +import {EyeOffIcon} from '@heroicons/react/outline' +import {useState} from 'react' +import {api} from 'web/lib/api' export const ProfileGrid = (props: { profiles: Profile[] @@ -22,6 +26,7 @@ export const ProfileGrid = (props: { compatibilityScores: Record | undefined starredUserIds: string[] | undefined refreshStars: () => Promise + onHide?: (userId: string) => void }) => { const { profiles, @@ -31,6 +36,7 @@ export const ProfileGrid = (props: { compatibilityScores, starredUserIds, refreshStars, + onHide, } = props const user = useUser() @@ -54,6 +60,7 @@ export const ProfileGrid = (props: { compatibilityScore={compatibilityScores?.[profile.user_id]} hasStar={starredUserIds?.includes(profile.user_id) ?? false} refreshStars={refreshStars} + onHide={onHide} /> ))} @@ -81,12 +88,14 @@ function ProfilePreview(props: { compatibilityScore: CompatibilityScore | undefined hasStar: boolean refreshStars: () => Promise + onHide?: (userId: string) => void }) { - const {profile, compatibilityScore} = props + const {profile, compatibilityScore, onHide} = props const {user} = profile const choicesIdsToLabels = useAllChoices() const t = useT() // const currentUser = useUser() + const [hiding, setHiding] = useState(false) const bio = profile.bio as JSONContent; @@ -157,7 +166,30 @@ function ProfilePreview(props: { {/*
*/} {/* )}*/} {compatibilityScore && ( - + + )} + {/* Hide profile button */} + {onHide && ( + + + )} diff --git a/web/components/profiles/profiles-home.tsx b/web/components/profiles/profiles-home.tsx index 43614a4f..499feae6 100644 --- a/web/components/profiles/profiles-home.tsx +++ b/web/components/profiles/profiles-home.tsx @@ -138,6 +138,12 @@ export function ProfilesHome() { } }, [profiles, filters, isLoadingMore, setProfiles]) + const onHide = useCallback((userId: string) => { + setProfiles((prev) => prev?.filter((p) => p.user_id !== userId)) + setProfileCount((prev) => prev ? prev - 1 : 0) + toast.success(t('profiles.hidden_success', 'Profile hidden. You will no longer see this person in search results.')) + }, [setProfiles, t]) + return ( <> {showBanner && fromSignup && ( @@ -237,6 +243,7 @@ export function ProfilesHome() { compatibilityScores={compatibleProfiles?.profileCompatibilityScores} starredUserIds={starredUserIds} refreshStars={refreshStars} + onHide={onHide} /> )}