diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 9749f41..f1fbaf3 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -77,6 +77,8 @@ import {deleteMessage} from "api/delete-message"; import {updateOptions} from "api/update-options"; import {getOptions} from "api/get-options"; import {hideProfile} from "api/hide-profile"; +import {unhideProfile} from "api/unhide-profile"; +import {getHiddenProfiles} from "api/get-hidden-profiles"; // const corsOptions: CorsOptions = { // origin: ['*'], // Only allow requests from this domain @@ -341,6 +343,8 @@ const handlers: { [k in APIPath]: APIHandler } = { 'has-free-like': hasFreeLike, 'hide-comment': hideComment, 'hide-profile': hideProfile, + 'unhide-profile': unhideProfile, + 'get-hidden-profiles': getHiddenProfiles, 'leave-private-user-message-channel': leavePrivateUserMessageChannel, 'like-profile': likeProfile, 'mark-all-notifs-read': markAllNotifsRead, diff --git a/backend/api/src/get-hidden-profiles.ts b/backend/api/src/get-hidden-profiles.ts new file mode 100644 index 0000000..7ec67c0 --- /dev/null +++ b/backend/api/src/get-hidden-profiles.ts @@ -0,0 +1,38 @@ +import {APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' + +export const getHiddenProfiles: APIHandler<'get-hidden-profiles'> = async ( + {limit = 100, offset = 0}, + auth +) => { + const pg = createSupabaseDirectClient() + + // Count total hidden for pagination info + const countRes = await pg.one<{ count: string }>( + `select count(*)::text as count + from hidden_profiles + where hider_user_id = $1`, + [auth.uid] + ) + const count = Number(countRes.count) || 0 + + // Fetch hidden users joined with users table for display + const rows = await pg.map( + `select u.id, u.name, u.username, u.data ->> 'avatarUrl' as "avatarUrl", hp.created_time as "createdTime" + from hidden_profiles hp + join users u on u.id = hp.hidden_user_id + where hp.hider_user_id = $1 + order by hp.created_time desc + limit $2 offset $3`, + [auth.uid, limit, offset], + (r: any) => ({ + id: r.id as string, + name: r.name as string, + username: r.username as string, + avatarUrl: r.avatarUrl as string | null | undefined, + createdTime: r.createdTime as string | undefined, + }) + ) + + return {status: 'success', hidden: rows, count} +} diff --git a/backend/api/src/unhide-profile.ts b/backend/api/src/unhide-profile.ts new file mode 100644 index 0000000..ec57155 --- /dev/null +++ b/backend/api/src/unhide-profile.ts @@ -0,0 +1,21 @@ +import {APIHandler} from './helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' + +// Unhide a profile for the requesting user by deleting from hidden_profiles. +// Idempotent: if the pair does not exist, succeed silently. +export const unhideProfile: APIHandler<'unhide-profile'> = async ( + {hiddenUserId}, + auth +) => { + const pg = createSupabaseDirectClient() + + await pg.none( + `delete + from hidden_profiles + where hider_user_id = $1 + and hidden_user_id = $2`, + [auth.uid, hiddenUserId] + ) + + return {status: 'success'} +} diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 8b2dfa1..616e7ba 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -457,6 +457,41 @@ export const API = (_apiTypeCheck = { summary: 'Hide a profile for the current user', tag: 'Profiles', }, + 'unhide-profile': { + method: 'POST', + authed: true, + rateLimited: true, + props: z.object({ + hiddenUserId: z.string(), + }), + returns: {} as { + status: 'success' + }, + summary: 'Unhide a previously hidden profile for the current user', + tag: 'Profiles', + }, + 'get-hidden-profiles': { + method: 'GET', + authed: true, + rateLimited: true, + props: z.object({ + limit: z.coerce.number().min(1).max(200).optional(), + offset: z.coerce.number().min(0).optional(), + }).strict(), + returns: {} as { + status: 'success' + hidden: { + id: string + name: string + username: string + avatarUrl?: string | null + createdTime?: string + }[] + count: number + }, + summary: 'Get the list of profiles the current user has hidden', + tag: 'Profiles', + }, 'get-profiles': { method: 'GET', authed: true, diff --git a/web/components/settings/hidden-profiles-modal.tsx b/web/components/settings/hidden-profiles-modal.tsx new file mode 100644 index 0000000..f49b95d --- /dev/null +++ b/web/components/settings/hidden-profiles-modal.tsx @@ -0,0 +1,139 @@ +import {useEffect, useMemo, useState} from 'react' +import {Modal, MODAL_CLASS, SCROLLABLE_MODAL_CLASS} from 'web/components/layout/modal' +import {Col} from 'web/components/layout/col' +import {Row} from 'web/components/layout/row' +import {Title} from 'web/components/widgets/title' +import {Avatar} from 'web/components/widgets/avatar' +import {Button} from 'web/components/buttons/button' +import {api} from 'web/lib/api' +import toast from 'react-hot-toast' +import {useT} from 'web/lib/locale' +import clsx from "clsx"; +import Link from "next/link"; + +type HiddenUser = { + id: string + name: string + username: string + avatarUrl?: string | null + createdTime?: string +} + +export function HiddenProfilesModal(props: { + open: boolean + setOpen: (open: boolean) => void +}) { + const {open, setOpen} = props + const t = useT() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [hidden, setHidden] = useState(null) + const [busyIds, setBusyIds] = useState>({}) + + const empty = useMemo(() => (hidden ? hidden.length === 0 : false), [hidden]) + + useEffect(() => { + if (!open) return + let alive = true + setLoading(true) + setError(null) + api('get-hidden-profiles', {limit: 200, offset: 0}) + .then((res) => { + if (!alive) return + setHidden(res.hidden) + }) + .catch((e) => { + console.error('Failed to load hidden profiles', e) + if (!alive) return + setError( + t( + 'settings.hidden_profiles.load_error', + 'Failed to load hidden profiles.' + ) + ) + }) + .finally(() => alive && setLoading(false)) + return () => { + alive = false + } + }, [open]) + + const unhide = async (userId: string) => { + if (busyIds[userId]) return + setBusyIds((b) => ({...b, [userId]: true})) + try { + await api('unhide-profile', {hiddenUserId: userId}) + setHidden((list) => (list ? list.filter((u) => u.id !== userId) : list)) + toast.success( + t( + 'settings.hidden_profiles.unhidden_success', + 'Profile unhidden. You will start seeing this person again in results.' + ) + ) + } catch (e) { + console.error('Failed to unhide profile', e) + toast.error(t('settings.hidden_profiles.unhide_failed', 'Failed to unhide')) + } finally { + setBusyIds((b) => ({...b, [userId]: false})) + } + } + + return ( + + + + {t('settings.hidden_profiles.title', "Profiles you've hidden")} + + {!loading && hidden && hidden.length > 0 &&

