Use persistent in storage for hidden profiles and allow toggle hidden profile

This commit is contained in:
MartinBraquet
2026-02-11 18:25:58 +01:00
parent a86f841b7e
commit b13a40f892
4 changed files with 137 additions and 113 deletions

View File

@@ -59,7 +59,8 @@ export default function ProfileHeader(props: {
<Row className="items-center gap-1">
<Col className="gap-1">
{currentUser && isCurrentUser && disabled &&
<div className="text-red-500">{t('profile.header.disabled_notice', 'You disabled your profile, so no one else can access it.')}</div>}
<div
className="text-red-500">{t('profile.header.disabled_notice', 'You disabled your profile, so no one else can access it.')}</div>}
<Row className="items-center gap-1 text-xl">
{/*{!isCurrentUser && <OnlineIcon last_online_time={userActivity?.last_online_time}/>}*/}
<span>
@@ -83,73 +84,73 @@ export default function ProfileHeader(props: {
username={user.username}
/>
<Tooltip text={t('more_options_user.edit_profile', 'Edit profile')} noTap>
<Button
color={'gray-outline'}
onClick={() => {
track('editprofile')
Router.push('profile')
}}
size="sm"
>
<PencilIcon className=" h-4 w-4"/>
</Button>
<Button
color={'gray-outline'}
onClick={() => {
track('editprofile', {userId: user.id})
Router.push('profile')
}}
size="sm"
>
<PencilIcon className=" h-4 w-4"/>
</Button>
</Tooltip>
<Tooltip text={t('more_options_user.profile_options', 'Profile options')} noTap>
<DropdownMenu
menuWidth={'w-52'}
icon={
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true"/>
}
items={[
{
name:
profile.visibility === 'member'
? t('profile.header.menu.list_public', 'List Profile Publicly')
: t('profile.header.menu.limit_members', 'Limit to Members Only'),
icon:
profile.visibility === 'member' ? (
<EyeIcon className="h-4 w-4"/>
) : (
<LockClosedIcon className="h-4 w-4"/>
),
onClick: () => setShowVisibilityModal(true),
},
{
name: disabled ? t('profile.header.menu.enable_profile', 'Enable profile') : t('profile.header.menu.disable_profile', 'Disable profile'),
icon: null,
onClick: async () => {
const confirmed = true // confirm(
// 'Are you sure you want to disable your profile? This will hide your profile from searches and listings..'
// )
if (confirmed) {
toast
.promise(disableProfile(!disabled), {
loading: disabled
? t('profile.header.toast.enabling', 'Enabling profile...')
: t('profile.header.toast.disabling', 'Disabling profile...'),
success: () => {
return disabled
? t('profile.header.toast.enabled', 'Profile enabled')
: t('profile.header.toast.disabled', 'Profile disabled')
},
error: () => {
return disabled
? t('profile.header.toast.failed_enable', 'Failed to enable profile')
: t('profile.header.toast.failed_disable', 'Failed to disable profile')
},
})
.then(() => {
refreshProfile()
})
.catch(() => {
// return false
})
}
<DropdownMenu
menuWidth={'w-52'}
icon={
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true"/>
}
items={[
{
name:
profile.visibility === 'member'
? t('profile.header.menu.list_public', 'List Profile Publicly')
: t('profile.header.menu.limit_members', 'Limit to Members Only'),
icon:
profile.visibility === 'member' ? (
<EyeIcon className="h-4 w-4"/>
) : (
<LockClosedIcon className="h-4 w-4"/>
),
onClick: () => setShowVisibilityModal(true),
},
},
]}
/>
{
name: disabled ? t('profile.header.menu.enable_profile', 'Enable profile') : t('profile.header.menu.disable_profile', 'Disable profile'),
icon: null,
onClick: async () => {
const confirmed = true // confirm(
// 'Are you sure you want to disable your profile? This will hide your profile from searches and listings..'
// )
if (confirmed) {
toast
.promise(disableProfile(!disabled), {
loading: disabled
? t('profile.header.toast.enabling', 'Enabling profile...')
: t('profile.header.toast.disabling', 'Disabling profile...'),
success: () => {
return disabled
? t('profile.header.toast.enabled', 'Profile enabled')
: t('profile.header.toast.disabled', 'Profile disabled')
},
error: () => {
return disabled
? t('profile.header.toast.failed_enable', 'Failed to enable profile')
: t('profile.header.toast.failed_disable', 'Failed to disable profile')
},
})
.then(() => {
refreshProfile()
})
.catch(() => {
// return false
})
}
},
},
]}
/>
</Tooltip>
</Row>
) : (

View File

@@ -10,15 +10,7 @@ import toast from 'react-hot-toast'
import {useT} from 'web/lib/locale'
import clsx from "clsx";
import Link from "next/link";
import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator";
type HiddenUser = {
id: string
name: string
username: string
avatarUrl?: string | null
createdTime?: string
}
import {useHiddenProfiles} from "web/hooks/use-hidden-profiles";
export function HiddenProfilesModal(props: {
open: boolean
@@ -26,32 +18,14 @@ export function HiddenProfilesModal(props: {
}) {
const {open, setOpen} = props
const t = useT()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hidden, setHidden] = useState<HiddenUser[] | null>(null)
const [busyIds, setBusyIds] = useState<Record<string, boolean>>({})
const {hiddenProfiles, refreshHiddenProfiles} = useHiddenProfiles()
const empty = useMemo(() => (hidden ? hidden.length === 0 : false), [hidden])
const empty = useMemo(() => (hiddenProfiles ? hiddenProfiles.length === 0 : false), [hiddenProfiles])
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
}
refreshHiddenProfiles()
}, [open])
const unhide = async (userId: string) => {
@@ -59,12 +33,12 @@ export function HiddenProfilesModal(props: {
setBusyIds((b) => ({...b, [userId]: true}))
try {
await api('unhide-profile', {hiddenUserId: userId})
setHidden((list) => (list ? list.filter((u) => u.id !== userId) : list))
refreshHiddenProfiles()
} 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}))
// setBusyIds((b) => ({...b, [userId]: false}))
}
}
@@ -74,14 +48,9 @@ export function HiddenProfilesModal(props: {
<Title className="mb-2">
{t('settings.hidden_profiles.title', "Profiles you've hidden")}
</Title>
{!loading && hidden && hidden.length > 0 && <p>
{t('settings.hidden_profiles.description', "These people don't appear in your search results.")}
</p>}
{loading && <CompassLoadingIndicator/>}
{error && <div className="text-red-500 py-2">{error}</div>}
{!loading && hidden && hidden.length > 0 && (
{hiddenProfiles && hiddenProfiles.length > 0 && (
<Col className={clsx("divide-y divide-canvas-300 w-full pr-4", SCROLLABLE_MODAL_CLASS)}>
{hidden.map((u) => (
{hiddenProfiles.map((u) => (
<Row key={u.id} className="items-center justify-between py-2 gap-2">
<Link
className="w-full rounded-md hover:bg-canvas-100 p-2"
@@ -109,7 +78,7 @@ export function HiddenProfilesModal(props: {
))}
</Col>
)}
{!loading && empty && (
{empty && (
<div className="text-ink-500 py-6 text-center">
{t(
'settings.hidden_profiles.empty',

View File

@@ -1,9 +1,10 @@
import clsx from 'clsx'
import {useState} from 'react'
import {useMemo, useState} from 'react'
import {EyeIcon, EyeOffIcon} from '@heroicons/react/outline'
import {Tooltip} from 'web/components/widgets/tooltip'
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale'
import {useHiddenProfiles} from "web/hooks/use-hidden-profiles";
export type HideProfileButtonProps = {
hiddenUserId: string
@@ -28,17 +29,34 @@ export function HideProfileButton(props: HideProfileButtonProps) {
const t = useT()
const [submitting, setSubmitting] = useState(false)
const [clicked, setClicked] = useState(false)
const {hiddenProfiles, refreshHiddenProfiles} = useHiddenProfiles()
const [optimisticHidden, setOptimisticHidden] = useState<boolean | undefined>(undefined)
const hidden = useMemo(() => {
if (optimisticHidden !== undefined) return optimisticHidden
return hiddenProfiles?.some((u) => u.id === hiddenUserId) ?? false
}, [hiddenProfiles, hiddenUserId, optimisticHidden])
const onClick = async (e: React.MouseEvent) => {
e.preventDefault()
if (stopPropagation) e.stopPropagation()
if (submitting) return
setSubmitting(true)
setClicked(true)
// Optimistically update hidden state
setOptimisticHidden(!hidden)
try {
await api('hide-profile', {hiddenUserId})
if (hidden) {
await api('unhide-profile', {hiddenUserId})
} else {
await api('hide-profile', {hiddenUserId})
}
refreshHiddenProfiles()
onHidden?.(hiddenUserId)
} catch (e) {
console.error('Failed to toggle hide profile', e)
// Revert optimistic update on failure
setOptimisticHidden(hidden)
} finally {
setSubmitting(false)
}
@@ -46,21 +64,20 @@ export function HideProfileButton(props: HideProfileButtonProps) {
return (
<Tooltip
text={!clicked ? (tooltip ?? t('profile_grid.hide_profile', "Don't show again in search results")) : t('profile_grid.unhide_profile', "Show again in search results")}
text={hidden ? t('profile_grid.unhide_profile', "Show again in search results") : (tooltip ?? t('profile_grid.hide_profile', "Don't show again in search results"))}
noTap>
<button
className={clsx(
'rounded-full p-1 hover:bg-canvas-200 shadow focus:outline-none',
className
)}
disabled={submitting}
onClick={onClick}
aria-label={
ariaLabel ?? (!clicked
? t('profile_grid.hide_profile', 'Hide this profile')
: t('profile_grid.unhide_profile', 'Unhide this profile'))
ariaLabel ?? (hidden ? t('profile_grid.unhide_profile', 'Unhide this profile') : t('profile_grid.hide_profile', 'Hide this profile'))
}
>
{clicked || submitting ? <EyeIcon className={clsx('h-5 w-5 guidance', iconClassName)}/> :
{hidden ? <EyeIcon className={clsx('h-5 w-5 guidance', iconClassName)}/> :
<EyeOffIcon className={clsx('h-5 w-5 guidance', iconClassName)}/>}
</button>
</Tooltip>

View File

@@ -0,0 +1,37 @@
import {useUser} from 'web/hooks/use-user'
import {useEffect} from 'react'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {api} from "web/lib/api";
type HiddenUser = {
id: string
name: string
username: string
avatarUrl?: string | null
createdTime?: string
}
export const useHiddenProfiles = () => {
const user = useUser()
const [hiddenProfiles, setHiddenProfiles] = usePersistentLocalState<
HiddenUser[] | undefined | null
>(undefined, `hidden-ids-${user?.id}`)
const refreshHiddenProfiles = () => {
if (user) {
api('get-hidden-profiles', {limit: 200, offset: 0})
.then((res) => {
setHiddenProfiles(res.hidden)
})
.catch((e) => {
console.error('Failed to load hidden profiles', e)
})
}
}
useEffect(() => {
refreshHiddenProfiles()
}, [user?.id])
return {hiddenProfiles, refreshHiddenProfiles}
}