From b707f80d63196ba28ac9167c7a82c5498a3fa941 Mon Sep 17 00:00:00 2001 From: swdev33 Date: Thu, 9 Oct 2025 09:07:12 +0200 Subject: [PATCH] feat(client, api): bluesky profile link (#62400) Co-authored-by: swdev33 <18295918+swdev33@users.noreply.github.com> --- api/prisma/schema.prisma | 1 + api/src/plugins/__fixtures__/user.ts | 1 + api/src/routes/protected/settings.test.ts | 12 ++++-- api/src/routes/protected/settings.ts | 11 ++++-- api/src/routes/protected/user.test.ts | 2 + api/src/routes/protected/user.ts | 4 ++ api/src/routes/public/user.test.ts | 2 + api/src/routes/public/user.ts | 2 + api/src/schemas/settings/update-my-socials.ts | 1 + api/src/schemas/user/get-session-user.ts | 1 + api/src/schemas/users/get-public-profile.ts | 1 + api/src/utils/normalize.test.ts | 17 +++++++++ api/src/utils/normalize.ts | 20 ++++++++++ .../__snapshots__/profile.test.tsx.snap | 22 +++++++++++ .../src/components/profile/components/bio.tsx | 2 + .../profile/components/internet.tsx | 37 +++++++++++++++++-- .../profile/components/social-icons.tsx | 24 ++++++++++-- .../src/components/profile/profile.test.tsx | 1 + client/src/redux/prop-types.ts | 1 + e2e/internet-presence-settings.spec.ts | 7 ++++ 20 files changed, 156 insertions(+), 13 deletions(-) diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 681d80f0d80..19365d98c78 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -152,6 +152,7 @@ model user { theme String? // Undefined timezone String? // Undefined twitter String? // Null | Undefined + bluesky String? // Null | Undefined unsubscribeId String /// Used to track the number of times the user's record was written to. /// diff --git a/api/src/plugins/__fixtures__/user.ts b/api/src/plugins/__fixtures__/user.ts index 9b53ecc246a..18164ea4085 100644 --- a/api/src/plugins/__fixtures__/user.ts +++ b/api/src/plugins/__fixtures__/user.ts @@ -82,6 +82,7 @@ export const newUser = (email: string) => ({ theme: 'default', timezone: null, twitter: null, + bluesky: null, updateCount: 0, // see extendClient in prisma.ts // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment username: expect.stringMatching(fccUuidRe), diff --git a/api/src/routes/protected/settings.test.ts b/api/src/routes/protected/settings.test.ts index 7fe28fa5a86..466f299d57f 100644 --- a/api/src/routes/protected/settings.test.ts +++ b/api/src/routes/protected/settings.test.ts @@ -765,6 +765,7 @@ Happy coding! const response = await superPut('/update-my-socials').send({ website: 'https://www.freecodecamp.org/', twitter: 'https://twitter.com/ossia', + bluesky: 'https://bsky.app/profile/quincy.bsky.social', linkedin: 'https://www.linkedin.com/in/quincylarson', githubProfile: 'https://github.com/QuincyLarson' }); @@ -780,6 +781,7 @@ Happy coding! const response = await superPut('/update-my-socials').send({ website: 'https://www.freecodecamp.org/', twitter: '', + bluesky: '', linkedin: '', githubProfile: '' }); @@ -795,6 +797,7 @@ Happy coding! const response = await superPut('/update-my-socials').send({ website: 'invalid', twitter: '', + bluesky: '', linkedin: '', githubProfile: '' }); @@ -807,6 +810,7 @@ Happy coding! const response = await superPut('/update-my-socials').send({ website: '', twitter: '', + bluesky: '', linkedin: '', githubProfile: 'https://x.com/should-be-github' }); @@ -1155,7 +1159,7 @@ describe('getWaitMessage', () => { }); describe('validateSocialUrl', () => { - test.each(['githubProfile', 'linkedin', 'twitter'] as const)( + test.each(['githubProfile', 'linkedin', 'twitter', 'bluesky'] as const)( 'accepts empty strings for %s', social => { expect(validateSocialUrl('', social)).toBe(true); @@ -1165,7 +1169,8 @@ describe('validateSocialUrl', () => { test.each([ ['githubProfile', 'https://something.com/user'], ['linkedin', 'https://www.x.com/in/username'], - ['twitter', 'https://www.toomanyexes.com/username'] + ['twitter', 'https://www.toomanyexes.com/username'], + ['bluesky', 'https://www.twitter.com/username'] ] as const)('rejects invalid urls for %s', (social, url) => { expect(validateSocialUrl(url, social)).toBe(false); }); @@ -1174,7 +1179,8 @@ describe('validateSocialUrl', () => { ['githubProfile', 'https://something.github.com/user'], ['linkedin', 'https://www.linkedin.com/in/username'], ['twitter', 'https://twitter.com/username'], - ['twitter', 'https://x.com/username'] + ['twitter', 'https://x.com/username'], + ['bluesky', 'https://bsky.app/profile/username.bsky.social'] ] as const)('accepts valid urls for %s', (social, url) => { expect(validateSocialUrl(url, social)).toBe(true); }); diff --git a/api/src/routes/protected/settings.ts b/api/src/routes/protected/settings.ts index 8c3fb501ae1..8090e93978d 100644 --- a/api/src/routes/protected/settings.ts +++ b/api/src/routes/protected/settings.ts @@ -56,7 +56,8 @@ export const isPictureWithProtocol = (picture?: string): boolean => { const ALLOWED_DOMAINS_MAP = { githubProfile: ['github.com'], linkedin: ['linkedin.com'], - twitter: ['twitter.com', 'x.com'] + twitter: ['twitter.com', 'x.com'], + bluesky: ['bsky.app'] }; /** @@ -339,14 +340,15 @@ ${isLinkSentWithinLimitTTL}` const socials = { twitter: req.body.twitter, + bluesky: req.body.bluesky, githubProfile: req.body.githubProfile, linkedin: req.body.linkedin, website: req.body.website }; - const valid = (['twitter', 'githubProfile', 'linkedin'] as const).every( - key => validateSocialUrl(socials[key], key) - ); + const valid = ( + ['twitter', 'bluesky', 'githubProfile', 'linkedin'] as const + ).every(key => validateSocialUrl(socials[key], key)); if (!valid) { logger.warn({ socials }, `Invalid social URL`); @@ -363,6 +365,7 @@ ${isLinkSentWithinLimitTTL}` data: { website: socials.website, twitter: socials.twitter, + bluesky: socials.bluesky, githubProfile: socials.githubProfile, linkedin: socials.linkedin } diff --git a/api/src/routes/protected/user.test.ts b/api/src/routes/protected/user.test.ts index a654b9f47d4..c008f19d11c 100644 --- a/api/src/routes/protected/user.test.ts +++ b/api/src/routes/protected/user.test.ts @@ -153,6 +153,7 @@ const testUserData: Prisma.userCreateInput = { ], yearsTopContributor: ['2018'], twitter: '@foobar', + bluesky: '@foobar', linkedin: 'linkedin.com/foobar', sendQuincyEmail: false }; @@ -304,6 +305,7 @@ const publicUserData = { profileUI: testUserData.profileUI, savedChallenges: testUserData.savedChallenges, twitter: 'https://twitter.com/foobar', + bluesky: 'https://bsky.app/profile/foobar', sendQuincyEmail: testUserData.sendQuincyEmail, username: testUserData.username, usernameDisplay: testUserData.usernameDisplay, diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index f3fb24d4dfc..5e63fe54a75 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -18,6 +18,7 @@ import { normalizeProfileUI, normalizeSurveys, normalizeTwitter, + normalizeBluesky, removeNulls } from '../../utils/normalize.js'; import { mapErr, type UpdateReqType } from '../../utils/index.js'; @@ -646,6 +647,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( sendQuincyEmail: true, theme: true, twitter: true, + bluesky: true, username: true, usernameDisplay: true, website: true, @@ -692,6 +694,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( completedDailyCodingChallenges, progressTimestamps, twitter, + bluesky, profileUI, currentChallengeId, location, @@ -729,6 +732,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( name: name ?? '', theme: theme ?? 'default', twitter: normalizeTwitter(twitter), + bluesky: normalizeBluesky(bluesky), username, usernameDisplay: usernameDisplay || username, userToken: encodedToken, diff --git a/api/src/routes/public/user.test.ts b/api/src/routes/public/user.test.ts index 357f0d83d86..fd7bc420b00 100644 --- a/api/src/routes/public/user.test.ts +++ b/api/src/routes/public/user.test.ts @@ -105,6 +105,7 @@ const testUserData: Prisma.userCreateInput = { ], yearsTopContributor: ['2018'], twitter: '@foobar', + bluesky: '@foobar', linkedin: 'linkedin.com/foobar' }; @@ -215,6 +216,7 @@ const publicUserData = { portfolio: testUserData.portfolio, profileUI: testUserData.profileUI, twitter: 'https://twitter.com/foobar', + bluesky: 'https://bsky.app/profile/foobar', username: testUserData.username, usernameDisplay: testUserData.usernameDisplay, website: testUserData.website, diff --git a/api/src/routes/public/user.ts b/api/src/routes/public/user.ts index 44a726de35e..8a260ab6212 100644 --- a/api/src/routes/public/user.ts +++ b/api/src/routes/public/user.ts @@ -12,6 +12,7 @@ import { normalizeFlags, normalizeProfileUI, normalizeTwitter, + normalizeBluesky, removeNulls } from '../../utils/normalize.js'; import { @@ -197,6 +198,7 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = ( // setting control it? Same applies to website, githubProfile, // and linkedin. twitter: normalizeTwitter(user.twitter), + bluesky: normalizeBluesky(user.bluesky), yearsTopContributor: user.yearsTopContributor, usernameDisplay: user.usernameDisplay || user.username }; diff --git a/api/src/schemas/settings/update-my-socials.ts b/api/src/schemas/settings/update-my-socials.ts index f0a3e98c3be..026ca1780bb 100644 --- a/api/src/schemas/settings/update-my-socials.ts +++ b/api/src/schemas/settings/update-my-socials.ts @@ -9,6 +9,7 @@ export const updateMySocials = { body: Type.Object({ website: urlOrEmptyString, twitter: urlOrEmptyString, + bluesky: urlOrEmptyString, githubProfile: urlOrEmptyString, linkedin: urlOrEmptyString }), diff --git a/api/src/schemas/user/get-session-user.ts b/api/src/schemas/user/get-session-user.ts index b5f7acb447a..8bfcb5d3ce2 100644 --- a/api/src/schemas/user/get-session-user.ts +++ b/api/src/schemas/user/get-session-user.ts @@ -111,6 +111,7 @@ export const getSessionUser = { sendQuincyEmail: Type.Union([Type.Null(), Type.Boolean()]), // // Tri-state: null (likely new user), true (subscribed), false (unsubscribed) theme: Type.String(), twitter: Type.Optional(Type.String()), + bluesky: Type.Optional(Type.String()), website: Type.Optional(Type.String()), yearsTopContributor: Type.Array(Type.String()), // TODO(Post-MVP): convert to number? isEmailVerified: Type.Boolean(), diff --git a/api/src/schemas/users/get-public-profile.ts b/api/src/schemas/users/get-public-profile.ts index 32e0ee9f9c1..96e6039a2a2 100644 --- a/api/src/schemas/users/get-public-profile.ts +++ b/api/src/schemas/users/get-public-profile.ts @@ -91,6 +91,7 @@ export const getPublicProfile = { ), profileUI, twitter: Type.Optional(Type.String()), + bluesky: Type.Optional(Type.String()), website: Type.Optional(Type.String()), yearsTopContributor: Type.Array(Type.String()), // TODO(Post-MVP): convert to number? joinDate: Type.String(), diff --git a/api/src/utils/normalize.test.ts b/api/src/utils/normalize.test.ts index 08e501e94a7..ee0267dda04 100644 --- a/api/src/utils/normalize.test.ts +++ b/api/src/utils/normalize.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest'; import { normalizeTwitter, + normalizeBluesky, normalizeProfileUI, normalizeChallenges, normalizeFlags, @@ -25,6 +26,22 @@ describe('normalize', () => { }); }); + describe('normalizeBluesky', () => { + test('returns the input if it is a url', () => { + const url = 'https://bsky.app/profile/a_generic_user'; + expect(normalizeBluesky(url)).toEqual(url); + }); + test('adds the handle to bsky.app if it is not a url', () => { + const handle = '@a_generic_user'; + expect(normalizeBluesky(handle)).toEqual( + 'https://bsky.app/profile/a_generic_user' + ); + }); + test('returns undefined if that is the input', () => { + expect(normalizeBluesky('')).toBeUndefined(); + }); + }); + const profileUIInput = { isLocked: true, showAbout: true, diff --git a/api/src/utils/normalize.ts b/api/src/utils/normalize.ts index 9b2944b5e9a..b6d1c422f15 100644 --- a/api/src/utils/normalize.ts +++ b/api/src/utils/normalize.ts @@ -40,6 +40,26 @@ export const normalizeTwitter = ( return url ?? handleOrUrl; }; +/** + * Converts a Bluesky handle or URL to a URL. + * + * @param handleOrUrl Bluesky handle or URL. + * @returns Bluesky URL. + */ +export const normalizeBluesky = ( + handleOrUrl: string | null +): string | undefined => { + if (!handleOrUrl) return undefined; + + let url; + try { + new URL(handleOrUrl); + } catch { + url = `https://bsky.app/profile/${handleOrUrl.replace(/^@/, '')}`; + } + return url ?? handleOrUrl; +}; + /** * Normalizes a date value to a timestamp number. * diff --git a/client/src/components/profile/__snapshots__/profile.test.tsx.snap b/client/src/components/profile/__snapshots__/profile.test.tsx.snap index 2b318706b1e..a55f3d77e85 100644 --- a/client/src/components/profile/__snapshots__/profile.test.tsx.snap +++ b/client/src/components/profile/__snapshots__/profile.test.tsx.snap @@ -290,6 +290,28 @@ exports[` > renders correctly 1`] = ` /> + + + diff --git a/client/src/components/profile/components/bio.tsx b/client/src/components/profile/components/bio.tsx index 7cc4d0aeb6d..9837b58a7e8 100644 --- a/client/src/components/profile/components/bio.tsx +++ b/client/src/components/profile/components/bio.tsx @@ -28,6 +28,7 @@ const Bio = ({ user, setIsEditing, isSessionUser }: BioProps) => { githubProfile, linkedin, twitter, + bluesky, website, isDonating, yearsTopContributor, @@ -85,6 +86,7 @@ const Bio = ({ user, setIsEditing, isSessionUser }: BioProps) => { githubProfile={githubProfile} linkedin={linkedin} twitter={twitter} + bluesky={bluesky} username={username} website={website} /> diff --git a/client/src/components/profile/components/internet.tsx b/client/src/components/profile/components/internet.tsx index 5220ad42619..d226df839d9 100644 --- a/client/src/components/profile/components/internet.tsx +++ b/client/src/components/profile/components/internet.tsx @@ -25,6 +25,7 @@ export interface Socials { githubProfile: string; linkedin: string; twitter: string; + bluesky: string; website: string; } @@ -60,6 +61,7 @@ const InternetSettings = ({ githubProfile = '', linkedin = '', twitter = '', + bluesky = '', website = '' } = user; @@ -67,6 +69,7 @@ const InternetSettings = ({ githubProfile, linkedin, twitter, + bluesky, website }); @@ -99,7 +102,13 @@ const InternetSettings = ({ }; const isFormPristine = () => { - const originalValues = { githubProfile, linkedin, twitter, website }; + const originalValues = { + githubProfile, + linkedin, + twitter, + bluesky, + website + }; return (Object.keys(originalValues) as Array).every( key => originalValues[key] === formValues[key] @@ -120,6 +129,9 @@ const InternetSettings = ({ setIsEditing(false); }; + const { state: blueskyValidation, message: blueskyValidationMessage } = + getValidationStateFor(formValues.bluesky); + const { state: githubProfileValidation, message: githubProfileValidationMessage @@ -209,6 +221,27 @@ const InternetSettings = ({ /> + + + Bluesky + + + + + - - diff --git a/client/src/components/profile/components/social-icons.tsx b/client/src/components/profile/components/social-icons.tsx index b1285b01a9f..d76d4d46683 100644 --- a/client/src/components/profile/components/social-icons.tsx +++ b/client/src/components/profile/components/social-icons.tsx @@ -1,7 +1,8 @@ import { faLinkedin, faGithub, - faXTwitter + faXTwitter, + faBluesky } from '@fortawesome/free-brands-svg-icons'; import { faLink } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -17,6 +18,7 @@ interface SocialIconsProps { linkedin: string; show?: boolean; twitter: string; + bluesky: string; username: string; website: string; } @@ -82,9 +84,24 @@ function TwitterIcon({ href, username }: IconProps): JSX.Element { ); } +function BlueskyIcon({ href, username }: IconProps): JSX.Element { + const { t } = useTranslation(); + return ( + + + + ); +} + function SocialIcons(props: SocialIconsProps): JSX.Element | null { - const { githubProfile, linkedin, twitter, username, website } = props; - const show = linkedin || githubProfile || website || twitter; + const { githubProfile, linkedin, twitter, bluesky, username, website } = + props; + const show = linkedin || githubProfile || website || twitter || bluesky; if (!show) { return null; } @@ -98,6 +115,7 @@ function SocialIcons(props: SocialIconsProps): JSX.Element | null { ) : null} {website ? : null} {twitter ? : null} + {bluesky ? : null} ); diff --git a/client/src/components/profile/profile.test.tsx b/client/src/components/profile/profile.test.tsx index 01dc9835ef0..da53c976cbd 100644 --- a/client/src/components/profile/profile.test.tsx +++ b/client/src/components/profile/profile.test.tsx @@ -48,6 +48,7 @@ const userProps = { keyboardShortcuts: false, theme: UserThemes.Default, twitter: 'string', + bluesky: 'string', username: 'string', website: 'string', yearsTopContributor: [], diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index 24b871c387b..f83ef4c80ec 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -411,6 +411,7 @@ export type User = { theme: UserThemes; keyboardShortcuts: boolean; twitter: string; + bluesky: string; username: string; website: string; yearsTopContributor: string[]; diff --git a/e2e/internet-presence-settings.spec.ts b/e2e/internet-presence-settings.spec.ts index 432e2e310ec..d728603be9e 100644 --- a/e2e/internet-presence-settings.spec.ts +++ b/e2e/internet-presence-settings.spec.ts @@ -7,6 +7,7 @@ const settingsPageElement = { githubCheckmark: 'internet-github-check', linkedinCheckmark: 'internet-linkedin-check', twitterCheckmark: 'internet-twitter-check', + blueskyCheckmark: 'internet-bluesky-check', personalWebsiteCheckmark: 'internet-website-check', flashMessageAlert: 'flash-message', internetPresenceForm: 'internet-presence' @@ -63,6 +64,12 @@ test.describe('Your Internet Presence', () => { label: 'Twitter', checkTestId: settingsPageElement.twitterCheckmark }, + { + name: 'bluesky', + url: 'https://bsky.app/profile/certified-user.bsky.social', + label: 'Bluesky', + checkTestId: settingsPageElement.blueskyCheckmark + }, { name: 'website', url: 'https://certified-user.com',