Skip to content

Commit 4f3c588

Browse files
gittihub-jpgautofix-ci[bot]ghostdevv
authored
fix: profile page only shows user's 10 most recent likes (#2820)
Signed-off-by: gittihub-jpg <rico@springer-mail.net> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Willow (GHOST) <git@willow.sh>
1 parent ef6b240 commit 4f3c588

6 files changed

Lines changed: 91 additions & 45 deletions

File tree

app/composables/atproto/useProfileLikes.ts

Lines changed: 0 additions & 13 deletions
This file was deleted.

app/pages/profile/[identity]/index.vue

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<script setup lang="ts">
2+
import { useInfiniteScroll } from '@vueuse/core'
23
import { updateProfile as updateProfileUtil } from '~/utils/atproto/profile'
4+
import { fetchProfileLikes } from '~/utils/atproto/likes'
35
import type { CommandPaletteContextCommandInput } from '~/types/command-palette'
46
import { getSafeHttpUrl } from '#shared/utils/url'
57
@@ -79,13 +81,38 @@ async function updateProfile() {
7981
}
8082
}
8183
82-
const { data: likes, status } = useProfileLikes(identity)
84+
const allLikesRecords = ref<string[]>([])
85+
// undefined = not yet fetched, string = next page cursor, null = no more pages
86+
const likesCursor = shallowRef<string | null | undefined>(undefined)
87+
const likesError = shallowRef(false)
88+
89+
const hasMoreLikes = computed(() => typeof likesCursor.value === 'string')
90+
const isLoadingInitialLikes = computed(() => likesCursor.value === undefined && !likesError.value)
91+
92+
const { isLoading: likesLoadingMore } = useInfiniteScroll(
93+
() => (import.meta.client ? window : null),
94+
async () => {
95+
try {
96+
const result = await fetchProfileLikes(identity.value, likesCursor.value ?? null, 20)
97+
allLikesRecords.value = [...allLikesRecords.value, ...(result.likes ?? [])]
98+
likesCursor.value = result.cursor ?? null
99+
} catch {
100+
likesError.value = true
101+
}
102+
},
103+
{
104+
distance: 200,
105+
// undefined (initial) → allow load; null (exhausted) → stop
106+
canLoadMore: () => likesCursor.value !== null,
107+
},
108+
)
83109
84110
const showInviteSection = computed(() => {
85111
return (
86112
profile.value.recordExists === false &&
87-
status.value === 'success' &&
88-
!likes.value?.records?.length &&
113+
!likesError.value &&
114+
allLikesRecords.value.length === 0 &&
115+
likesCursor.value !== undefined &&
89116
!userPending.value &&
90117
user.value?.handle !== profile.value.handle
91118
)
@@ -239,18 +266,36 @@ defineOgImage(
239266
dir="ltr"
240267
>
241268
{{ $t('profile.likes') }}
242-
<span v-if="likes">({{ likes.records?.length ?? 0 }})</span>
269+
<span>({{ allLikesRecords.length ?? 0 }})</span>
243270
</h2>
244-
<div v-if="status === 'pending'" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
271+
<div v-if="isLoadingInitialLikes" class="flex flex-col gap-4">
245272
<SkeletonBlock v-for="i in 4" :key="i" class="h-16 rounded-lg" />
246273
</div>
247-
<div v-else-if="status === 'error'">
274+
<div v-else-if="likesError">
248275
<p>{{ $t('common.error') }}</p>
249276
</div>
250-
<div v-else-if="likes?.records" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
251-
<PackageLikeCard v-for="like in likes.records" :packageUrl="like.value.subjectRef" />
277+
<template v-else-if="allLikesRecords.length > 0">
278+
<ol class="list-none m-0 p-0 grid grid-cols-1 lg:grid-cols-2 gap-4">
279+
<li v-for="like in allLikesRecords" :key="like">
280+
<PackageLikeCard :packageUrl="like" />
281+
</li>
282+
</ol>
283+
</template>
284+
285+
<!-- Loading more indicator -->
286+
<div v-if="likesLoadingMore" class="flex items-center justify-center py-4 gap-2">
287+
<span class="i-svg-spinners:ring-resize w-4 h-4" aria-hidden="true" />
288+
<span class="text-fg-muted text-sm">{{ $t('common.loading_more') }}</span>
252289
</div>
253290

291+
<!-- End of results -->
292+
<p
293+
v-else-if="!hasMoreLikes && allLikesRecords.length > 0"
294+
class="py-4 text-center text-fg-subtle font-mono text-sm"
295+
>
296+
{{ $t('common.end_of_results') }}
297+
</p>
298+
254299
<!-- Invite section: shown when user does not have npmx profile or any like lexicons -->
255300
<div
256301
v-if="showInviteSection"

app/utils/atproto/likes.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,18 @@ export async function togglePackageLike(
5252
? unlikePackage(packageName, userHandle)
5353
: likePackage(packageName, userHandle)
5454
}
55+
56+
/**
57+
* Fetches paginated profile likes for a given handle.
58+
*/
59+
export async function fetchProfileLikes(handle: string, cursor?: string | null, limit = 20) {
60+
try {
61+
return await $fetch(`/api/social/profile/${handle}/likes`, {
62+
query: { cursor, limit },
63+
})
64+
} catch (e) {
65+
// oxlint-disable-next-line no-console -- error logging
66+
console.error('failed to fetch profile likes', handle, cursor, limit, e)
67+
return { cursor: null, likes: null }
68+
}
69+
}

server/api/social/profile/[identifier]/likes.get.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getQuery, getRouterParam } from 'h3'
12
import { IdentityUtils } from '#server/utils/atproto/utils/identity'
23

34
export default defineEventHandler(async event => {
@@ -9,9 +10,21 @@ export default defineEventHandler(async event => {
910
})
1011
}
1112

13+
const query = getQuery(event)
14+
const cursor = typeof query.cursor === 'string' ? query.cursor : undefined
15+
const parsedLimit = typeof query.limit === 'string' ? Number(query.limit) : NaN
16+
const limit = Number.isNaN(parsedLimit) ? 20 : Math.min(Math.max(parsedLimit, 1), 100)
17+
1218
const utils = new IdentityUtils()
1319
const minidoc = await utils.getMiniDoc(identifier)
1420
const likesUtil = new PackageLikesUtils()
1521

16-
return likesUtil.getUserLikes(minidoc)
22+
const likes = await likesUtil.getUserLikes(minidoc, limit, cursor ?? undefined)
23+
24+
return {
25+
cursor: likes.cursor,
26+
likes: likes.records
27+
.map(record => record.value.subjectRef)
28+
.filter(url => typeof url === 'string'),
29+
}
1730
})

server/utils/atproto/utils/likes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,18 +289,21 @@ export class PackageLikesUtils {
289289
* Gets a list of likes for a user. Newest first
290290
* @param miniDoc
291291
* @param limit
292+
* @param cursor
292293
* @returns
293294
*/
294295
async getUserLikes(
295296
miniDoc: blue.microcosm.identity.resolveMiniDoc.$OutputBody,
296297
limit: number = 10,
298+
cursor?: string,
297299
) {
298300
const client = new Client(miniDoc.pds, {
299301
headers: { 'User-Agent': 'npmx' },
300302
})
301303
const result = await client.list(dev.npmx.feed.like, {
302304
limit,
303305
repo: miniDoc.did,
306+
cursor,
304307
})
305308
return result
306309
}

test/nuxt/components/ProfileInviteSection.spec.ts

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
import type { VueWrapper } from '@vue/test-utils'
2-
import { mockNuxtImport, mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
2+
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
44

5-
const { mockUseProfileLikes } = vi.hoisted(() => ({
6-
mockUseProfileLikes: vi.fn(),
7-
}))
8-
9-
mockNuxtImport('useProfileLikes', () => mockUseProfileLikes)
10-
115
import ProfilePage from '~/pages/profile/[identity]/index.vue'
126

137
function createAtprotoUser(handle: string) {
@@ -30,13 +24,17 @@ registerEndpoint('/api/social/profile/test-handle', () => ({
3024

3125
registerEndpoint('/api/auth/session', () => authSessionHandler())
3226

27+
registerEndpoint('/api/social/profile/test-handle/likes', () => ({
28+
cursor: null,
29+
likes: [],
30+
}))
31+
3332
describe('Profile invite section', () => {
3433
let wrapper: VueWrapper | undefined
3534

3635
beforeEach(() => {
3736
clearNuxtData()
3837
authSessionHandler = () => null
39-
mockUseProfileLikes.mockReset()
4038
})
4139

4240
afterEach(() => {
@@ -51,11 +49,6 @@ describe('Profile invite section', () => {
5149
resolveAuthSession = () => resolve(null)
5250
})
5351

54-
mockUseProfileLikes.mockReturnValue({
55-
data: ref({ records: [] }),
56-
status: ref('success'),
57-
})
58-
5952
const component = await mountSuspended(ProfilePage, {
6053
route: '/profile/test-handle',
6154
})
@@ -68,11 +61,6 @@ describe('Profile invite section', () => {
6861
it('shows invite section after auth resolves for non-owner', async () => {
6962
authSessionHandler = () => createAtprotoUser('other-user')
7063

71-
mockUseProfileLikes.mockReturnValue({
72-
data: ref({ records: [] }),
73-
status: ref('success'),
74-
})
75-
7664
const component = await mountSuspended(ProfilePage, {
7765
route: '/profile/test-handle',
7866
})
@@ -90,11 +78,6 @@ describe('Profile invite section', () => {
9078
return createAtprotoUser('test-handle')
9179
}
9280

93-
mockUseProfileLikes.mockReturnValue({
94-
data: ref({ records: [] }),
95-
status: ref('success'),
96-
})
97-
9881
const component = await mountSuspended(ProfilePage, {
9982
route: '/profile/test-handle',
10083
})

0 commit comments

Comments
 (0)