feat(client, api): bluesky profile link (#62400)

Co-authored-by: swdev33 <18295918+swdev33@users.noreply.github.com>
This commit is contained in:
swdev33
2025-10-09 09:07:12 +02:00
committed by GitHub
parent 137b371b34
commit b707f80d63
20 changed files with 156 additions and 13 deletions

View File

@@ -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.
///

View File

@@ -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),

View File

@@ -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);
});

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

@@ -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
};

View File

@@ -9,6 +9,7 @@ export const updateMySocials = {
body: Type.Object({
website: urlOrEmptyString,
twitter: urlOrEmptyString,
bluesky: urlOrEmptyString,
githubProfile: urlOrEmptyString,
linkedin: urlOrEmptyString
}),

View File

@@ -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(),

View File

@@ -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(),

View File

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

View File

@@ -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.
*

View File

@@ -290,6 +290,28 @@ exports[`<Profile/> > renders correctly 1`] = `
/>
</svg>
</a>
<a
aria-label="aria.bluesky"
href="string"
rel="noopener noreferrer"
target="_blank"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-bluesky fa-2x "
data-icon="bluesky"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M111.8 62.2C170.2 105.9 233 194.7 256 242.4c23-47.6 85.8-136.4 144.2-180.2c42.1-31.6 110.3-56 110.3 21.8c0 15.5-8.9 130.5-14.1 149.2C478.2 298 412 314.6 353.1 304.5c102.9 17.5 129.1 75.5 72.5 133.5c-107.4 110.2-154.3-27.6-166.3-62.9l0 0c-1.7-4.9-2.6-7.8-3.3-7.8s-1.6 3-3.3 7.8l0 0c-12 35.3-59 173.1-166.3 62.9c-56.5-58-30.4-116 72.5-133.5C100 314.6 33.8 298 15.7 233.1C10.4 214.4 1.5 99.4 1.5 83.9c0-77.8 68.2-53.4 110.3-21.8z"
fill="currentColor"
/>
</svg>
</a>
</div>
</div>
</section>

View File

@@ -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}
/>

View File

@@ -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<keyof Socials>).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 = ({
/>
<Info message={twitterValidationMessage} />
</FormGroup>
<FormGroup
controlId='internet-bluesky'
validationState={blueskyValidation}
>
<ControlLabel htmlFor='internet-bluesky-input'>
Bluesky
</ControlLabel>
<FormControl
onChange={createHandleChange('bluesky')}
placeholder='https://bsky.app/profile/user-name.bsky.social'
type='url'
value={formValues.bluesky}
id='internet-bluesky-input'
/>
<Check
url={formValues.bluesky}
validation={blueskyValidation}
dataPlaywrightTestLabel='internet-bluesky-check'
/>
<Info message={blueskyValidationMessage} />
</FormGroup>
<FormGroup
controlId='internet-website'
validationState={websiteValidation}
@@ -223,13 +256,11 @@ const InternetSettings = ({
value={formValues.website}
id='internet-website-input'
/>
<Check
url={formValues.website}
validation={websiteValidation}
dataPlaywrightTestLabel='internet-website-check'
/>
<Info message={websiteValidationMessage} />
</FormGroup>
</div>

View File

@@ -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 (
<a
aria-label={t('aria.bluesky', { username })}
href={href}
rel='noopener noreferrer'
target='_blank'
>
<FontAwesomeIcon icon={faBluesky} size='2x' />
</a>
);
}
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 ? <WebsiteIcon href={website} username={username} /> : null}
{twitter ? <TwitterIcon href={twitter} username={username} /> : null}
{bluesky ? <BlueskyIcon href={bluesky} username={username} /> : null}
</Col>
</Row>
);

View File

@@ -48,6 +48,7 @@ const userProps = {
keyboardShortcuts: false,
theme: UserThemes.Default,
twitter: 'string',
bluesky: 'string',
username: 'string',
website: 'string',
yearsTopContributor: [],

View File

@@ -411,6 +411,7 @@ export type User = {
theme: UserThemes;
keyboardShortcuts: boolean;
twitter: string;
bluesky: string;
username: string;
website: string;
yearsTopContributor: string[];

View File

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