Skip to content

Comments

feat: package likes#712

Merged
danielroe merged 30 commits intonpmx-dev:mainfrom
fatfingers23:feat/likes
Feb 3, 2026
Merged

feat: package likes#712
danielroe merged 30 commits intonpmx-dev:mainfrom
fatfingers23:feat/likes

Conversation

@fatfingers23
Copy link
Contributor

@fatfingers23 fatfingers23 commented Feb 2, 2026

Our first social feature 🥳 This closes #79

Adds

  • Likes and unlikes
  • Generic cache that uses useStorage locally and redis in prod with set expire
  • Caches on the Constellation requests to return total counts faster since Constellation cannot show counts until it indexs it and that takes a few milliseconds to seconds depending on traffic
  • Some more helpers for working with atproto for checking for scopes, redirecting to login
  • Since we have added a scope the client needs to re-authenticate with the new one if a user was already logged in. Right now if it sees the logged in client does not have the new scope it directs to the PDS to reauth.
  • If the user likes without being logged in shows them the auth modal

Could be Improved

  • Not sure on the icon. Went with what I could find. I think a filled one may work better and an animation would be nice.
  • I think the caching layer I added for Likes could be abstracted out for better use with constellation. Constellation is the source of truth, but we need caching at times for faster feed back. Will have to think on how that will work
  • The redirect for scopes reauth could probably be improved to let the user know?
  • Auth always redirects back to main page. I think we could probably add an item to add some state where on redirect it loads the previous page

Concerns

  • Race conditions on likes and many users. Interested to see how it holds up with a lot of users at once. And just genreal race condtions of state
  • I'm not familiar with how Nuxt handles dependencies and not sure if the way I did the Redis connection will reuse client connections or open new ones?
  • I need to test more on error handling on everything and showing errors from the API. But this PR was getting really long in the tooth and I think some of that can be divided up for others who may not know atproto stuff but are much better with Nuxt than me

I probably won't be able to address feedback till tomorrow around 22:00 UTC

Zen.mp4

@vercel
Copy link

vercel bot commented Feb 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 3, 2026 8:44pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 3, 2026 8:44pm
npmx-lunaria Ignored Ignored Feb 3, 2026 8:44pm

Request Review

Copy link
Contributor

@Adebesin-Cell Adebesin-Cell left a comment

Choose a reason for hiding this comment

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

Interesting PR!

Screen.Recording.2026-02-02.at.08.07.12.mov

Connecting with any of the socials results in a 404 error.

It attempts to navigate to: https://npmxdev-git-fork-fatfingers23-feat-likes-poetry.vercel.app/package/api/auth/atproto

