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',