Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions app/composables/atproto/useProfileLikes.ts

This file was deleted.

61 changes: 53 additions & 8 deletions app/pages/profile/[identity]/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { useInfiniteScroll } from '@vueuse/core'
import { updateProfile as updateProfileUtil } from '~/utils/atproto/profile'
import { fetchProfileLikes } from '~/utils/atproto/likes'
import type { CommandPaletteContextCommandInput } from '~/types/command-palette'
import { getSafeHttpUrl } from '#shared/utils/url'

Expand Down Expand Up @@ -79,13 +81,38 @@ async function updateProfile() {
}
}

const { data: likes, status } = useProfileLikes(identity)
const allLikesRecords = ref<string[]>([])
// undefined = not yet fetched, string = next page cursor, null = no more pages
const likesCursor = shallowRef<string | null | undefined>(undefined)
const likesError = shallowRef(false)

const hasMoreLikes = computed(() => typeof likesCursor.value === 'string')
const isLoadingInitialLikes = computed(() => likesCursor.value === undefined && !likesError.value)

const { isLoading: likesLoadingMore } = useInfiniteScroll(
() => (import.meta.client ? window : null),
async () => {
try {
const result = await fetchProfileLikes(identity.value, likesCursor.value ?? null, 20)
allLikesRecords.value = [...allLikesRecords.value, ...(result.likes ?? [])]
likesCursor.value = result.cursor ?? null
} catch {
likesError.value = true
}
},
{
distance: 200,
// undefined (initial) β†’ allow load; null (exhausted) β†’ stop
canLoadMore: () => likesCursor.value !== null,
},
)

