Skip to content
Open
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
1 change: 1 addition & 0 deletions app/components/Header/AccountMenu.client.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useAtproto } from '~/composables/atproto/useAtproto'
import { useModal } from '~/composables/useModal'

const {
Expand Down
27 changes: 6 additions & 21 deletions app/components/Header/AuthModal.client.vue
Original file line number Diff line number Diff line change
@@ -1,37 +1,22 @@
<script setup lang="ts">
import { useAtproto } from '~/composables/atproto/useAtproto'
import { authRedirect } from '~/utils/atproto/helpers'

const handleInput = shallowRef('')

const { user, logout } = useAtproto()

async function handleBlueskySignIn() {
await navigateTo(
{
path: '/api/auth/atproto',
query: { handle: 'https://bsky.social' },
},
{ external: true },
)
await authRedirect('https://bsky.social')
}

async function handleCreateAccount() {
await navigateTo(
{
path: '/api/auth/atproto',
query: { handle: 'https://npmx.social', create: 'true' },
},
{ external: true },
)
await authRedirect('https://npmx.social', true)
}

async function handleLogin() {
if (handleInput.value) {
await navigateTo(
{
path: '/api/auth/atproto',
query: { handle: handleInput.value },
},
{ external: true },
)
await authRedirect(handleInput.value)
}
}
</script>
Expand Down
1 change: 1 addition & 0 deletions app/components/Header/MobileMenu.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import { useAtproto } from '~/composables/atproto/useAtproto'

const isOpen = defineModel<boolean>('open', { default: false })

Expand Down
File renamed without changes.
69 changes: 69 additions & 0 deletions app/pages/package/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { areUrlsEquivalent } from '#shared/utils/url'
import { isEditableElement } from '~/utils/input'
import { formatBytes } from '~/utils/formatters'
import { NuxtLink } from '#components'
import { useModal } from '~/composables/useModal'
import { useAtproto } from '~/composables/atproto/useAtproto'
import { togglePackageLike } from '~/utils/atproto/likes'

definePageMeta({
name: 'package',
Expand Down Expand Up @@ -352,6 +355,54 @@ const canonicalUrl = computed(() => {
return requestedVersion.value ? `${base}/v/${requestedVersion.value}` : base
})

//atproto
// TODO: Maybe set this where it's not loaded here every load?
const { user } = useAtproto()

const authModal = useModal('auth-modal')

const { data: likesData } = useFetch(() => `/api/social/likes/${packageName.value}`, {
default: () => ({ totalLikes: 0, userHasLiked: false }),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should probably be client-side only:

Suggested change
default: () => ({ totalLikes: 0, userHasLiked: false }),
default: () => ({ totalLikes: 0, userHasLiked: false }),
server: false,

server: false,
})

const isLikeActionPending = ref(false)

const likeAction = async () => {
if (user.value?.handle == null) {
authModal.open()
return
}

if (isLikeActionPending.value) return

const currentlyLiked = likesData.value?.userHasLiked ?? false
const currentLikes = likesData.value?.totalLikes ?? 0

// Optimistic update
likesData.value = {
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
userHasLiked: !currentlyLiked,
}

isLikeActionPending.value = true

const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)

isLikeActionPending.value = false

if (result.success) {
// Update with server response
likesData.value = result.data
} else {
// Revert on error
likesData.value = {
totalLikes: currentLikes,
userHasLiked: currentlyLiked,
}
}
}

useHead({
link: [{ rel: 'canonical', href: canonicalUrl }],
})
Expand Down Expand Up @@ -501,6 +552,24 @@ defineOgImageComponent('Package', {
</template>
</ClientOnly>

<button
@click="likeAction"
type="button"
class="inline-flex items-center gap-1.5 font-mono text-sm text-fg hover:text-fg-muted transition-colors duration-200"
:title="$t('package.links.like')"
>
<span
:class="
likesData?.userHasLiked
? 'i-lucide-heart-minus text-red-500'
: 'i-lucide-heart-plus'
"
class="w-4 h-4"
aria-hidden="true"
/>
<span>{{ formatCompactNumber(likesData?.totalLikes ?? 0, { decimals: 1 }) }}</span>
</button>

<!-- Internal navigation: Docs + Code + Compare (hidden on mobile, shown in external links instead) -->
<nav
v-if="resolvedVersion"
Expand Down
30 changes: 30 additions & 0 deletions app/utils/atproto/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { FetchError } from 'ofetch'
import type { LocationQueryRaw } from 'vue-router'

/**
* Redirect user to ATProto authentication
*/
export async function authRedirect(identifier: string, create: boolean = false) {
let query: LocationQueryRaw = { handle: identifier }
if (create) {
query = { ...query, create: 'true' }
}
await navigateTo(
{
path: '/api/auth/atproto',
query,
},
{ external: true },
)
}

export async function handleAuthError(
fetchError: FetchError,
userHandle?: string | null,
): Promise<never> {
const errorMessage = fetchError?.data?.message
if (errorMessage === ERROR_NEED_REAUTH && userHandle) {
await authRedirect(userHandle)
}
throw fetchError
}
60 changes: 60 additions & 0 deletions app/utils/atproto/likes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { FetchError } from 'ofetch'
import { handleAuthError } from '~/utils/atproto/helpers'
import type { PackageLikes } from '#shared/types/social'

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

/**
* Like a package via the API
*/
export async function likePackage(
packageName: string,
userHandle?: string | null,
): Promise<LikeResult> {
try {
const result = await $fetch<PackageLikes>('/api/social/like', {
method: 'POST',
body: { packageName },
})
return { success: true, data: result }
} catch (e) {
if (e instanceof FetchError) {
await handleAuthError(e, userHandle)
}
return { success: false, error: e as Error }
}
}

/**
* Unlike a package via the API
*/
export async function unlikePackage(
packageName: string,
userHandle?: string | null,
): Promise<LikeResult> {
try {
const result = await $fetch<PackageLikes>('/api/social/like', {
method: 'DELETE',
body: { packageName },
})
return { success: true, data: result }
} catch (e) {
if (e instanceof FetchError) {
await handleAuthError(e, userHandle)
}
return { success: false, error: e as Error }
}
}

/**
* Toggle like status for a package
*/
export async function togglePackageLike(
packageName: string,
currentlyLiked: boolean,
userHandle?: string | null,
): Promise<LikeResult> {
return currentlyLiked
? unlikePackage(packageName, userHandle)
: likePackage(packageName, userHandle)
}
9 changes: 9 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ export default defineNuxtConfig({
driver: 'fsLite',
base: './.cache/atproto-oauth/session',
},
'generic-cache': {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably also need to update modules/cache.ts to configure production redis

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May have to leave that one to you 😅. Do you have something to read up on that? The local generic-cache defined there is used just locally for dev and then uses upstash redis like you did for the oauth session lock when in production if that makes a difference.

driver: 'fsLite',
base: './.cache/generic',
},
},
typescript: {
tsConfig: {
Expand Down Expand Up @@ -270,6 +274,11 @@ export default defineNuxtConfig({
'virtua/vue',
'semver',
'validate-npm-package-name',
'@atproto/lex',
'@atproto/lex-data',
'@atproto/lex-json',
'@atproto/lex-schema',
'@atproto/lex-client',
],
},
},
Expand Down
2 changes: 1 addition & 1 deletion server/api/auth/atproto.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default defineEventHandler(async event => {
if (!config.sessionPassword) {
throw createError({
status: 500,
message: 'NUXT_SESSION_PASSWORD not set',
message: UNSET_NUXT_SESSION_PASSWORD,
})
}

Expand Down
41 changes: 41 additions & 0 deletions server/api/social/like.delete.ts
Copy link
Contributor

@alexdln alexdln Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about making xrpc routes (sth like /xrpc/npmx.feed.like.create)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now we're just using the the server side oauth client so if we made this endpoint an XRPC it wouldn't be quite correct since it uses the cookie for authentication. But there is a plan for some XRPC endpoints for other things just need to make a middleware for it to authenticate the service auth jwt

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think all the social/ endpoints need to live outside of the auth/ directory

they're not auth related, they just rely on a session.

  • auth/* anything to do with actually authenticating (login, logout, session, etc)
  • social/* anything to do with social, whether it requires a session or not

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as v from 'valibot'
import { Client } from '@atproto/lex'
import * as dev from '#shared/types/lexicons/dev'
import { PackageLikeBodySchema } from '#shared/schemas/social'
import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'

export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
const loggedInUsersDid = oAuthSession?.did.toString()

if (!oAuthSession || !loggedInUsersDid) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}

//Checks if the user has a scope to like packages
await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE)

const body = v.parse(PackageLikeBodySchema, await readBody(event))

const likesUtil = new PackageLikesUtils()

const getTheUsersLikedRecord = await likesUtil.getTheUsersLikedRecord(
body.packageName,
loggedInUsersDid,
)

if (getTheUsersLikedRecord) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if it isn't truthy? somehow

feels like something would have to be very wrong for that to happen, but we should log an error if so

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same. I think some of this like logic is kind of "let's see what all comes of it stage". Do we have something for logging or is it just console.error?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think just a regular console.error for now

They should show up in the vercel logs

const client = new Client(oAuthSession)

await client.delete(dev.npmx.feed.like, {
rkey: getTheUsersLikedRecord.rkey,
})
var result = await likesUtil.unlikeAPackageAndReturnLikes(body.packageName, loggedInUsersDid)
return result
}

console.warn(
`User ${loggedInUsersDid} tried to unlike a package ${body.packageName} but it was not liked by them.`,
)

return await likesUtil.getLikes(body.packageName, loggedInUsersDid)
})
46 changes: 46 additions & 0 deletions server/api/social/like.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as v from 'valibot'
import { Client } from '@atproto/lex'
import * as dev from '#shared/types/lexicons/dev'
import type { UriString } from '@atproto/lex'
import { LIKES_SCOPE } from '#shared/utils/constants'
import { PackageLikeBodySchema } from '#shared/schemas/social'
import { throwOnMissingOAuthScope } from '~~/server/utils/atproto/oauth'

export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
const loggedInUsersDid = oAuthSession?.did.toString()

if (!oAuthSession || !loggedInUsersDid) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}

//Checks if the user has a scope to like packages
await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE)

const body = v.parse(PackageLikeBodySchema, await readBody(event))

const likesUtil = new PackageLikesUtils()

// Checks to see if the user has liked the package already
const likesResult = await likesUtil.getLikes(body.packageName, loggedInUsersDid)
if (likesResult.userHasLiked) {
return likesResult
}

const subjectRef = PACKAGE_SUBJECT_REF(body.packageName)
const client = new Client(oAuthSession)

const like = dev.npmx.feed.like.$build({
createdAt: new Date().toISOString(),
subjectRef: subjectRef as UriString,
})

const result = await client.create(dev.npmx.feed.like, like)
if (!result) {
throw createError({
status: 500,
message: 'Failed to create a like',
})
}

return await likesUtil.likeAPackageAndReturnLikes(body.packageName, loggedInUsersDid, result.uri)
})
12 changes: 12 additions & 0 deletions server/api/social/likes/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default eventHandlerWithOAuthSession(async (event, oAuthSession, _) => {
const packageName = getRouterParam(event, 'pkg')
if (!packageName) {
throw createError({
status: 400,
message: 'package name not provided',
})
}

const likesUtil = new PackageLikesUtils()
return await likesUtil.getLikes(packageName, oAuthSession?.did.toString())
})
Loading
Loading