Skip to content

Commit 065c8b6

Browse files
feat: filter profile photos by public/private visibility (#628)
1 parent c29daa3 commit 065c8b6

18 files changed

Lines changed: 324 additions & 68 deletions

src/components/Reels/ImageActions/ImageActions.spec.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const fakeImage = {
3535
id: 'img-1',
3636
url: 'https://image.url/blob',
3737
thumbnailUrl: '',
38+
isPublic: true,
3839
metadata: {
3940
userName: 'alice',
4041
userAddress: '0xa',

src/components/Reels/ImageViewer/ImageViewer.spec.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const fakeImage = {
1818
id: 'img-1',
1919
url: 'https://image.url/foo.jpg',
2020
thumbnailUrl: '',
21+
isPublic: true,
2122
metadata: {
2223
userName: 'alice',
2324
userAddress: '0xa',

src/components/profile/ProfileTabs/useProfileTabAvailability.spec.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,19 @@ import { useGetProfileAssetsQuery } from '../../../features/profile/profile.asse
33
import { useGetProfileCreationsQuery } from '../../../features/profile/profile.creations.client'
44
import { useGetProfilePlacesQuery } from '../../../features/profile/profile.places.client'
55
import { useGetProfileCommunitiesQuery } from '../../../features/profile/profile.social.client'
6-
import { useAuthIdentity } from '../../../hooks/useAuthIdentity'
76
import { useReelImagesByUser } from '../../../hooks/useReelImagesByUser'
87
import { useProfileTabAvailability } from './useProfileTabAvailability'
98

109
jest.mock('../../../features/profile/profile.assets.client', () => ({ useGetProfileAssetsQuery: jest.fn() }))
1110
jest.mock('../../../features/profile/profile.creations.client', () => ({ useGetProfileCreationsQuery: jest.fn() }))
1211
jest.mock('../../../features/profile/profile.places.client', () => ({ useGetProfilePlacesQuery: jest.fn() }))
1312
jest.mock('../../../features/profile/profile.social.client', () => ({ useGetProfileCommunitiesQuery: jest.fn() }))
14-
jest.mock('../../../hooks/useAuthIdentity', () => ({ useAuthIdentity: jest.fn() }))
1513
jest.mock('../../../hooks/useReelImagesByUser', () => ({ useReelImagesByUser: jest.fn() }))
1614

1715
const mockedUsePlaces = useGetProfilePlacesQuery as jest.MockedFunction<typeof useGetProfilePlacesQuery>
1816
const mockedUseCreations = useGetProfileCreationsQuery as jest.MockedFunction<typeof useGetProfileCreationsQuery>
1917
const mockedUseAssets = useGetProfileAssetsQuery as jest.MockedFunction<typeof useGetProfileAssetsQuery>
2018
const mockedUseCommunities = useGetProfileCommunitiesQuery as jest.MockedFunction<typeof useGetProfileCommunitiesQuery>
21-
const mockedUseAuthIdentity = useAuthIdentity as jest.MockedFunction<typeof useAuthIdentity>
2219
const mockedUseReelImages = useReelImagesByUser as jest.MockedFunction<typeof useReelImagesByUser>
2320

2421
const ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
@@ -28,7 +25,6 @@ const emptyPhotosResult = { images: [], total: 0, isLoading: false, error: null
2825

2926
describe('useProfileTabAvailability', () => {
3027
beforeEach(() => {
31-
mockedUseAuthIdentity.mockReturnValue({ identity: undefined } as unknown as ReturnType<typeof useAuthIdentity>)
3228
mockedUsePlaces.mockReturnValue(emptyQueryResult as unknown as ReturnType<typeof useGetProfilePlacesQuery>)
3329
mockedUseCreations.mockReturnValue(emptyQueryResult as unknown as ReturnType<typeof useGetProfileCreationsQuery>)
3430
mockedUseAssets.mockReturnValue(emptyQueryResult as unknown as ReturnType<typeof useGetProfileAssetsQuery>)
@@ -116,5 +112,11 @@ describe('useProfileTabAvailability', () => {
116112

117113
expect(result.current.hidden.size).toBe(0)
118114
})
115+
116+
it('should probe photos unsigned so private snapshots never reveal the tab', () => {
117+
renderHook(() => useProfileTabAvailability(ADDRESS, true))
118+
119+
expect(mockedUseReelImages).toHaveBeenCalledWith(ADDRESS, { limit: 1, offset: 0 })
120+
})
119121
})
120122
})

src/components/profile/ProfileTabs/useProfileTabAvailability.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { useGetProfileAssetsQuery } from '../../../features/profile/profile.asse
33
import { useGetProfileCreationsQuery } from '../../../features/profile/profile.creations.client'
44
import { useGetProfilePlacesQuery } from '../../../features/profile/profile.places.client'
55
import { useGetProfileCommunitiesQuery } from '../../../features/profile/profile.social.client'
6-
import { useAuthIdentity } from '../../../hooks/useAuthIdentity'
76
import { useReelImagesByUser } from '../../../hooks/useReelImagesByUser'
87
import type { ProfileTab } from './ProfileTabs.types'
98

@@ -17,16 +16,17 @@ interface TabAvailability {
1716
const PROBE_OPTIONS = { limit: 1, offset: 0 } as const
1817

1918
function useProfileTabAvailability(address: string, isOwnProfile: boolean): TabAvailability {
20-
const { identity } = useAuthIdentity()
21-
2219
const places = useGetProfilePlacesQuery({ address, limit: 1, offset: 0 })
2320
const wearables = useGetProfileCreationsQuery({ address, category: 'wearable', limit: 1, offset: 0 }, { skip: isOwnProfile })
2421
const emotes = useGetProfileCreationsQuery({ address, category: 'emote', limit: 1, offset: 0 }, { skip: isOwnProfile })
2522
const assets = useGetProfileAssetsQuery({ address, limit: 1, offset: 0 }, { skip: !isOwnProfile })
2623
// Member view gets the target user's publicly visible communities (public + listed) — the
2724
// endpoint only returns the full list (incl. private/unlisted) for the member themselves.
2825
const communities = useGetProfileCommunitiesQuery({ address, limit: 1, offset: 0 })
29-
const photos = useReelImagesByUser(address, PROBE_OPTIONS, isOwnProfile ? identity : undefined)
26+
// Probe public photos only (unsigned) so the photos tab is revealed on a member profile solely
27+
// when the user has public snapshots — matching what the gallery actually renders. On the own
28+
// profile the tab is always shown (see below), so this probe's result is unused there.
29+
const photos = useReelImagesByUser(address, PROBE_OPTIONS)
3030

3131
return useMemo(() => {
3232
// Reveal-on-data model: on a MEMBER profile every data-driven tab starts hidden and only

src/features/reels/reels.client.spec.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
const fetchWithIdentityMock = jest.fn()
12
jest.mock('../../utils/signedFetch', () => ({
2-
fetchWithIdentity: jest.fn(),
3-
fetchWithOptionalIdentity: jest.fn()
3+
fetchWithIdentity: (...args: unknown[]) => fetchWithIdentityMock(...args)
44
}))
55

6+
import type { AuthIdentity } from '@dcl/crypto'
67
import { clearImageCache, enrichWearables, fetchImageById, fetchImagesByUser, fetchProfileFaces, isMaticUrn } from './reels.client'
78

89
const envMock = jest.fn<string | undefined, [string]>((key: string) => {
@@ -32,6 +33,7 @@ afterAll(() => {
3233

3334
beforeEach(() => {
3435
fetchMock.mockReset()
36+
fetchWithIdentityMock.mockReset()
3537
clearImageCache()
3638
})
3739

@@ -74,6 +76,24 @@ describe('reels.client', () => {
7476
fetchMock.mockResolvedValue({ ok: false })
7577
await expect(fetchImagesByUser('0xabc', { limit: 24, offset: 0 })).rejects.toThrow()
7678
})
79+
80+
it('should sign the request with the identity when one is provided (owner gallery)', async () => {
81+
const identity = { authChain: [] } as unknown as AuthIdentity
82+
fetchWithIdentityMock.mockResolvedValue({ ok: true, json: async () => ({ images: [], currentImages: 0, maxImages: 0 }) })
83+
const signal = new AbortController().signal
84+
85+
await fetchImagesByUser('0xabc', { limit: 24, offset: 0 }, signal, identity)
86+
87+
expect(fetchWithIdentityMock).toHaveBeenCalledWith(
88+
'https://reels-test.local/api/users/0xabc/images?limit=24&offset=0',
89+
identity,
90+
'GET',
91+
undefined,
92+
undefined,
93+
signal
94+
)
95+
expect(fetchMock).not.toHaveBeenCalled()
96+
})
7797
})
7898

7999
describe('when classifying a URN as matic vs ethereum', () => {

src/features/reels/reels.client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ async function fetchImageById(id: string, signal?: AbortSignal): Promise<Image>
2626
return image
2727
}
2828

29+
// camera-reel-service has no visibility query param — it gates visibility purely by auth: a request
30+
// signed as the owner returns ALL images (public + private, each carrying `isPublic`), an unsigned
31+
// request returns only public ones. So pass `identity` ONLY when fetching the owner's own gallery
32+
// (so they can filter public/private client-side); never pass it for someone else's gallery, or it
33+
// would leak that user's private snapshots.
2934
async function fetchImagesByUser(
3035
address: string,
3136
options: FetchListOptions,

src/features/reels/reels.types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ interface Image {
3838
id: string
3939
url: string
4040
thumbnailUrl: string
41+
// Whether the owner published this snapshot to their public gallery. Only meaningful when the
42+
// listing was fetched signed-as-owner (the unsigned listing already excludes private images);
43+
// drives the owner-only visibility filter in the profile "My Photos" tab.
44+
isPublic: boolean
4145
metadata: ImageMetadata
4246
}
4347

src/hooks/useReelImagesByUser.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,13 @@ describe('useReelImagesByUser', () => {
4141
expect(result.current.error?.message).toBe('boom')
4242
expect(result.current.images).toEqual([])
4343
})
44+
45+
it('should forward the identity to the fetch so the owner gallery is signed', async () => {
46+
const identity = { authChain: [] } as unknown as Parameters<typeof useReelImagesByUser>[2]
47+
reelsMock.fetchImagesByUser.mockResolvedValue({ images: [], currentImages: 0, maxImages: 0 })
48+
renderHook(() => useReelImagesByUser('0xabc', { limit: 24, offset: 0 }, identity))
49+
await waitFor(() => expect(reelsMock.fetchImagesByUser).toHaveBeenCalled())
50+
expect(reelsMock.fetchImagesByUser).toHaveBeenCalledWith('0xabc', { limit: 24, offset: 0 }, expect.anything(), identity)
51+
})
4452
})
4553
})

src/hooks/useReelImagesByUser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ interface ReelImagesState {
1010
error: Error | null
1111
}
1212

13+
// Pass `identity` only to fetch the signed-in user's OWN gallery (returns public + private). Leave it
14+
// undefined for anyone else's gallery so the unsigned listing returns public images only.
1315
const useReelImagesByUser = (address: string | undefined, options: FetchListOptions, identity?: AuthIdentity): ReelImagesState => {
1416
const [state, setState] = useState<ReelImagesState>({
1517
images: [],

src/intl/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1583,7 +1583,11 @@
15831583
"empty_member": "This member has not taken any photos yet.",
15841584
"count": "{count} photos",
15851585
"empty_owner_subtitle": "Jump in and start collecting memories! Press 'C' to open the Camera and 'Space bar' to snap a photo.",
1586-
"empty_owner_cta": "Jump in"
1586+
"empty_owner_cta": "Jump in",
1587+
"filter_all": "All",
1588+
"filter_public": "Public",
1589+
"filter_private": "Private",
1590+
"empty_filter": "No photos match this filter."
15871591
},
15881592
"referral_rewards": {
15891593
"anonymous": "Sign in to invite friends and unlock rewards.",

0 commit comments

Comments
 (0)