const showInviteSection = computed(() => {
return (
profile.value.recordExists === false &&
status.value === 'success' &&
!likes.value?.records?.length &&
!likesError.value &&
allLikesRecords.value.length === 0 &&
likesCursor.value !== undefined &&
!userPending.value &&
user.value?.handle !== profile.value.handle
)
Expand Down Expand Up @@ -239,18 +266,36 @@ defineOgImage(
dir="ltr"
>
{{ $t('profile.likes') }}
<span v-if="likes">({{ likes.records?.length ?? 0 }})</span>
<span>({{ allLikesRecords.length ?? 0 }})</span>
</h2>
<div v-if="status === 'pending'" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div v-if="isLoadingInitialLikes" class="flex flex-col gap-4">
<SkeletonBlock v-for="i in 4" :key="i" class="h-16 rounded-lg" />
</div>
<div v-else-if="status === 'error'">
<div v-else-if="likesError">
<p>{{ $t('common.error') }}</p>
</div>
<div v-else-if="likes?.records" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<PackageLikeCard v-for="like in likes.records" :packageUrl="like.value.subjectRef" />
<template v-else-if="allLikesRecords.length > 0">
<ol class="list-none m-0 p-0 grid grid-cols-1 lg:grid-cols-2 gap-4">
<li v-for="like in allLikesRecords" :key="like">
<PackageLikeCard :packageUrl="like" />
</li>
</ol>
</template>

<!-- Loading more indicator -->
<div v-if="likesLoadingMore" class="flex items-center justify-center py-4 gap-2">
<span class="i-svg-spinners:ring-resize w-4 h-4" aria-hidden="true" />
<span class="text-fg-muted text-sm">{{ $t('common.loading_more') }}</span>
</div>

<!-- End of results -->
<p
v-else-if="!hasMoreLikes && allLikesRecords.length > 0"
class="py-4 text-center text-fg-subtle font-mono text-sm"
>
{{ $t('common.end_of_results') }}
</p>

<!-- Invite section: shown when user does not have npmx profile or any like lexicons -->
<div
v-if="showInviteSection"
Expand Down
15 changes: 15 additions & 0 deletions app/utils/atproto/likes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,18 @@ export async function togglePackageLike(
? unlikePackage(packageName, userHandle)
: likePackage(packageName, userHandle)
}

/**
* Fetches paginated profile likes for a given handle.
*/
export async function fetchProfileLikes(handle: string, cursor?: string | null, limit = 20) {
try {
return await $fetch(`/api/social/profile/${handle}/likes`, {
query: { cursor, limit },
})
} catch (e) {
// oxlint-disable-next-line no-console -- error logging
console.error('failed to fetch profile likes', handle, cursor, limit, e)
return { cursor: null, likes: null }
}
}
15 changes: 14 additions & 1 deletion server/api/social/profile/[identifier]/likes.get.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getQuery, getRouterParam } from 'h3'
import { IdentityUtils } from '#server/utils/atproto/utils/identity'

export default defineEventHandler(async event => {
Expand All @@ -9,9 +10,21 @@ export default defineEventHandler(async event => {
})
}

const query = getQuery(event)
const cursor = typeof query.cursor === 'string' ? query.cursor : undefined
const parsedLimit = typeof query.limit === 'string' ? Number(query.limit) : NaN
const limit = Number.isNaN(parsedLimit) ? 20 : Math.min(Math.max(parsedLimit, 1), 100)

const utils = new IdentityUtils()
const minidoc = await utils.getMiniDoc(identifier)
const likesUtil = new PackageLikesUtils()

return likesUtil.getUserLikes(minidoc)
const likes = await likesUtil.getUserLikes(minidoc, limit, cursor ?? undefined)

return {
cursor: likes.cursor,
likes: likes.records
.map(record => record.value.subjectRef)
.filter(url => typeof url === 'string'),
}
})
3 changes: 3 additions & 0 deletions server/utils/atproto/utils/likes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,18 +289,21 @@ export class PackageLikesUtils {
* Gets a list of likes for a user. Newest first
* @param miniDoc
* @param limit
* @param cursor
* @returns
*/
async getUserLikes(
miniDoc: blue.microcosm.identity.resolveMiniDoc.$OutputBody,
limit: number = 10,
cursor?: string,
) {
const client = new Client(miniDoc.pds, {
headers: { 'User-Agent': 'npmx' },
})
const result = await client.list(dev.npmx.feed.like, {
limit,
repo: miniDoc.did,
cursor,
})
return result
}
Expand Down
29 changes: 6 additions & 23 deletions test/nuxt/components/ProfileInviteSection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import type { VueWrapper } from '@vue/test-utils'
import { mockNuxtImport, mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockUseProfileLikes } = vi.hoisted(() => ({
mockUseProfileLikes: vi.fn(),
}))

mockNuxtImport('useProfileLikes', () => mockUseProfileLikes)

import ProfilePage from '~/pages/profile/[identity]/index.vue'

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

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

registerEndpoint('/api/social/profile/test-handle/likes', () => ({
cursor: null,
likes: [],
}))

describe('Profile invite section', () => {
let wrapper: VueWrapper | undefined

beforeEach(() => {
clearNuxtData()
authSessionHandler = () => null
mockUseProfileLikes.mockReset()
})

afterEach(() => {
Expand All @@ -51,11 +49,6 @@ describe('Profile invite section', () => {
resolveAuthSession = () => resolve(null)
})

mockUseProfileLikes.mockReturnValue({
data: ref({ records: [] }),
status: ref('success'),
})

const component = await mountSuspended(ProfilePage, {
route: '/profile/test-handle',
})
Expand All @@ -68,11 +61,6 @@ describe('Profile invite section', () => {
it('shows invite section after auth resolves for non-owner', async () => {
authSessionHandler = () => createAtprotoUser('other-user')

mockUseProfileLikes.mockReturnValue({
data: ref({ records: [] }),
status: ref('success'),
})

const component = await mountSuspended(ProfilePage, {
route: '/profile/test-handle',
})
Expand All @@ -90,11 +78,6 @@ describe('Profile invite section', () => {
return createAtprotoUser('test-handle')
}

mockUseProfileLikes.mockReturnValue({
data: ref({ records: [] }),
status: ref('success'),
})

const component = await mountSuspended(ProfilePage, {
route: '/profile/test-handle',
})
Expand Down
Loading