if (user.value?.handle == null) {
authModal.open()
} else {
const result = likesData.value?.userHasLiked ? await unlikePackage() : await likePackage()
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this approach could be improved a bit. Right now, the API request is sent immediately on every click.

It might be better to optimistically update the UI and debounce the request, so users can toggle the button freely and only the final state is sent to the API after a short delay. This would also allow us to provide immediate feedback that the action was registered, and handle request failures more gracefully (e.g., by rolling back the UI state or showing an error).

This would avoid unnecessary API calls and result in a smoother UX overall.

Copy link
Contributor

Choose a reason for hiding this comment

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

i agree, optimistic updates would fit nicely here and shouldn't be too difficult 👍

Comment on lines 26 to 31
if (hasLiked) {
throw createError({
status: 400,
message: 'User has already liked the package',
})
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: silently succeed here instead of throwing an error.

Scenario: user likes the package twice with quirky internet connection and/or slower than usual response times.

Potential result on the UI: user see the like added then seeing the error message that liking has failed.

Been there, done that, with an app with more than 500,000 users :D

Comment on lines 14 to 21
const body = await readBody<{ packageName: string }>(event)

if (!body.packageName) {
throw createError({
status: 400,
message: 'packageName is required',
})
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Could leverage valibot here, this way you wouldn't have to resort to custom null checks to ensure that body is actually of type you expect it to be.

* Gets the definite answer if the user has liked a npm package. Either from the cache or the network
* @param packageName
* @param usersDid
* @returns
Copy link
Contributor

Choose a reason for hiding this comment

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

👀

Copy link
Member

@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

nuxt.config.ts Outdated
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.

@@ -0,0 +1,246 @@
import { getCacheAdatper } from '../../cache'
import { $nsid as likeNsid } from '#shared/types/lexicons/dev/npmx/feed/like.defs'
import type { Backlink } from '~~/shared/utils/constellation'
Copy link
Member

Choose a reason for hiding this comment

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

here and some other places too:

Suggested change
import type { Backlink } from '~~/shared/utils/constellation'
import type { Backlink } from '#shared/utils/constellation'

@43081j
Copy link
Contributor

43081j commented Feb 2, 2026

looking good!

i left a bunch of comments, mostly about code organisation though tbh

Comment on lines 44 to 46
const data = ref<PackageLikes | null>(null)
const error = ref<Error | null>(null)
const pending = ref(false)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const data = ref<PackageLikes | null>(null)
const error = ref<Error | null>(null)
const pending = ref(false)
const data = shallowRef<PackageLikes | null>(null)
const error = shallowRef<Error | null>(null)
const pending = shallowRef(false)

Copy link
Contributor

Choose a reason for hiding this comment

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

By the way, I dont think we need these as composables. We almost never want the data from the ref itself, but rather the data returned by mutate. Just use $fetch where we need it or capsule just the $fetch in a util (IMO)

server: false,
})

const { mutate: likePackage } = useLikePackage(packageName.value)
Copy link
Contributor

Choose a reason for hiding this comment

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

passing packageName.value means, that when packageName updates the likePackage will like the initial value.

@fatfingers23
Copy link
Contributor Author

Thank you everyone for all the feedback! I think I have addressed everything brought up. About to log off for a few hours and going come back to do some final testing and I have 2 todos I want to hit

  • There's a like type I can share with the backend
  • Look into the user session being loaded when not needed

@danielroe
Copy link
Member

it seems that likes aren't persisted, right? as in, when I navigate back to a package, it shows up as 'unliked'

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/api/auth/atproto.get.ts (1)

49-51: ⚠️ Potential issue | 🟡 Minor

Normalise OAuth callback query params before URLSearchParams.

getQuery returns values that can be string | string[] when query parameters are duplicated; the cast to Record<string, string> hides this and breaks type safety. Per coding guidelines, ensure strictly type-safe code with explicit array value handling. The current code would silently convert arrays to comma-separated strings (e.g., on lines 32–33 with .toString() calls).

🛠️ Suggested normalisation
-  const { session: authSession } = await atclient.callback(
-    new URLSearchParams(query as Record<string, string>),
-  )
+  const params = new URLSearchParams()
+  for (const [key, value] of Object.entries(query)) {
+    if (Array.isArray(value)) {
+      throw createError({
+        status: 400,
+        message: `Duplicate query parameter: ${key}`,
+      })
+    }
+    if (value != null) params.set(key, value.toString())
+  }
+  const { session: authSession } = await atclient.callback(params)

Comment on lines +21 to +30
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
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Checking for nuxt.config files ==="
fd -t f -e 'ts' -e 'js' 'nuxt.config' .

echo -e "\n=== Searching for ERROR_NEED_REAUTH usage across codebase ==="
rg "ERROR_NEED_REAUTH" -n --type ts --type js

echo -e "\n=== Checking if ERROR_NEED_REAUTH is defined in constants ==="
fd -t f 'constants' | head -20 | xargs cat 2>/dev/null | grep -B 2 -A 2 "ERROR_NEED_REAUTH" || echo "No direct matches found"

echo -e "\n=== Checking app/utils/atproto/helpers.ts for all imports ==="
cat app/utils/atproto/helpers.ts

Repository: npmx-dev/npmx.dev

Length of output: 1492


🏁 Script executed:

#!/bin/bash
echo "=== Checking nuxt.config.ts for auto-import settings ==="
cat nuxt.config.ts | grep -A 30 "imports" || echo "No imports section found"

echo -e "\n=== Checking server/utils/atproto/oauth.ts for ERROR_NEED_REAUTH import ==="
head -40 server/utils/atproto/oauth.ts

Repository: npmx-dev/npmx.dev

Length of output: 2915


🏁 Script executed:

#!/bin/bash
echo "=== Checking server/utils/atproto/oauth.ts around line 76 where ERROR_NEED_REAUTH is used ==="
sed -n '1,90p' server/utils/atproto/oauth.ts | tail -50

Repository: npmx-dev/npmx.dev

Length of output: 1873


🏁 Script executed:

#!/bin/bash
echo "=== Checking complete imports in server/utils/atproto/oauth.ts ==="
head -20 server/utils/atproto/oauth.ts

Repository: npmx-dev/npmx.dev

Length of output: 1019


Import ERROR_NEED_REAUTH from shared constants.

ERROR_NEED_REAUTH is used on line 26 but not imported. Add the missing import:

import { ERROR_NEED_REAUTH } from '#shared/utils/constants'

This constant is defined in shared/utils/constants.ts and must be explicitly imported, as Nuxt's auto-import configuration only covers composables, not shared utilities.

Comment on lines +1 to +6
import type { Redis } from '@upstash/redis'

/**
* Redis cache storage with TTL handled by redis for use in production
*/
export class RedisCacheAdapter implements CacheAdapter {
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Import CacheAdapter to avoid a compile-time error.

CacheAdapter is used but not imported, which will fail type-checking.

🧩 Proposed fix
-import type { Redis } from '@upstash/redis'
+import type { Redis } from '@upstash/redis'
+import type { CacheAdapter } from './shared'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import type { Redis } from '@upstash/redis'
/**
* Redis cache storage with TTL handled by redis for use in production
*/
export class RedisCacheAdapter implements CacheAdapter {
import type { Redis } from '@upstash/redis'
import type { CacheAdapter } from './shared'
/**
* Redis cache storage with TTL handled by redis for use in production
*/
export class RedisCacheAdapter implements CacheAdapter {

Comment on lines +26 to +32
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
const formattedKey = this.formatKey(key)
if (ttl) {
await this.redis.setex(formattedKey, ttl, value)
} else {
await this.redis.set(formattedKey, value)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Treat ttl = 0 explicitly instead of falling through.

if (ttl) skips TTL when ttl is 0. If 0 is meaningful in your usage, check for undefined instead.

🧯 Proposed fix
-    if (ttl) {
+    if (ttl !== undefined) {
       await this.redis.setex(formattedKey, ttl, value)
     } else {
       await this.redis.set(formattedKey, value)
     }

Copy link
Member

@danielroe danielroe left a comment

Choose a reason for hiding this comment

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

🚀

@danielroe danielroe added this pull request to the merge queue Feb 3, 2026
Merged via the queue into npmx-dev:main with commit bdaaa2c Feb 3, 2026
13 of 14 checks passed
@Gonzo17
Copy link

Gonzo17 commented Feb 3, 2026

Hello :) I just saw the new feature, nice work! It has a minor issue, the translation package.links.like (used as title of the button) is missing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: package likes

8 participants