+ {t('settings.hidden_profiles.description', "These people don't appear in your search results.")} +

} + {loading && ( +
+ {t('settings.hidden_profiles.loading', 'Loading hidden profiles...')} +
+ )} + {error &&
{error}
} + {!loading && hidden && hidden.length > 0 && ( + + {hidden.map((u) => ( + + + + + +
{u.name}
+
@{u.username}
+ +
+ + +
+ ))} + + )} + {!loading && empty && ( +
+ {t( + 'settings.hidden_profiles.empty', + "You haven't hidden any profiles." + )} +
+ )} + +
+ ) +} + +export default HiddenProfilesModal diff --git a/web/components/widgets/hide-profile-button.tsx b/web/components/widgets/hide-profile-button.tsx index 6850c72..a12aee7 100644 --- a/web/components/widgets/hide-profile-button.tsx +++ b/web/components/widgets/hide-profile-button.tsx @@ -56,7 +56,7 @@ export function HideProfileButton(props: HideProfileButtonProps) { +

{t('settings.general.account', 'Account')}

{t('settings.general.email', 'Email')}
- {!isChangingEmail ? ( - ) : ( @@ -191,7 +199,8 @@ const LoadedGeneralSettings = (props: {
{t('settings.general.password', 'Password')}
@@ -201,5 +210,8 @@ const LoadedGeneralSettings = (props: { {t('settings.delete_account', 'Delete Account')} + + {/* Hidden profiles modal */} + }