Skip to content
Draft
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
15 changes: 14 additions & 1 deletion app/components/Package/Likes.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ const topLikedBadgeLabel = computed(() =>

const isLikeActionPending = shallowRef(false)

const isCountTruncated = computed(() => (likesData.value?.totalLikes ?? 0) >= 1000)

const likeAction = async () => {
if (user.value?.handle == null) {
authModal.open()
Expand Down Expand Up @@ -154,7 +156,18 @@ const likeAction = async () => {
class="i-svg-spinners:ring-resize w-3 h-3 my-0.5"
aria-hidden="true"
/>
<span v-else>{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}</span>
<span v-else class="inline-flex items-center gap-0.5">
{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
<span
v-if="isCountTruncated"
class="text-fg-subtle text-3xs leading-none"
:aria-label="
$t('package.likes.count_truncated', { count: likesData?.totalLikes ?? 0 })
"
>
+
</span>
</span>
</ButtonBase>
</div>
</TooltipApp>
Expand Down
98 changes: 90 additions & 8 deletions app/pages/profile/[identity]/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
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 +80,85 @@ async function updateProfile() {
}
}

const { data: likes, status } = useProfileLikes(identity)
const allLikesRecords = ref<Array<{ value: { subjectRef: string } }>>([])
const likesCursor = shallowRef<string | null>(null)
const likesLoadingMore = shallowRef(false)
const likesError = shallowRef(false)

async function loadInitialLikes() {
try {
const result = await fetchProfileLikes(identity.value, null, 20)
allLikesRecords.value = result.records
likesCursor.value = result.cursor
likesError.value = false
} catch {
likesError.value = true
}
}

async function loadMoreLikes() {
if (likesLoadingMore.value || !likesCursor.value) return
likesLoadingMore.value = true
try {
const result = await fetchProfileLikes(identity.value, likesCursor.value, 20)
allLikesRecords.value = [...allLikesRecords.value, ...result.records]
likesCursor.value = result.cursor
} catch {
likesError.value = true
} finally {
likesLoadingMore.value = false
}
}

const hasMoreLikes = computed(() => likesCursor.value !== null)
const isLoadingInitialLikes = computed(
() => allLikesRecords.value.length === 0 && !likesError.value,
)

onMounted(() => {
loadInitialLikes()
})

let observer: IntersectionObserver | null = null

onUnmounted(() => {
if (observer) {
observer.disconnect()
observer = null
}
})

function setupInfiniteScroll() {
if (observer) {
observer.disconnect()
}
observer = new IntersectionObserver(
entries => {
const target = entries[0]
if (target?.isIntersecting && hasMoreLikes.value && !likesLoadingMore.value) {
loadMoreLikes()
}
},
{ rootMargin: '200px' },
)

nextTick(() => {
const sentinel = document.getElementById('likes-scroll-sentinel')
if (sentinel && observer) {
observer.observe(sentinel)
}
})
}

watch(allLikesRecords, () => {
setupInfiniteScroll()
})

const showInviteSection = computed(() => {
return (
profile.value.recordExists === false &&
status.value === 'success' &&
!likes.value?.records?.length &&
!likesError.value &&
allLikesRecords.value.length === 0 &&
!userPending.value &&
user.value?.handle !== profile.value.handle
)
Expand Down Expand Up @@ -239,18 +312,27 @@ 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="grid grid-cols-1 lg:grid-cols-2 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" />
<div v-else-if="allLikesRecords.length > 0" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<PackageLikeCard v-for="like in allLikesRecords" :packageUrl="like.value.subjectRef" />
</div>

<!-- Loading more indicator for infinite scroll -->
<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') }}</span>
</div>

<!-- Scroll sentinel for intersection observer -->
<div id="likes-scroll-sentinel" class="h-1" />

<!-- Invite section: shown when user does not have npmx profile or any like lexicons -->
<div
v-if="showInviteSection"
Expand Down
36 changes: 36 additions & 0 deletions app/utils/atproto/likes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { FetchError } from 'ofetch'
import { handleAuthError } from '~/utils/atproto/helpers'
import type { PackageLikes } from '#shared/types/social'

export type PaginatedProfileLikes = {
records: {
value: {
subjectRef: string
}
}[]
cursor: string | null
hasNextPage: boolean
}

type LikeResult = { success: true; data: PackageLikes } | { success: false; error: Error }

/**
Expand Down Expand Up @@ -52,3 +62,29 @@ 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,
): Promise<PaginatedProfileLikes> {
const params = new URLSearchParams({ limit: String(limit) })
if (cursor) {
params.set('cursor', cursor)
}

try {
const result = await $fetch<PaginatedProfileLikes>(
`/api/social/profile/${handle}/likes?${params.toString()}`,
)
return result
} catch (e) {
if (e instanceof FetchError) {
await handleAuthError(e, undefined)
}
return { records: [], cursor: null, hasNextPage: false }
}
}
7 changes: 6 additions & 1 deletion server/api/social/profile/[identifier]/likes.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ export default defineEventHandler(async event => {
})
}

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

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

return likesUtil.getUserLikes(minidoc)
return likesUtil.getUserLikes(minidoc, limit, cursor)
})
Loading