mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
feat(client, api): bluesky profile link (#62400)
Co-authored-by: swdev33 <18295918+swdev33@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export const updateMySocials = {
|
||||
body: Type.Object({
|
||||
website: urlOrEmptyString,
|
||||
twitter: urlOrEmptyString,
|
||||
bluesky: urlOrEmptyString,
|
||||
githubProfile: urlOrEmptyString,
|
||||
linkedin: urlOrEmptyString
|
||||
}),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -48,6 +48,7 @@ const userProps = {
|
||||
keyboardShortcuts: false,
|
||||
theme: UserThemes.Default,
|
||||
twitter: 'string',
|
||||
bluesky: 'string',
|
||||
username: 'string',
|
||||
website: 'string',
|
||||
yearsTopContributor: [],
|
||||
|
||||
@@ -411,6 +411,7 @@ export type User = {
|
||||
theme: UserThemes;
|
||||
keyboardShortcuts: boolean;
|
||||
twitter: string;
|
||||
bluesky: string;
|
||||
username: string;
|
||||
website: string;
|
||||
yearsTopContributor: string[];
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user