Hide feature to hide per-user profiles

This commit is contained in:
MartinBraquet
2026-02-11 14:57:27 +01:00
parent 05d003535b
commit 17d0fba831
7 changed files with 118 additions and 4 deletions

View File

@@ -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)

View File

@@ -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'}
}

View File

@@ -57,6 +57,7 @@ export const sendSearchNotifications = async () => {
const props = {
...filters,
skipId: row.creator_id,
userId: row.creator_id,
lastModificationWithin: '24 hours',
shortBio: true,
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<string, CompatibilityScore> | undefined
starredUserIds: string[] | undefined
refreshStars: () => Promise<void>
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}
/>
))}
</div>
@@ -81,12 +88,14 @@ function ProfilePreview(props: {
compatibilityScore: CompatibilityScore | undefined
hasStar: boolean
refreshStars: () => Promise<void>
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: {
{/* <div />*/}
{/* )}*/}
{compatibilityScore && (
<CompatibleBadge compatibility={compatibilityScore}/>
<CompatibleBadge compatibility={compatibilityScore} className={'pt-1'}/>
)}
{/* Hide profile button */}
{onHide && (
<Tooltip text={t('profile_grid.hide_profile', "Don't show again")} noTap>
<button
className="ml-2 rounded-full p-1 hover:bg-canvas-300 shadow focus:outline-none"
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
if (hiding) return
setHiding(true)
try {
await api('hide-profile', {hiddenUserId: profile.user_id})
onHide(profile.user_id)
} finally {
setHiding(false)
}
}}
aria-label={t('profile_grid.hide_profile', 'Hide this profile')}
>
<EyeOffIcon className="h-5 w-5 guidance"/>
</button>
</Tooltip>
)}
</Row>

View File

@@ -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}
/>
</>)}
</>