Skip to content

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 4:32am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 3, 2026 4:32am
npmx-lunaria Ignored Ignored Feb 3, 2026 4:32am

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
Collaborator

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 29
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 24
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
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

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'

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,

import type { PackageLikes } from '#server/utils/atproto/utils/likes'

export async function authRedirect(identifier: string, create: boolean = false) {
let query = { handle: identifier } as {}
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: this should be a type annotation rather than a cast, and shouldn't be {}

Suggested change
let query = { handle: identifier } as {}
let query: LocationQueryRaw = { handle: identifier }

or if we can't pull the exact type from vue-router, use Record<string, string> or something

}

export async function handleAuthError(e: unknown, userHandle?: string | null): Promise<never> {
const fetchError = e as FetchError
Copy link
Collaborator

Choose a reason for hiding this comment

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

given we cast the error anyway at all call sites, can this function just accept a FetchError

otherwise we're doing an unsafe cast here, if anyone in future calls it from somewhere that doesn't only do a $fetch in the try block

return { user, pending, logout }
}

export function useLikePackage(packageName: string) {
Copy link
Collaborator

@43081j 43081j Feb 2, 2026

Choose a reason for hiding this comment

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

this file is becoming a bit of a "catch all" as the atproto features quickly expand. we should split them up i think, and have a app/composables/atproto/*, one file per composable.

that'll also help with organising the tests once they exist

its currently named after one of the composables, and the rest are just thrown into the mix

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

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

/**
* Local implmentation of a cache to be used during development
*/
export class StorageCacheAdapter implements CacheAdapter {
Copy link
Collaborator

@43081j 43081j Feb 2, 2026

Choose a reason for hiding this comment

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

we should probably organise these into files:

  • server/utils/cache/adapter.ts - or shared.ts, holds the interface and any shared functions
  • server/utils/cache/local.ts - this StorageCacheAdapter
  • server/utils/cache/redis.ts
  • server/utils/cache/get.ts - not sure about this one, but just holds the main getCacheAdapter. can't live in the shared file since that'd cause circular imports

generally we should have one class per file imo

@43081j
Copy link
Collaborator

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'

@fatfingers23
Copy link
Contributor Author

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

They should be 😬. Was some heavy refactoring today so may be a bit of a bug. Going give it some more heavy testing when I come back. Is that on the preview URL? Should be loaded into cache for a while and once the cache expire it grabs from constellation if you have liked the packaged and the total count

@fatfingers23
Copy link
Contributor Author

I think I've taken troubleshooting about as far as I can on this side of the fence :/. I think someone with access to the Vercel service will need to look. All works locally both for upstash redis and useStorage

When you like it updates the record in your user repo but does not show, notice after the 10 minutes it shows you liked it. Which is about the time it takes for cache entry to expire. I am passing in a fresh instance of $fetch to the likes service I made so it should not be using the cachedFetch. On the cache side I tried removing upstash option and tried useStorage with both vercel-kv and vercel-runtime-cache. Same thing there.

Feels very much so like something in Vercel is caching web requests or that the cache backends may not be writing as expected. Any help would be greatly appreciated 🙏

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

7 participants