Skip to content

Commit e5b9bbd

Browse files
authored
Merge pull request #5 from fatfingers23/feat/profile-page
extras
2 parents 8bf70eb + 5676cf6 commit e5b9bbd

File tree

9 files changed

+131
-82
lines changed

9 files changed

+131
-82
lines changed

app/pages/profile/[handle]/index.vue

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@ import { updateProfile as updateProfileUtil } from '~/utils/atproto/profile'
44
const route = useRoute('profile-handle')
55
const handle = computed(() => route.params.handle)
66
7-
const { data: profile } = await useFetch<NPMXProfile>(() => `/api/social/profile/${handle.value}`, {
8-
default: () => ({ displayName: handle.value, description: '', website: '' }),
9-
})
10-
11-
if (!profile.value) {
7+
const { data: profile, error: profileError } = await useFetch<NPMXProfile>(
8+
() => `/api/social/profile/${handle.value}`,
9+
{
10+
default: () => ({
11+
displayName: handle.value,
12+
description: '',
13+
website: '',
14+
recordExists: false,
15+
}),
16+
},
17+
)
18+
if (!profile.value || profileError.value?.statusCode === 404) {
1219
throw createError({
1320
statusCode: 404,
1421
statusMessage: $t('profile.not_found'),
@@ -46,6 +53,7 @@ async function updateProfile() {
4653
displayName: displayNameInput.value,
4754
description: descriptionInput.value || undefined,
4855
website: websiteInput.value || undefined,
56+
recordExists: true,
4957
}
5058
5159
try {
@@ -70,6 +78,20 @@ async function updateProfile() {
7078
7179
const { data: likes, status } = useProfileLikes(handle)
7280
81+
const showInviteSection = computed(() => {
82+
return (
83+
profile.value.recordExists === false &&
84+
status.value === 'success' &&
85+
!likes.value?.records?.length &&
86+
user.value?.handle !== handle.value
87+
)
88+
})
89+
90+
const inviteUrl = computed(() => {
91+
const text = $t('profile.invite.compose_text', { handle: handle.value })
92+
return `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`
93+
})
94+
7395
useSeoMeta({
7496
title: () => $t('profile.seo_title', { handle: handle.value }),
7597
description: () => $t('profile.seo_description', { handle: handle.value }),
@@ -175,6 +197,19 @@ defineOgImageComponent('Default', {
175197
<div v-else-if="likes?.records" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
176198
<PackageLikeCard v-for="like in likes.records" :packageUrl="like.value.subjectRef" />
177199
</div>
200+
201+
<!-- Invite section: shown when user does not have npmx profile or any like lexicons -->
202+
<div
203+
v-if="showInviteSection"
204+
class="flex flex-col items-start gap-4 p-6 bg-bg-subtle border border-border rounded-lg"
205+
>
206+
<p class="text-fg-muted">
207+
{{ $t('profile.invite.message') }}
208+
</p>
209+
<LinkBase variant="button-secondary" classicon="i-simple-icons:bluesky" :to="inviteUrl">
210+
{{ $t('profile.invite.share_button') }}
211+
</LinkBase>
212+
</div>
178213
</section>
179214
</main>
180215
</template>

i18n/locales/en.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,12 @@
160160
"seo_title": "{handle} - npmx",
161161
"seo_description": "npmx profile by {handle}",
162162
"not_found": "Profile Not Found",
163-
"not_found_message": "The profile for {handle} could not be found."
163+
"not_found_message": "The profile for {handle} could not be found.",
164+
"invite": {
165+
"message": "It doesn't look like they're using npmx yet. Want to tell them about it?",
166+
"share_button": "Share on Bluesky",
167+
"compose_text": "Hey {'@'}{handle}! Have you checked out npmx.dev yet? It's a browser for the npm registry that's fast, modern, and open-source.\nhttps://npmx.dev"
168+
}
164169
},
165170
"package": {
166171
"not_found": "Package Not Found",

i18n/schema.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,21 @@
486486
},
487487
"not_found_message": {
488488
"type": "string"
489+
},
490+
"invite": {
491+
"type": "object",
492+
"properties": {
493+
"message": {
494+
"type": "string"
495+
},
496+
"share_button": {
497+
"type": "string"
498+
},
499+
"compose_text": {
500+
"type": "string"
501+
}
502+
},
503+
"additionalProperties": false
489504
}
490505
},
491506
"additionalProperties": false

lexicons/blue/microcosm/repo/get-record-by-uri.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.

lunaria/files/en-GB.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,12 @@
159159
"seo_title": "{handle} - npmx",
160160
"seo_description": "npmx profile by {handle}",
161161
"not_found": "Profile Not Found",
162-
"not_found_message": "The profile for {handle} could not be found."
162+
"not_found_message": "The profile for {handle} could not be found.",
163+
"invite": {
164+
"message": "It doesn't look like they're using npmx yet. Want to tell them about it?",
165+
"share_button": "Share on Bluesky",
166+
"compose_text": "Hey {'@'}{handle}! Have you checked out npmx.dev yet? It's a fast, modern browser for the npm registry.\nhttps://npmx.dev"
167+
}
163168
},
164169
"package": {
165170
"not_found": "Package Not Found",

lunaria/files/en-US.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,12 @@
159159
"seo_title": "{handle} - npmx",
160160
"seo_description": "npmx profile by {handle}",
161161
"not_found": "Profile Not Found",
162-
"not_found_message": "The profile for {handle} could not be found."
162+
"not_found_message": "The profile for {handle} could not be found.",
163+
"invite": {
164+
"message": "It doesn't look like they're using npmx... yet. Want to tell them about it?",
165+
"share_button": "Share on Bluesky",
166+
"compose_text": "Hey {'@'}{handle}! Have you checked out npmx.dev yet? It's a fast, modern browser for the npm registry.\nhttps://npmx.dev"
167+
}
163168
},
164169
"package": {
165170
"not_found": "Package Not Found",

server/api/auth/atproto.get.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,17 @@ import { SLINGSHOT_HOST } from '#shared/utils/constants'
66
import { useServerSession } from '#server/utils/server-session'
77
import { handleApiError } from '#server/utils/error-handler'
88
import type { DidString } from '@atproto/lex'
9-
import { Client } from '@atproto/lex'
10-
import * as com from '#shared/types/lexicons/com'
9+
import { Client, isAtUriString } from '@atproto/lex'
1110
import * as app from '#shared/types/lexicons/app'
11+
import * as blue from '#shared/types/lexicons/blue'
1212
import { isAtIdentifierString } from '@atproto/lex'
1313
import { scope } from '#server/utils/atproto/oauth'
1414
import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
1515
// @ts-expect-error virtual file from oauth module
1616
import { clientUri } from '#oauth/config'
1717

1818
const OAUTH_REQUEST_COOKIE_PREFIX = 'atproto_oauth_req'
19+
const slingshotClient = new Client({ service: `https://${SLINGSHOT_HOST}` })
1920

2021
export default defineEventHandler(async event => {
2122
const config = useRuntimeConfig(event)
@@ -224,8 +225,7 @@ function decodeOAuthState(event: H3Event, state: string | null): OAuthStateData
224225
* @returns An object containing the user's DID, handle, PDS, and avatar URL (if available)
225226
*/
226227
async function getMiniProfile(authSession: OAuthSession) {
227-
const client = new Client({ service: `https://${SLINGSHOT_HOST}` })
228-
const response = await client.xrpcSafe(com['bad-example'].identity.resolveMiniDoc, {
228+
const response = await slingshotClient.xrpcSafe(blue.microcosm.identity.resolveMiniDoc, {
229229
headers: { 'User-Agent': 'npmx' },
230230
params: { identifier: authSession.did },
231231
})
@@ -289,16 +289,17 @@ async function getNpmxProfile(handle: string, authSession: OAuthSession) {
289289

290290
// get existing npmx profile OR create a new one
291291
const profileUri = `at://${client.did}/dev.npmx.actor.profile/self`
292+
if (!isAtUriString(profileUri)) {
293+
throw new Error(`Invalid at-uri: ${profileUri}`)
294+
}
292295

293-
// TODO: update with safe client rpc, see `getMiniProfile` response variable
294-
const profileResponse = await fetch(
295-
`https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.repo.getRecordByUri?at_uri=${encodeURIComponent(profileUri)}`,
296-
{ headers: { 'User-Agent': 'npmx' }, signal: AbortSignal.timeout(5_000) },
297-
)
296+
const profileResult = await slingshotClient.xrpcSafe(blue.microcosm.repo.getRecordByUri, {
297+
headers: { 'User-Agent': 'npmx' },
298+
params: { at_uri: profileUri },
299+
})
298300

299-
if (profileResponse.ok) {
300-
const profile = await profileResponse.json()
301-
return profile
301+
if (profileResult.success) {
302+
return profileResult.body.value
302303
} else {
303304
const profile = {
304305
website: '',

server/utils/atproto/utils/profile.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { MiniDoc, NPMXProfile } from '~~/shared/types/social'
1+
import type { MiniDoc, NPMXProfile } from '#shared/types/social'
2+
import * as blue from '#shared/types/lexicons/blue'
3+
import * as dev from '#shared/types/lexicons/dev'
4+
import { Client, isAtIdentifierString, isAtUriString } from '@atproto/lex'
25

36
//Cache keys and helpers
47
const CACHE_PREFIX = 'atproto-profile:'
@@ -12,10 +15,11 @@ const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5
1215
*/
1316
export class ProfileUtils {
1417
private readonly cache: CacheAdapter
15-
// TODO: create Slingshot client (like Constellation class)
18+
private readonly slingshotClient: Client
1619

1720
constructor() {
1821
this.cache = getCacheAdapter('generic')
22+
this.slingshotClient = new Client({ service: `https://${SLINGSHOT_HOST}` })
1923
}
2024

2125
private async slingshotMiniDoc(handle: string) {
@@ -26,20 +30,28 @@ export class ProfileUtils {
2630
if (cachedMiniDoc) {
2731
miniDoc = cachedMiniDoc
2832
} else {
29-
const resolveUrl = `https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}`
30-
const response = await fetch(resolveUrl, {
33+
if (!isAtIdentifierString(handle)) {
34+
throw createError({
35+
status: 400,
36+
message: `Invalid at-identifier: ${handle}`,
37+
})
38+
}
39+
40+
const response = await this.slingshotClient.xrpcSafe(blue.microcosm.identity.resolveMiniDoc, {
3141
headers: { 'User-Agent': 'npmx' },
42+
params: { identifier: handle },
3243
})
33-
if (!response.ok) {
44+
if (!response.success) {
45+
// Not always, but usually this will mean the profile cannot be found
46+
// and can be assumed most of the time it does not exists
3447
throw createError({
35-
status: response.status,
48+
status: 404,
3649
message: `Failed to resolve MiniDoc for ${handle}`,
3750
})
3851
}
39-
const value = (await response.json()) as MiniDoc
4052

41-
miniDoc = value
42-
await this.cache.set(miniDocKey, value, CACHE_MAX_AGE)
53+
miniDoc = response.body
54+
await this.cache.set(miniDocKey, miniDoc, CACHE_MAX_AGE)
4355
}
4456

4557
return miniDoc
@@ -60,16 +72,29 @@ export class ProfileUtils {
6072
profile = cachedProfile
6173
} else {
6274
const profileUri = `at://${miniDoc.did}/dev.npmx.actor.profile/self`
63-
const response = await fetch(
64-
`https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.repo.getRecordByUri?at_uri=${profileUri}`,
65-
{
66-
headers: { 'User-Agent': 'npmx' },
67-
},
68-
)
69-
if (response.ok) {
70-
const { value } = (await response.json()) as { value: NPMXProfile }
71-
profile = value
75+
if (!isAtUriString(profileUri)) {
76+
throw new Error(`Invalid at-uri: ${profileUri}`)
77+
}
78+
79+
const response = await this.slingshotClient.xrpcSafe(blue.microcosm.repo.getRecordByUri, {
80+
headers: { 'User-Agent': 'npmx' },
81+
params: { at_uri: profileUri },
82+
})
83+
84+
if (response.success) {
85+
const validationResult = dev.npmx.actor.profile.$validate(response.body.value)
86+
profile = { recordExists: true, ...validationResult }
7287
await this.cache.set(profileKey, profile, CACHE_MAX_AGE)
88+
} else {
89+
if (response.error === 'RecordNotFound') {
90+
return {
91+
recordExists: false,
92+
displayName: miniDoc.handle,
93+
description: '',
94+
website: '',
95+
}
96+
}
97+
throw new Error(`Failed to fetch profile: ${response.error}`)
7398
}
7499
}
75100

shared/types/social.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,6 @@ export type NPMXProfile = {
2727
displayName: string
2828
website?: string
2929
description?: string
30+
// If the atproto record exists for the profile
31+
recordExists: boolean
3032
}

0 commit comments

Comments
 (0)