Merge remote-tracking branch 'origin/main' into renovate/immer-10.x

environments/review-renovate-i-1iggu0/deployments/3987
Alex Gleason 1 year ago
commit 0cf97dbe25
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7

@ -12,10 +12,11 @@
yarn-error.log*
/junit.xml
/dist/
/static/
/static-test/
/public/
/dist/
/soapbox.zip
.idea
.DS_Store

@ -1,6 +1,6 @@
/node_modules/**
/dist/**
/static/**
/static-test/**
/public/**
/tmp/**
/coverage/**

@ -48,9 +48,8 @@ module.exports = {
'\\.(css|scss|json)$',
],
'import/resolver': {
node: {
paths: ['app'],
},
typescript: true,
node: true,
},
polyfills: [
'es:all', // core-js
@ -296,7 +295,7 @@ module.exports = {
{
// Only enforce JSDoc comments on UI components for now.
// https://www.npmjs.com/package/eslint-plugin-jsdoc
files: ['app/soapbox/components/ui/**/*'],
files: ['src/components/ui/**/*'],
rules: {
'jsdoc/require-jsdoc': ['error', {
publicOnly: true,

4
.gitignore vendored

@ -10,11 +10,13 @@
yarn-error.log*
/junit.xml
*.timestamp-*
*.bundled_*
/dist/
/static/
/static-test/
/public/
/dist/
/soapbox.zip
.idea
.DS_Store

@ -31,20 +31,9 @@ deps:
<<: *cache
policy: push
danger:
lint:
stage: test
script:
# https://github.com/danger/danger-js/issues/1029#issuecomment-998915436
- export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!}
- npx danger ci
except:
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
allow_failure: true
lint-js:
stage: test
script: yarn lint:js
script: yarn lint
only:
changes:
- "**/*.js"
@ -53,64 +42,25 @@ lint-js:
- "**/*.mjs"
- "**/*.ts"
- "**/*.tsx"
- ".eslintignore"
- ".eslintrc.cjs"
lint-sass:
stage: test
script: yarn lint:sass
only:
changes:
- "**/*.scss"
- "**/*.css"
- ".eslintignore"
- ".eslintrc.cjs"
- ".stylelintrc.json"
# jest:
# stage: test
# script: yarn test:coverage --runInBand
# only:
# changes:
# - "**/*.js"
# - "**/*.json"
# - "app/soapbox/**/*"
# - "webpack/**/*"
# - "custom/**/*"
# - "jest.config.cjs"
# - "package.json"
# - "yarn.lock"
# - ".gitlab-ci.yml"
# coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/
# artifacts:
# reports:
# junit: junit.xml
# coverage_report:
# coverage_format: cobertura
# path: .coverage/cobertura-coverage.xml
nginx-test:
build:
stage: test
image: nginx:latest
before_script:
- cp installation/mastodon.conf /etc/nginx/conf.d/default.conf
script: nginx -t
only:
changes:
- "installation/mastodon.conf"
build-production:
stage: test
- apt-get update -y && apt-get install -y zip
script:
- yarn build
# - yarn manage:translations en
# # Fail if files got changed.
# # https://stackoverflow.com/a/9066385
# - git diff --quiet
- cp static/index.html static/404.html
- cp dist/index.html dist/404.html
- cd dist && zip -r ../soapbox.zip . && cd ..
variables:
NODE_ENV: production
artifacts:
paths:
- static
- soapbox.zip
docs-deploy:
stage: deploy
@ -130,16 +80,20 @@ review:
environment:
name: review/$CI_COMMIT_REF_NAME
url: https://$CI_COMMIT_REF_SLUG.git.soapbox.pub
before_script:
- apt-get update -y && apt-get install -y unzip
script:
- npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub
- unzip soapbox.zip -d dist
- npx -y surge dist $CI_COMMIT_REF_SLUG.git.soapbox.pub
allow_failure: true
pages:
stage: deploy
before_script: []
before_script:
- apt-get update -y && apt-get install -y unzip
script:
# artifacts are kept between jobs
- mv static public
- unzip soapbox.zip -d public
variables:
NODE_ENV: production
artifacts:

@ -4,5 +4,5 @@
"*.mjs": "eslint --cache",
"*.ts": "eslint --cache",
"*.tsx": "eslint --cache",
"app/styles/**/*.scss": "stylelint"
"src/styles/**/*.scss": "stylelint"
}

@ -14,4 +14,4 @@ ENV FALLBACK_PORT=4444
ENV BACKEND_URL=http://localhost:4444
ENV CSP=
COPY installation/docker.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /app/static /usr/share/nginx/html
COPY --from=build /app/dist /usr/share/nginx/html

@ -1,5 +0,0 @@
# Custom icons
- verified.svg - Created by Alex Gleason. CC0
Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg

@ -1,24 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<link href="/manifest.json" rel="manifest">
<!--server-generated-meta-->
<%= snippets %>
</head>
<body class="theme-mode-light no-reduce-motion">
<div id="soapbox" class="h-full">
<div class="loading-indicator-wrapper">
<div class="loading-indicator">
<div class="loading-indicator__container">
<div class="loading-indicator__figure"></div>
</div>
</div>
</div>
</div>
<noscript>To use this website, please enable JavaScript.</noscript>
</body>
</html>

@ -1,95 +0,0 @@
{
"id": "108046244464677537",
"created_at": "2022-03-30T15:40:53.287Z",
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"language": null,
"uri": "https://truthsocial.com/users/alex/statuses/108046244464677537",
"url": "https://truthsocial.com/@alex/108046244464677537",
"replies_count": 0,
"reblogs_count": 0,
"favourites_count": 0,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "",
"reblog": null,
"application": {
"name": "Soapbox FE",
"website": "https://soapbox.pub/"
},
"account": {
"id": "107759994408336377",
"username": "alex",
"acct": "alex",
"display_name": "Alex Gleason",
"locked": false,
"bot": false,
"discoverable": null,
"group": false,
"created_at": "2022-02-08T00:00:00.000Z",
"note": "<p>Launching Truth Social</p>",
"url": "https://truthsocial.com/@alex",
"avatar": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png",
"avatar_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png",
"header": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png",
"header_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png",
"followers_count": 4713,
"following_count": 43,
"statuses_count": 7,
"last_status_at": "2022-03-30",
"verified": true,
"location": "Texas",
"website": "https://soapbox.pub/",
"emojis": [],
"fields": []
},
"media_attachments": [
{
"id": "108046243948255335",
"type": "video",
"url": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/media_attachments/files/108/046/243/948/255/335/original/3b17ce701c0d6f08.mp4",
"preview_url": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/cache/preview_cards/images/000/543/912/original/e1fcf6ace01d9ded.jpg",
"external_video_id": "vwfnq9",
"remote_url": null,
"preview_remote_url": null,
"text_url": "https://truthsocial.com/media/SpbYvqKIT2VubC9FFn0",
"meta": {
"original": {
"width": 988,
"height": 556,
"frame_rate": "60/1",
"duration": 1.949025,
"bitrate": 402396
}
},
"description": null,
"blurhash": null
}
],
"mentions": [],
"tags": [],
"emojis": [],
"card": {
"url": "https://rumble.com/vz1trd-video-upload-for-108046244464677537.html?mref=ummtf&mc=3nvg0",
"title": "Video upload for 108046244464677537",
"description": "",
"type": "video",
"author_name": "hostid1",
"author_url": "https://rumble.com/user/hostid1",
"provider_name": "Rumble.com",
"provider_url": "https://rumble.com/",
"html": "<iframe src=\"https://rumble.com/embed/vwfnq9/\" width=\"988\" height=\"556\" frameborder=\"0\" title=\"Video upload for 108046244464677537\" allowfullscreen=\"\"></iframe>",
"width": 988,
"height": 556,
"image": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/cache/preview_cards/images/000/543/912/original/e1fcf6ace01d9ded.jpg",
"embed_url": "",
"blurhash": "UQH1;m~8sks,%M~9?DRk-mRnR+xs9cWVj[bH"
},
"poll": null
}

@ -1,427 +0,0 @@
import api from '../api';
import type { AppDispatch, RootState } from 'soapbox/store';
/**
* LocalStorage 'soapbox:verification'
*
* {
* token: String,
* challenges: {
* email: Number (0 = incomplete, 1 = complete),
* sms: Number,
* age: Number
* }
* }
*/
const LOCAL_STORAGE_VERIFICATION_KEY = 'soapbox:verification';
const PEPE_FETCH_INSTANCE_SUCCESS = 'PEPE_FETCH_INSTANCE_SUCCESS';
const FETCH_CHALLENGES_SUCCESS = 'FETCH_CHALLENGES_SUCCESS';
const FETCH_TOKEN_SUCCESS = 'FETCH_TOKEN_SUCCESS';
const SET_NEXT_CHALLENGE = 'SET_NEXT_CHALLENGE';
const SET_CHALLENGES_COMPLETE = 'SET_CHALLENGES_COMPLETE';
const SET_LOADING = 'SET_LOADING';
const EMAIL: Challenge = 'email';
const SMS: Challenge = 'sms';
const AGE: Challenge = 'age';
export type Challenge = 'age' | 'sms' | 'email'
type Challenges = {
email?: 0 | 1
sms?: 0 | 1
age?: 0 | 1
}
type Verification = {
token?: string
challenges?: Challenges
challengeTypes?: Array<'age' | 'sms' | 'email'>
};
/**
* Fetch the state of the user's verification in local storage.
*/
const fetchStoredVerification = (): Verification | null => {
try {
return JSON.parse(localStorage.getItem(LOCAL_STORAGE_VERIFICATION_KEY) as string);
} catch {
return null;
}
};
/**
* Remove the state of the user's verification from local storage.
*/
const removeStoredVerification = () => {
localStorage.removeItem(LOCAL_STORAGE_VERIFICATION_KEY);
};
/**
* Fetch and return the Registration token for Pepe.
*/
const fetchStoredToken = () => {
try {
const verification: Verification | null = fetchStoredVerification();
return verification!.token;
} catch {
return null;
}
};
/**
* Fetch and return the state of the verification challenges.
*/
const fetchStoredChallenges = () => {
try {
const verification: Verification | null = fetchStoredVerification();
return verification!.challenges;
} catch {
return null;
}
};
/**
* Fetch and return the state of the verification challenge types.
*/
const fetchStoredChallengeTypes = () => {
try {
const verification: Verification | null = fetchStoredVerification();
return verification!.challengeTypes;
} catch {
return null;
}
};
/**
* Update the verification object in local storage.
*
* @param {*} verification object
*/
const updateStorage = ({ ...updatedVerification }: Verification) => {
const verification = fetchStoredVerification();
localStorage.setItem(
LOCAL_STORAGE_VERIFICATION_KEY,
JSON.stringify({ ...verification, ...updatedVerification }),
);
};
/**
* Fetch Pepe challenges and registration token
*/
const fetchVerificationConfig = () =>
async(dispatch: AppDispatch) => {
await dispatch(fetchPepeInstance());
dispatch(fetchRegistrationToken());
};
/**
* Save the challenges in localStorage.
*
* - If the API removes a challenge after the client has stored it, remove that
* challenge from localStorage.
* - If the API adds a challenge after the client has stored it, add that
* challenge to localStorage.
* - Don't overwrite a challenge that has already been completed.
* - Update localStorage to the new set of challenges.
*/
function saveChallenges(challenges: Array<'age' | 'sms' | 'email'>) {
const currentChallenges: Challenges = fetchStoredChallenges() || {};
const challengesToRemove = Object.keys(currentChallenges).filter((currentChallenge) => !challenges.includes(currentChallenge as Challenge)) as Challenge[];
challengesToRemove.forEach((challengeToRemove) => delete currentChallenges[challengeToRemove]);
for (let i = 0; i < challenges.length; i++) {
const challengeName = challenges[i];
if (typeof currentChallenges[challengeName] !== 'number') {
currentChallenges[challengeName] = 0;
}
}
updateStorage({
challenges: currentChallenges,
challengeTypes: challenges,
});
}
/**
* Finish a challenge.
*/
function finishChallenge(challenge: Challenge) {
const currentChallenges: Challenges = fetchStoredChallenges() || {};
// Set challenge to "complete"
currentChallenges[challenge] = 1;
updateStorage({ challenges: currentChallenges });
}
/**
* Fetch the next challenge
*/
const fetchNextChallenge = (): Challenge => {
const currentChallenges: Challenges = fetchStoredChallenges() || {};
return Object.keys(currentChallenges).find((challenge) => currentChallenges[challenge as Challenge] === 0) as Challenge;
};
/**
* Dispatch the next challenge or set to complete if all challenges are completed.
*/
const dispatchNextChallenge = (dispatch: AppDispatch) => {
const nextChallenge = fetchNextChallenge();
if (nextChallenge) {
dispatch({ type: SET_NEXT_CHALLENGE, challenge: nextChallenge });
} else {
dispatch({ type: SET_CHALLENGES_COMPLETE });
}
};
/**
* Fetch the challenges and age mininum from Pepe
*/
const fetchPepeInstance = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SET_LOADING });
return api(getState).get('/api/v1/pepe/instance').then(response => {
const { challenges, age_minimum: ageMinimum } = response.data;
saveChallenges(challenges);
const currentChallenge = fetchNextChallenge();
dispatch({ type: PEPE_FETCH_INSTANCE_SUCCESS, instance: { isReady: true, ...response.data } });
dispatch({
type: FETCH_CHALLENGES_SUCCESS,
ageMinimum,
currentChallenge,
isComplete: !currentChallenge,
});
})
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
/**
* Fetch the regristration token from Pepe unless it's already been stored locally
*/
const fetchRegistrationToken = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SET_LOADING });
const token = fetchStoredToken();
if (token) {
dispatch({
type: FETCH_TOKEN_SUCCESS,
value: token,
});
return null;
}
return api(getState).post('/api/v1/pepe/registrations')
.then(response => {
updateStorage({ token: response.data.access_token });
return dispatch({
type: FETCH_TOKEN_SUCCESS,
value: response.data.access_token,
});
})
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
const checkEmailAvailability = (email: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SET_LOADING });
const token = fetchStoredToken();
return api(getState).get(`/api/v1/pepe/account/exists?email=${email}`, {
headers: { Authorization: `Bearer ${token}` },
})
.catch(() => {})
.then(() => dispatch({ type: SET_LOADING, value: false }));
};
/**
* Send the user's email to Pepe to request confirmation
*/
const requestEmailVerification = (email: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SET_LOADING });
const token = fetchStoredToken();
return api(getState).post('/api/v1/pepe/verify_email/request', { email }, {
headers: { Authorization: `Bearer ${token}` },
})
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
const checkEmailVerification = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const token = fetchStoredToken();
return api(getState).get('/api/v1/pepe/verify_email', {
headers: { Authorization: `Bearer ${token}` },
});
};
/**
* Confirm the user's email with Pepe
*/
const confirmEmailVerification = (emailToken: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SET_LOADING });
const token = fetchStoredToken();
return api(getState).post('/api/v1/pepe/verify_email/confirm', { token: emailToken }, {
headers: { Authorization: `Bearer ${token}` },
})
.then((response) => {
updateStorageFromEmailConfirmation(dispatch, response.data.token);
})
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
const updateStorageFromEmailConfirmation = (dispatch: AppDispatch, token: string) => {
const challengeTypes = fetchStoredChallengeTypes();
if (!challengeTypes) {
return;
}
const indexOfEmail = challengeTypes.indexOf('email');
const challenges: Challenges = {};
challengeTypes?.forEach((challengeType, idx) => {
const value = idx <= indexOfEmail ? 1 : 0;
challenges[challengeType] = value;
});
updateStorage({ token, challengeTypes, challenges });
dispatchNextChallenge(dispatch);
};
const postEmailVerification = () =>
(dispatch: AppDispatch) => {
finishChallenge(EMAIL);
dispatchNextChallenge(dispatch);
};
/**
* Send the user's phone number to Pepe to request confirmation
*/
const requestPhoneVerification = (phone: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SET_LOADING });
const token = fetchStoredToken();
return api(getState).post('/api/v1/pepe/verify_sms/request', { phone }, {
headers: { Authorization: `Bearer ${token}` },
})
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
/**
* Send the user's phone number to Pepe to re-request confirmation
*/
const reRequestPhoneVerification = (phone: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SET_LOADING });
return api(getState).post('/api/v1/pepe/reverify_sms/request', { phone })
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
/**
* Confirm the user's phone number with Pepe
*/
const confirmPhoneVerification = (code: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SET_LOADING });
const token = fetchStoredToken();
return api(getState).post('/api/v1/pepe/verify_sms/confirm', { code }, {
headers: { Authorization: `Bearer ${token}` },
})
.then(() => {
finishChallenge(SMS);
dispatchNextChallenge(dispatch);
})
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
/**
* Re-Confirm the user's phone number with Pepe
*/
const reConfirmPhoneVerification = (code: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SET_LOADING });
return api(getState).post('/api/v1/pepe/reverify_sms/confirm', { code })
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
/**
* Confirm the user's age with Pepe
*/
const verifyAge = (birthday: Date) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SET_LOADING });
const token = fetchStoredToken();
return api(getState).post('/api/v1/pepe/verify_age/confirm', { birthday }, {
headers: { Authorization: `Bearer ${token}` },
})
.then(() => {
finishChallenge(AGE);
dispatchNextChallenge(dispatch);
})
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
/**
* Create the user's account with Pepe
*/
const createAccount = (username: string, password: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SET_LOADING });
const token = fetchStoredToken();
return api(getState).post('/api/v1/pepe/accounts', { username, password }, {
headers: { Authorization: `Bearer ${token}` },
}).finally(() => dispatch({ type: SET_LOADING, value: false }));
};
export {
PEPE_FETCH_INSTANCE_SUCCESS,
FETCH_CHALLENGES_SUCCESS,
FETCH_TOKEN_SUCCESS,
LOCAL_STORAGE_VERIFICATION_KEY,
SET_CHALLENGES_COMPLETE,
SET_LOADING,
SET_NEXT_CHALLENGE,
checkEmailAvailability,
confirmEmailVerification,
confirmPhoneVerification,
createAccount,
fetchStoredChallenges,
fetchVerificationConfig,
fetchRegistrationToken,
removeStoredVerification,
requestEmailVerification,
checkEmailVerification,
postEmailVerification,
reConfirmPhoneVerification,
requestPhoneVerification,
reRequestPhoneVerification,
verifyAge,
};

@ -1,25 +0,0 @@
import React from 'react';
import { COUNTRY_CODES, CountryCode } from 'soapbox/utils/phone';
interface ICountryCodeDropdown {
countryCode: CountryCode
onChange(countryCode: CountryCode): void
}
/** Dropdown menu to select a country code. */
const CountryCodeDropdown: React.FC<ICountryCodeDropdown> = ({ countryCode, onChange }) => {
return (
<select
value={countryCode}
className='h-full rounded-md border-transparent bg-transparent py-0 pl-3 pr-7 text-base focus:outline-none focus:ring-primary-500 dark:text-white sm:text-sm'
onChange={(event) => onChange(event.target.value as any)}
>
{COUNTRY_CODES.map((code) => (
<option value={code} key={code}>+{code}</option>
))}
</select>
);
};
export default CountryCodeDropdown;

@ -1,81 +0,0 @@
import { parsePhoneNumber, AsYouType } from 'libphonenumber-js';
import React, { useState, useEffect } from 'react';
import { CountryCode } from 'soapbox/utils/phone';
import Input from '../input/input';
import CountryCodeDropdown from './country-code-dropdown';
interface IPhoneInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'required' | 'autoFocus'> {
/** E164 phone number. */
value?: string
/** Change handler which receives the E164 phone string. */
onChange?: (phone: string | undefined) => void
/** Country code that's selected on mount. */
defaultCountryCode?: CountryCode
}
/** Internationalized phone input with country code picker. */
const PhoneInput: React.FC<IPhoneInput> = (props) => {
const { value, onChange, defaultCountryCode = '1', ...rest } = props;
const [countryCode, setCountryCode] = useState<CountryCode>(defaultCountryCode);
const [nationalNumber, setNationalNumber] = useState<string>('');
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
// HACK: AsYouType is not meant to be used this way. But it works!
const asYouType = new AsYouType({ defaultCallingCode: countryCode });
const formatted = asYouType.input(target.value);
// If the new value is the same as before, we might be backspacing,
// so use the actual event value instead of the formatted value.
if (formatted === nationalNumber && target.value !== nationalNumber) {
setNationalNumber(target.value);
} else {
setNationalNumber(formatted);
}
};
// When the internal state changes, update the external state.
useEffect(() => {
if (onChange) {
try {
const opts = { defaultCallingCode: countryCode, extract: false } as any;
const result = parsePhoneNumber(nationalNumber, opts);
// Throw if the number is invalid, but catch it below.
// We'll only ever call `onChange` with a valid E164 string or `undefined`.
if (!result.isPossible()) {
throw result;
}
onChange(result.format('E.164'));
} catch (e) {
// The value returned is always a valid E164 string.
// If it's not valid, it'll return undefined.
onChange(undefined);
}
}
}, [countryCode, nationalNumber]);
useEffect(() => {
handleChange({ target: { value: nationalNumber } } as any);
}, [countryCode, nationalNumber]);
return (
<Input
onChange={handleChange}
value={nationalNumber}
prepend={
<CountryCodeDropdown
countryCode={countryCode}
onChange={setCountryCode}
/>
}
{...rest}
/>
);
};
export default PhoneInput;

@ -1,142 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { useState, useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { Avatar, Card, HStack, Icon, IconButton, Stack, Text } from 'soapbox/components/ui';
import StatusCard from 'soapbox/features/status/components/card';
import { useInstance } from 'soapbox/hooks';
import { AdKeys } from 'soapbox/queries/ads';
import type { Ad as AdEntity } from 'soapbox/types/soapbox';
interface IAd {
ad: AdEntity
}
/** Displays an ad in sponsored post format. */
const Ad: React.FC<IAd> = ({ ad }) => {
const queryClient = useQueryClient();
const instance = useInstance();
const timer = useRef<NodeJS.Timeout | undefined>(undefined);
const infobox = useRef<HTMLDivElement>(null);
const [showInfo, setShowInfo] = useState(false);
// Fetch the impression URL (if any) upon displaying the ad.
// Don't fetch it more than once.
useQuery(['ads', 'impression', ad.impression], async () => {
if (ad.impression) {
return await axios.get(ad.impression);
}
}, { cacheTime: Infinity, staleTime: Infinity });
/** Invalidate query cache for ads. */
const bustCache = (): void => {
queryClient.invalidateQueries(AdKeys.ads);
};
/** Toggle the info box on click. */
const handleInfoButtonClick: React.MouseEventHandler = () => {
setShowInfo(!showInfo);
};
/** Hide the info box when clicked outside. */
const handleClickOutside = (event: MouseEvent) => {
if (event.target && infobox.current && !infobox.current.contains(event.target as any)) {
setShowInfo(false);
}
};
// Hide the info box when clicked outside.
// https://stackoverflow.com/a/42234988
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [infobox]);
// Wait until the ad expires, then invalidate cache.
useEffect(() => {
if (ad.expires_at) {
const delta = new Date(ad.expires_at).getTime() - (new Date()).getTime();
timer.current = setTimeout(bustCache, delta);
}
return () => {
if (timer.current) {
clearTimeout(timer.current);
}
};
}, [ad.expires_at]);
return (
<div className='relative'>
<Card className='py-4' variant='rounded'>
<Stack space={4}>
<HStack alignItems='center' space={3}>
<Avatar src={instance.thumbnail} size={42} />
<Stack grow>
<HStack space={1}>
<Text size='sm' weight='semibold' truncate>
{instance.title}
</Text>
<Icon
className='h-4 w-4 stroke-accent-500'
src={require('@tabler/icons/timeline.svg')}
/>
</HStack>
<Stack>
<HStack alignItems='center' space={1}>
<Text theme='muted' size='sm' truncate>
<FormattedMessage id='sponsored.subtitle' defaultMessage='Sponsored post' />
</Text>
</HStack>
</Stack>
</Stack>
<Stack justifyContent='center'>
<IconButton
iconClassName='h-6 w-6 stroke-gray-600'
src={require('@tabler/icons/info-circle.svg')}
onClick={handleInfoButtonClick}
/>
</Stack>
</HStack>
<StatusCard card={ad.card} onOpenMedia={() => { }} horizontal />
</Stack>
</Card>
{showInfo && (
<div ref={infobox} className='absolute right-5 top-5 max-w-[234px]'>
<Card variant='rounded'>
<Stack space={2}>
<Text size='sm' weight='bold'>
<FormattedMessage id='sponsored.info.title' defaultMessage='Why am I seeing this ad?' />
</Text>
<Text size='sm' theme='muted'>
{ad.reason ? (
ad.reason
) : (
<FormattedMessage
id='sponsored.info.message'
defaultMessage='{siteTitle} displays ads to help fund our service.'
values={{ siteTitle: instance.title }}
/>
)}
</Text>
</Stack>
</Card>
</div>
)}
</div>
);
};
export default Ad;

@ -1,42 +0,0 @@
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import type { RootState } from 'soapbox/store';
import type { Card } from 'soapbox/types/entities';
/** Map of available provider modules. */
const PROVIDERS: Record<string, () => Promise<AdProvider>> = {
soapbox: async() => (await import('./soapbox-config')).default,
truth: async() => (await import('./truth')).default,
};
/** Ad server implementation. */
interface AdProvider {
getAds(getState: () => RootState): Promise<Ad[]>
}
/** Entity representing an advertisement. */
interface Ad {
/** Ad data in Card (OEmbed-ish) format. */
card: Card
/** Impression URL to fetch when displaying the ad. */
impression?: string
/** Time when the ad expires and should no longer be displayed. */
expires_at?: string
/** Reason the ad is displayed. */
reason?: string
}
/** Gets the current provider based on config. */
const getProvider = async(getState: () => RootState): Promise<AdProvider | undefined> => {
const state = getState();
const soapboxConfig = getSoapboxConfig(state);
const isEnabled = soapboxConfig.extensions.getIn(['ads', 'enabled'], false) === true;
const providerName = soapboxConfig.extensions.getIn(['ads', 'provider'], 'soapbox') as string;
if (isEnabled && PROVIDERS[providerName]) {
return PROVIDERS[providerName]();
}
};
export { getProvider };
export type { Ad, AdProvider };

@ -1,14 +0,0 @@
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import type { AdProvider } from '.';
/** Provides ads from Soapbox Config. */
const SoapboxConfigAdProvider: AdProvider = {
getAds: async(getState) => {
const state = getState();
const soapboxConfig = getSoapboxConfig(state);
return soapboxConfig.ads.toArray();
},
};
export default SoapboxConfigAdProvider;

@ -1,40 +0,0 @@
import axios from 'axios';
import { z } from 'zod';
import { getSettings } from 'soapbox/actions/settings';
import { cardSchema } from 'soapbox/schemas/card';
import { filteredArray } from 'soapbox/schemas/utils';
import type { AdProvider } from '.';
/** TruthSocial ad API entity. */
const truthAdSchema = z.object({
impression: z.string(),
card: cardSchema,
expires_at: z.string(),
reason: z.string().catch(''),
});
/** Provides ads from the TruthSocial API. */
const TruthAdProvider: AdProvider = {
getAds: async(getState) => {
const state = getState();
const settings = getSettings(state);
try {
const { data } = await axios.get('/api/v2/truth/ads?device=desktop', {
headers: {
'Accept-Language': z.string().catch('*').parse(settings.get('locale')),
},
});
return filteredArray(truthAdSchema).parse(data);
} catch (e) {
// do nothing
}
return [];
},
};
export default TruthAdProvider;

@ -1,95 +0,0 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link, Redirect, Route, Switch, useHistory, useLocation } from 'react-router-dom';
import LandingGradient from 'soapbox/components/landing-gradient';
import SiteLogo from 'soapbox/components/site-logo';
import { useOwnAccount, useInstance, useRegistrationStatus } from 'soapbox/hooks';
import { Button, Card, CardBody } from '../../components/ui';
import LoginPage from '../auth-login/components/login-page';
import PasswordReset from '../auth-login/components/password-reset';
import PasswordResetConfirm from '../auth-login/components/password-reset-confirm';
import RegistrationForm from '../auth-login/components/registration-form';
import ExternalLoginForm from '../external-login/components/external-login-form';
import Footer from '../public-layout/components/footer';
import RegisterInvite from '../register-invite';
import Verification from '../verification';
import EmailPassthru from '../verification/email-passthru';
const messages = defineMessages({
register: { id: 'auth_layout.register', defaultMessage: 'Create an account' },
});
const AuthLayout = () => {
const intl = useIntl();
const history = useHistory();
const { search } = useLocation();
const { account } = useOwnAccount();
const instance = useInstance();
const { isOpen } = useRegistrationStatus();
const isLoginPage = history.location.pathname === '/login';
return (
<div className='h-full'>
<LandingGradient />
<main className='relative h-full sm:flex sm:justify-center'>
<div className='flex h-full w-full flex-col sm:max-w-lg md:max-w-2xl lg:max-w-6xl'>
<header className='relative mb-auto flex justify-between px-2 py-12'>
<div className='relative z-0 flex-1 px-2 lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center'>
<Link to='/' className='cursor-pointer'>
<SiteLogo alt={instance.title} className='h-7' />
</Link>
</div>
{(isLoginPage && isOpen) && (
<div className='relative z-10 ml-auto flex items-center'>
<Button
theme='tertiary'
icon={require('@tabler/icons/user.svg')}
to='/signup'
>
{intl.formatMessage(messages.register)}
</Button>
</div>
)}
</header>
<div className='flex flex-col items-center justify-center'>
<div className='w-full pb-10 sm:mx-auto sm:max-w-lg md:max-w-2xl'>
<Card variant='rounded' size='xl'>
<CardBody>
<Switch>
{/* If already logged in, redirect home. */}
{account && <Redirect from='/login' to='/' exact />}
<Route exact path='/verify' component={Verification} />
<Route exact path='/verify/email/:token' component={EmailPassthru} />
<Route exact path='/login/external' component={ExternalLoginForm} />
<Route exact path='/login/add' component={LoginPage} />
<Route exact path='/login' component={LoginPage} />
<Route exact path='/signup' component={RegistrationForm} />
<Route exact path='/reset-password' component={PasswordReset} />
<Route exact path='/edit-password' component={PasswordResetConfirm} />
<Route path='/invite/:token' component={RegisterInvite} />
<Redirect from='/auth/password/new' to='/reset-password' />
<Redirect from='/auth/password/edit' to={`/edit-password${search}`} />
</Switch>
</CardBody>
</Card>
</div>
</div>
<div className='mt-auto'>
<Footer />
</div>
</div>
</main>
</div>
);
};
export default AuthLayout;

@ -1,97 +0,0 @@
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Button, Form, FormActions, FormGroup, Input, Stack } from 'soapbox/components/ui';
import { useFeatures } from 'soapbox/hooks';
import ConsumersList from './consumers-list';
const messages = defineMessages({
username: {
id: 'login.fields.username_label',
defaultMessage: 'E-mail or username',
},
email: {
id: 'login.fields.email_label',
defaultMessage: 'E-mail address',
},
password: {
id: 'login.fields.password_placeholder',
defaultMessage: 'Password',
},
});
interface ILoginForm {
isLoading: boolean
handleSubmit: React.FormEventHandler
}
const LoginForm: React.FC<ILoginForm> = ({ isLoading, handleSubmit }) => {
const intl = useIntl();
const features = useFeatures();
const usernameLabel = intl.formatMessage(features.logInWithUsername ? messages.username : messages.email);
const passwordLabel = intl.formatMessage(messages.password);
return (
<div>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
<h1 className='text-center text-2xl font-bold'><FormattedMessage id='login_form.header' defaultMessage='Sign In' /></h1>
</div>
<Stack className='mx-auto sm:w-2/3 sm:pt-10 md:w-1/2' space={5}>
<Form onSubmit={handleSubmit}>
<FormGroup labelText={usernameLabel}>
<Input
aria-label={usernameLabel}
placeholder={usernameLabel}
type='text'
name='username'
autoCorrect='off'
autoCapitalize='off'
required
/>
</FormGroup>
<FormGroup
labelText={passwordLabel}
hintText={
<Link to='/reset-password' className='hover:underline' tabIndex={-1}>
<FormattedMessage
id='login.reset_password_hint'
defaultMessage='Trouble logging in?'
/>
</Link>
}
>
<Input
aria-label={passwordLabel}
placeholder={passwordLabel}
type='password'
name='password'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
required
/>
</FormGroup>
<FormActions>
<Button
theme='primary'
type='submit'
disabled={isLoading}
>
<FormattedMessage id='login.sign_in' defaultMessage='Sign in' />
</Button>
</FormActions>
</Form>
<ConsumersList />
</Stack>
</div>
);
};
export default LoginForm;

@ -1,10 +0,0 @@
import React from 'react';
import ExternalLoginForm from './components/external-login-form';
/** Page for logging into a remote instance */
const ExternalLoginPage: React.FC = () => {
return <ExternalLoginForm />;
};
export default ExternalLoginPage;

@ -1,90 +0,0 @@
import React from 'react';
import LandingPage from '..';
import { rememberInstance } from '../../../actions/instance';
import { SOAPBOX_CONFIG_REMEMBER_SUCCESS } from '../../../actions/soapbox';
import { PEPE_FETCH_INSTANCE_SUCCESS } from '../../../actions/verification';
import { render, screen, rootReducer, applyActions } from '../../../jest/test-helpers';
describe('<LandingPage />', () => {
it('renders a RegistrationForm for an open Pleroma instance', () => {
const state = rootReducer(undefined, {
type: rememberInstance.fulfilled.type,
payload: {
version: '2.7.2 (compatible; Pleroma 2.3.0)',
registrations: true,
},
});
render(<LandingPage />, undefined, state);
expect(screen.queryByTestId('registrations-open')).toBeInTheDocument();
expect(screen.queryByTestId('registrations-closed')).not.toBeInTheDocument();
expect(screen.queryByTestId('registrations-pepe')).not.toBeInTheDocument();
});
it('renders "closed" message for a closed Pleroma instance', () => {
const state = rootReducer(undefined, {
type: rememberInstance.fulfilled.type,
payload: {
version: '2.7.2 (compatible; Pleroma 2.3.0)',
registrations: false,
},
});
render(<LandingPage />, undefined, state);
expect(screen.queryByTestId('registrations-closed')).toBeInTheDocument();
expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument();
expect(screen.queryByTestId('registrations-pepe')).not.toBeInTheDocument();
});
it('renders Pepe flow if Pepe extension is enabled', () => {
const state = applyActions(undefined, [{
type: SOAPBOX_CONFIG_REMEMBER_SUCCESS,
soapboxConfig: {
extensions: {
pepe: {
enabled: true,
},
},
},
}, {
type: PEPE_FETCH_INSTANCE_SUCCESS,
instance: {
registrations: true,
},
}], rootReducer);
render(<LandingPage />, undefined, state);
expect(screen.queryByTestId('registrations-pepe')).toBeInTheDocument();
expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument();
expect(screen.queryByTestId('registrations-closed')).not.toBeInTheDocument();
});
it('renders "closed" message for a Truth Social instance with Pepe closed', () => {
const state = applyActions(undefined, [{
type: rememberInstance.fulfilled.type,
payload: {
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
registrations: false,
},
}, {
type: PEPE_FETCH_INSTANCE_SUCCESS,
instance: {
registrations: false,
},
}], rootReducer);
render(<LandingPage />, undefined, state);
expect(screen.queryByTestId('registrations-closed')).toBeInTheDocument();
expect(screen.queryByTestId('registrations-pepe')).not.toBeInTheDocument();
expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument();
});
});

@ -1,133 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { isDefaultAvatar } from 'soapbox/utils/accounts';
import resizeImage from 'soapbox/utils/resize-image';
import type { AxiosError } from 'axios';
const messages = defineMessages({
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useAppDispatch();
const { account } = useOwnAccount();
const fileInput = React.useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = React.useState<string | null>();
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [isDisabled, setDisabled] = React.useState<boolean>(true);
const isDefault = account ? isDefaultAvatar(account.avatar) : false;
const openFilePicker = () => {
fileInput.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const maxPixels = 400 * 400;
const rawFile = event.target.files?.item(0);
if (!rawFile) return;
resizeImage(rawFile, maxPixels).then((file) => {
const url = file ? URL.createObjectURL(file) : account?.avatar as string;
setSelectedFile(url);
setSubmitting(true);
const formData = new FormData();
formData.append('avatar', rawFile);
const credentials = dispatch(patchMe(formData));
Promise.all([credentials]).then(() => {
setDisabled(false);
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
setDisabled(false);
setSelectedFile(null);
if (error.response?.status === 422) {
toast.error((error.response.data as any).error.replace('Validation failed: ', ''));
} else {
toast.error(messages.error);
}
});
}).catch(console.error);
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-900/50 sm:-mx-10 sm:pb-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
<FormattedMessage id='onboarding.avatar.title' defaultMessage='Choose a profile picture' />
</Text>
<Text theme='muted' align='center'>
<FormattedMessage id='onboarding.avatar.subtitle' defaultMessage='Just have fun with it.' />
</Text>
</Stack>
</div>
<div className='mx-auto sm:w-2/3 sm:pt-10 md:w-1/2'>
<Stack space={10}>
<div className='relative mx-auto rounded-full bg-gray-200'>
{account && (
<Avatar src={selectedFile || account.avatar} size={175} />
)}
{isSubmitting && (
<div className='absolute inset-0 flex items-center justify-center rounded-full bg-white/80 dark:bg-primary-900/80'>
<Spinner withText={false} />
</div>
)}
<button
onClick={openFilePicker}
type='button'
className={clsx({
'absolute bottom-3 right-2 p-1 bg-primary-600 rounded-full ring-2 ring-white dark:ring-primary-900 hover:bg-primary-700': true,
'opacity-50 pointer-events-none': isSubmitting,
})}
disabled={isSubmitting}
>
<Icon src={require('@tabler/icons/plus.svg')} className='h-5 w-5 text-white' />
</button>
<input type='file' className='hidden' ref={fileInput} onChange={handleFileChange} />
</div>
<Stack justifyContent='center' space={2}>
<Button block theme='primary' type='button' onClick={onNext} disabled={isDefault && isDisabled || isSubmitting}>
{isSubmitting ? (
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
) : (
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
)}
</Button>
{isDisabled && (
<Button block theme='tertiary' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
)}
</Stack>
</Stack>
</div>
</div>
</CardBody>
</Card>
);
};
export default AvatarSelectionStep;

@ -1,105 +0,0 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import { Button, Card, CardBody, FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import type { AxiosError } from 'axios';
const messages = defineMessages({
bioPlaceholder: { id: 'onboarding.bio.placeholder', defaultMessage: 'Tell the world a little about yourself…' },
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
const BioStep = ({ onNext }: { onNext: () => void }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { account } = useOwnAccount();
const [value, setValue] = React.useState<string>(account?.source?.note ?? '');
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [errors, setErrors] = React.useState<string[]>([]);
const handleSubmit = () => {
setSubmitting(true);
const credentials = dispatch(patchMe({ note: value }));
Promise.all([credentials])
.then(() => {
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
} else {
toast.error(messages.error);
}
});
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
<FormattedMessage id='onboarding.note.title' defaultMessage='Write a short bio' />
</Text>
<Text theme='muted' align='center'>
<FormattedMessage id='onboarding.note.subtitle' defaultMessage='You can always edit this later.' />
</Text>
</Stack>
</div>
<Stack space={5}>
<div className='mx-auto sm:w-2/3 sm:pt-10'>
<FormGroup
hintText={<FormattedMessage id='onboarding.bio.hint' defaultMessage='Max 500 characters' />}
labelText={<FormattedMessage id='edit_profile.fields.bio_label' defaultMessage='Bio' />}
errors={errors}
>
<Textarea
onChange={(event) => setValue(event.target.value)}
placeholder={intl.formatMessage(messages.bioPlaceholder)}
value={value}
maxLength={500}
/>
</FormGroup>
</div>
<div className='mx-auto sm:w-2/3 md:w-1/2'>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
type='submit'
disabled={isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? (
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
) : (
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
)}
</Button>
<Button block theme='tertiary' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
</Stack>
</div>
</Stack>
</div>
</CardBody>
</Card>
);
};
export default BioStep;

@ -1,156 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import StillImage from 'soapbox/components/still-image';
import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { isDefaultHeader } from 'soapbox/utils/accounts';
import resizeImage from 'soapbox/utils/resize-image';
import type { AxiosError } from 'axios';
const messages = defineMessages({
header: { id: 'account.header.alt', defaultMessage: 'Profile header' },
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { account } = useOwnAccount();
const fileInput = React.useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = React.useState<string | null>();
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [isDisabled, setDisabled] = React.useState<boolean>(true);
const isDefault = account ? isDefaultHeader(account.header) : false;
const openFilePicker = () => {
fileInput.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const maxPixels = 1920 * 1080;
const rawFile = event.target.files?.item(0);
if (!rawFile) return;
resizeImage(rawFile, maxPixels).then((file) => {
const url = file ? URL.createObjectURL(file) : account?.header as string;
setSelectedFile(url);
setSubmitting(true);
const formData = new FormData();
formData.append('header', file);
const credentials = dispatch(patchMe(formData));
Promise.all([credentials]).then(() => {
setDisabled(false);
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
setDisabled(false);
setSelectedFile(null);
if (error.response?.status === 422) {
toast.error((error.response.data as any).error.replace('Validation failed: ', ''));
} else {
toast.error(messages.error);
}
});
}).catch(console.error);
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
<FormattedMessage id='onboarding.header.title' defaultMessage='Pick a cover image' />
</Text>
<Text theme='muted' align='center'>
<FormattedMessage id='onboarding.header.subtitle' defaultMessage='This will be shown at the top of your profile.' />
</Text>
</Stack>
</div>
<div className='mx-auto sm:w-2/3 sm:pt-10 md:w-1/2'>
<Stack space={10}>
<div className='rounded-lg border border-solid border-gray-200 dark:border-gray-800'>
<div
role='button'
className='relative flex h-24 items-center justify-center rounded-t-md bg-gray-200 dark:bg-gray-800'
>
{selectedFile || account?.header && (
<StillImage
src={selectedFile || account.header}
alt={intl.formatMessage(messages.header)}
className='absolute inset-0 rounded-t-md object-cover'
/>
)}
{isSubmitting && (
<div
className='absolute inset-0 flex items-center justify-center rounded-t-md bg-white/80 dark:bg-primary-900/80'
>
<Spinner withText={false} />
</div>
)}
<button
onClick={openFilePicker}
type='button'
className={clsx({
'absolute -top-3 -right-3 p-1 bg-primary-600 rounded-full ring-2 ring-white dark:ring-primary-900 hover:bg-primary-700': true,
'opacity-50 pointer-events-none': isSubmitting,
})}
disabled={isSubmitting}
>
<Icon src={require('@tabler/icons/plus.svg')} className='h-5 w-5 text-white' />
</button>
<input type='file' className='hidden' ref={fileInput} onChange={handleFileChange} />
</div>
<div className='flex flex-col px-4 pb-4'>
{account && (
<Avatar src={account.avatar} size={64} className='-mt-8 mb-2 ring-2 ring-white dark:ring-primary-800' />
)}
<Text weight='bold' size='sm'>{account?.display_name}</Text>
<Text theme='muted' size='sm'>@{account?.username}</Text>
</div>
</div>
<Stack justifyContent='center' space={2}>
<Button block theme='primary' type='button' onClick={onNext} disabled={isDefault && isDisabled || isSubmitting}>
{isSubmitting ? (
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
) : (
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
)}
</Button>
{isDisabled && (
<Button block theme='tertiary' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
)}
</Stack>
</Stack>
</div>
</div>
</CardBody>
</Card>
);
};
export default CoverPhotoSelectionStep;

@ -1,115 +0,0 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import { Button, Card, CardBody, FormGroup, Input, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import type { AxiosError } from 'axios';
const messages = defineMessages({
usernamePlaceholder: { id: 'onboarding.display_name.placeholder', defaultMessage: 'Eg. John Smith' },
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { account } = useOwnAccount();
const [value, setValue] = React.useState<string>(account?.display_name || '');
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [errors, setErrors] = React.useState<string[]>([]);
const trimmedValue = value.trim();
const isValid = trimmedValue.length > 0;
const isDisabled = !isValid || value.length > 30;
const hintText = React.useMemo(() => {
const charsLeft = 30 - value.length;
const suffix = charsLeft === 1 ? 'character remaining' : 'characters remaining';
return `${charsLeft} ${suffix}`;
}, [value]);
const handleSubmit = () => {
setSubmitting(true);
const credentials = dispatch(patchMe({ display_name: value }));
Promise.all([credentials])
.then(() => {
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
} else {
toast.error(messages.error);
}
});
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
<FormattedMessage id='onboarding.display_name.title' defaultMessage='Choose a display name' />
</Text>
<Text theme='muted' align='center'>
<FormattedMessage id='onboarding.display_name.subtitle' defaultMessage='You can always edit this later.' />
</Text>
</Stack>
</div>
<div className='mx-auto sm:w-2/3 sm:pt-10 md:w-1/2'>
<Stack space={5}>
<FormGroup
hintText={hintText}
labelText={<FormattedMessage id='onboarding.display_name.label' defaultMessage='Display name' />}
errors={errors}
>
<Input
onChange={(event) => setValue(event.target.value)}
placeholder={intl.formatMessage(messages.usernamePlaceholder)}
type='text'
value={value}
maxLength={30}
/>
</FormGroup>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
type='submit'
disabled={isDisabled || isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? (
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
) : (
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
)}
</Button>
<Button block theme='tertiary' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
</Stack>
</Stack>
</div>
</div>
</CardBody>
</Card>
);
};
export default DisplayNameStep;

@ -1,18 +0,0 @@
import { abovefoldAlgorithm } from '../abovefold';
const DATA = Object.freeze(['a', 'b', 'c', 'd']);
test('abovefoldAlgorithm', () => {
const result = Array(50).fill('').map((_, i) => {
return abovefoldAlgorithm(DATA, i, { seed: '!', range: [2, 6], pageSize: 20 });
});
// console.log(result);
expect(result[0]).toBe(undefined);
expect(result[4]).toBe('a');
expect(result[5]).toBe(undefined);
expect(result[24]).toBe('b');
expect(result[30]).toBe(undefined);
expect(result[42]).toBe('c');
expect(result[43]).toBe(undefined);
});

@ -1,19 +0,0 @@
import { linearAlgorithm } from '../linear';
const DATA = Object.freeze(['a', 'b', 'c', 'd']);
test('linearAlgorithm', () => {
const result = Array(50).fill('').map((_, i) => {
return linearAlgorithm(DATA, i, { interval: 5 });
});
// console.log(result);
expect(result[0]).toBe(undefined);
expect(result[4]).toBe('a');
expect(result[8]).toBe(undefined);
expect(result[9]).toBe('b');
expect(result[10]).toBe(undefined);
expect(result[14]).toBe('c');
expect(result[15]).toBe(undefined);
expect(result[19]).toBe('d');
});

@ -1,52 +0,0 @@
import seedrandom from 'seedrandom';
import type { PickAlgorithm } from './types';
type Opts = {
/** Randomization seed. */
seed: string
/**
* Start/end index of the slot by which one item will be randomly picked per page.
*
* Eg. `[2, 6]` will cause one item to be picked among the third through seventh indexes.
*
* `end` must be larger than `start`.
*/
range: [start: number, end: number]
/** Number of items in the page. */
pageSize: number
};
/**
* Algorithm to display items per-page.
* One item is randomly inserted into each page within the index range.
*/
const abovefoldAlgorithm: PickAlgorithm = (items, iteration, rawOpts) => {
const opts = normalizeOpts(rawOpts);
/** Current page of the index. */
const page = Math.floor(iteration / opts.pageSize);
/** Current index within the page. */
const pageIndex = (iteration % opts.pageSize);
/** RNG for the page. */
const rng = seedrandom(`${opts.seed}-page-${page}`);
/** Index to insert the item. */
const insertIndex = Math.floor(rng() * (opts.range[1] - opts.range[0])) + opts.range[0];
if (pageIndex === insertIndex) {
return items[page % items.length];
}
};
const normalizeOpts = (opts: unknown): Opts => {
const { seed, range, pageSize } = (opts && typeof opts === 'object' ? opts : {}) as Record<any, unknown>;
return {
seed: typeof seed === 'string' ? seed : '',
range: Array.isArray(range) ? [Number(range[0]), Number(range[1])] : [2, 6],
pageSize: typeof pageSize === 'number' ? pageSize : 20,
};
};
export {
abovefoldAlgorithm,
};

@ -1,11 +0,0 @@
import { abovefoldAlgorithm } from './abovefold';
import { linearAlgorithm } from './linear';
import type { PickAlgorithm } from './types';
const ALGORITHMS: Record<any, PickAlgorithm | undefined> = {
'linear': linearAlgorithm,
'abovefold': abovefoldAlgorithm,
};
export { ALGORITHMS };

@ -1,28 +0,0 @@
import type { PickAlgorithm } from './types';
type Opts = {
/** Number of iterations until the next item is picked. */
interval: number
};
/** Picks the next item every iteration. */
const linearAlgorithm: PickAlgorithm = (items, iteration, rawOpts) => {
const opts = normalizeOpts(rawOpts);
const itemIndex = items ? Math.floor(iteration / opts.interval) % items.length : 0;
const item = items ? items[itemIndex] : undefined;
const showItem = (iteration + 1) % opts.interval === 0;
return showItem ? item : undefined;
};
const normalizeOpts = (opts: unknown): Opts => {
const { interval } = (opts && typeof opts === 'object' ? opts : {}) as Record<any, unknown>;
return {
interval: typeof interval === 'number' ? interval : 20,
};
};
export {
linearAlgorithm,
};

@ -1,15 +0,0 @@
/**
* Returns an item to insert at the index, or `undefined` if an item shouldn't be inserted.
*/
type PickAlgorithm = <D = any>(
/** Elligible candidates to pick. */
items: readonly D[],
/** Current iteration by which an item may be chosen. */
iteration: number,
/** Implementation-specific opts. */
opts: Record<string, unknown>
) => D | undefined;
export {
PickAlgorithm,
};

@ -1,228 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import OtpInput from 'react-otp-input';
import { verifyCredentials } from 'soapbox/actions/auth';
import { closeModal } from 'soapbox/actions/modals';
import { reConfirmPhoneVerification, reRequestPhoneVerification } from 'soapbox/actions/verification';
import { FormGroup, PhoneInput, Modal, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { getAccessToken } from 'soapbox/utils/auth';
const messages = defineMessages({
verificationInvalid: {
id: 'sms_verification.invalid',
defaultMessage: 'Please enter a valid phone number.',
},
verificationSuccess: {
id: 'sms_verification.success',
defaultMessage: 'A verification code has been sent to your phone number.',
},
verificationFail: {
id: 'sms_verification.fail',
defaultMessage: 'Failed to send SMS message to your phone number.',
},
verificationExpired: {
id: 'sms_verification.expired',
defaultMessage: 'Your SMS token has expired.',
},
verifySms: {
id: 'sms_verification.modal.verify_sms',
defaultMessage: 'Verify SMS',
},
verifyNumber: {
id: 'sms_verification.modal.verify_number',
defaultMessage: 'Verify phone number',
},
verifyCode: {
id: 'sms_verification.modal.verify_code',
defaultMessage: 'Verify code',
},
});
interface IVerifySmsModal {
onClose: (type: string) => void
}
enum Statuses {
IDLE = 'IDLE',
READY = 'READY',
REQUESTED = 'REQUESTED',
FAIL = 'FAIL',
SUCCESS = 'SUCCESS',
}
const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const instance = useInstance();
const accessToken = useAppSelector((state) => getAccessToken(state));
const isLoading = useAppSelector((state) => state.verification.isLoading);
const [status, setStatus] = useState<Statuses>(Statuses.IDLE);
const [phone, setPhone] = useState<string>();
const [verificationCode, setVerificationCode] = useState('');
const [requestedAnother, setAlreadyRequestedAnother] = useState(false);
const isValid = !!phone;
const onChange = useCallback((phone?: string) => {
setPhone(phone);
}, []);
const handleSubmit = (event: React.MouseEvent) => {
event.preventDefault();
if (!isValid) {
setStatus(Statuses.IDLE);
toast.error(intl.formatMessage(messages.verificationInvalid));
return;
}
dispatch(reRequestPhoneVerification(phone!)).then(() => {
toast.success(
intl.formatMessage(messages.verificationSuccess),
);
})
.finally(() => setStatus(Statuses.REQUESTED))
.catch(() => {
toast.error(intl.formatMessage(messages.verificationFail));
});
};
const resendVerificationCode = (event?: React.MouseEvent<HTMLButtonElement>) => {
setAlreadyRequestedAnother(true);
handleSubmit(event as React.MouseEvent<HTMLButtonElement>);
};
const onConfirmationClick = (event: any) => {
switch (status) {
case Statuses.IDLE:
setStatus(Statuses.READY);
break;
case Statuses.READY:
handleSubmit(event);
break;
case Statuses.REQUESTED:
submitVerification();
break;
default: break;
}
};
const confirmationText = useMemo(() => {
switch (status) {
case Statuses.IDLE:
return intl.formatMessage(messages.verifySms);
case Statuses.READY:
return intl.formatMessage(messages.verifyNumber);
case Statuses.REQUESTED:
return intl.formatMessage(messages.verifyCode);
default:
return null;
}
}, [status]);
const renderModalBody = () => {
switch (status) {
case Statuses.IDLE:
return (
<Text theme='muted'>
<FormattedMessage
id='sms_verification.modal.verify_help_text'
defaultMessage='Verify your phone number to start using {instance}.'
values={{
instance: instance.title,
}}
/>
</Text>
);
case Statuses.READY:
return (
<FormGroup labelText={<FormattedMessage id='sms_verification.phone.label' defaultMessage='Phone number' />}>
<PhoneInput
value={phone}
onChange={onChange}
required
autoFocus
/>
</FormGroup>
);
case Statuses.REQUESTED:
return (
<>
<Text theme='muted' size='sm' align='center'>
<FormattedMessage
id='sms_verification.modal.enter_code'
defaultMessage='We sent you a 6-digit code via SMS. Enter it below.'
/>
</Text>
<OtpInput
value={verificationCode}
onChange={setVerificationCode}
numInputs={6}
isInputNum
shouldAutoFocus
isDisabled={isLoading}
containerStyle='flex justify-center mt-2 space-x-4'
inputStyle='w-10i border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500'
/>
</>
);
default:
return null;
}
};
const submitVerification = () => {
if (!accessToken) return;
// TODO: handle proper validation from Pepe -- expired vs invalid
dispatch(reConfirmPhoneVerification(verificationCode))
.then(() => {
setStatus(Statuses.SUCCESS);
// eslint-disable-next-line promise/catch-or-return
dispatch(verifyCredentials(accessToken))
.then(() => dispatch(closeModal('VERIFY_SMS')));
})
.catch(() => toast.error(intl.formatMessage(messages.verificationExpired)));
};
useEffect(() => {
if (verificationCode.length === 6) {
submitVerification();
}
}, [verificationCode]);
return (
<Modal
title={
<FormattedMessage
id='sms_verification.modal.verify_title'
defaultMessage='Verify your phone number'
/>
}
onClose={() => onClose('VERIFY_SMS')}
cancelAction={status === Statuses.IDLE ? () => onClose('VERIFY_SMS') : undefined}
cancelText='Skip for now'
confirmationAction={onConfirmationClick}
confirmationText={confirmationText}
secondaryAction={status === Statuses.REQUESTED ? resendVerificationCode : undefined}
secondaryText={status === Statuses.REQUESTED ? (
<FormattedMessage
id='sms_verification.modal.resend_code'
defaultMessage='Resend verification code?'
/>
) : undefined}
secondaryDisabled={requestedAnother}
>
<Stack space={4}>
{renderModalBody()}
</Stack>
</Modal>
);
};
export default VerifySmsModal;

@ -1,105 +0,0 @@
import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { __stub } from 'soapbox/api';
import { render, screen } from '../../../jest/test-helpers';
import Verification from '../index';
const TestableComponent = () => (
<Switch>
<Route path='/verify' exact><Verification /></Route>
<Route path='/' exact><span data-testid='home'>Homepage</span></Route>
</Switch>
);
const renderComponent = (store: any) => render(
<TestableComponent />,
{},
store,
{ initialEntries: ['/verify'] },
);
describe('<Verification />', () => {
let store: any;
beforeEach(() => {
store = {
verification: ImmutableRecord({
instance: ImmutableMap({
isReady: true,
registrations: true,
}),
ageMinimum: null,
currentChallenge: null,
isLoading: false,
isComplete: false,
token: null,
})(),
};
__stub(mock => {
mock.onGet('/api/v1/pepe/instance')
.reply(200, {
age_minimum: 18,
approval_required: true,
challenges: ['age', 'email', 'sms'],
});
mock.onPost('/api/v1/pepe/registrations')
.reply(200, {
access_token: 'N-dZmNqNSmTutJLsGjZ5AnJL4sLw_y-N3pn2acSqJY8',
});
});
});
describe('When registration is closed', () => {
it('successfully redirects to the homepage', () => {
const verification = store.verification.setIn(['instance', 'registrations'], false);
store.verification = verification;
renderComponent(store);
expect(screen.getByTestId('home')).toHaveTextContent('Homepage');
});
});
describe('When verification is complete', () => {
it('successfully renders the Registration component', () => {
const verification = store.verification.set('isComplete', true);
store.verification = verification;
renderComponent(store);
expect(screen.getByRole('heading')).toHaveTextContent('Register your account');
});
});
describe('Switching verification steps', () => {
it('successfully renders the Birthday step', () => {
const verification = store.verification.set('currentChallenge', 'age');
store.verification = verification;
renderComponent(store);
expect(screen.getByRole('heading')).toHaveTextContent('Enter your birth date');
});
it('successfully renders the Email step', () => {
const verification = store.verification.set('currentChallenge', 'email');
store.verification = verification;
renderComponent(store);
expect(screen.getByRole('heading')).toHaveTextContent('Enter your email address');
});
it('successfully renders the SMS step', () => {
const verification = store.verification.set('currentChallenge', 'sms');
store.verification = verification;
renderComponent(store);
expect(screen.getByRole('heading')).toHaveTextContent('Enter your phone number');
});
});
});

@ -1,117 +0,0 @@
import React from 'react';
import { __stub } from 'soapbox/api';
import { fireEvent, render, screen, waitFor } from '../../../jest/test-helpers';
import Registration from '../registration';
describe('<Registration />', () => {
it('renders', () => {
render(<Registration />);
expect(screen.getByRole('heading')).toHaveTextContent(/register your account/i);
});
describe('with valid data', () => {
beforeEach(() => {
__stub(mock => {
mock.onPost('/api/v1/pepe/accounts').reply(200, {});
mock.onPost('/api/v1/apps').reply(200, {});
mock.onPost('/oauth/token').reply(200, {});
mock.onGet('/api/v1/accounts/verify_credentials').reply(200, { id: '123' });
mock.onGet('/api/v1/instance').reply(200, {});
});
});
it('handles successful submission', async() => {
render(<Registration />);
await waitFor(() => {
fireEvent.submit(screen.getByTestId('button'), { preventDefault: () => {} });
});
await waitFor(() => {
expect(screen.getByTestId('toast')).toHaveTextContent(/welcome to/i);
});
expect(screen.queryAllByRole('heading')).toHaveLength(0);
});
});
describe('with invalid data', () => {
it('handles 422 errors', async() => {
__stub(mock => {
mock.onPost('/api/v1/pepe/accounts').reply(
422, {
error: 'user_taken',
},
);
});
render(<Registration />);
await waitFor(() => {
fireEvent.submit(screen.getByTestId('button'), { preventDefault: () => {} });
});
await waitFor(() => {
expect(screen.getByTestId('toast')).toHaveTextContent(/this username has already been taken/i);
});
});
it('handles 422 errors with messages', async() => {
__stub(mock => {
mock.onPost('/api/v1/pepe/accounts').reply(
422, {
error: 'user_vip',
message: 'This username is unavailable.',
},
);
});
render(<Registration />);
await waitFor(() => {
fireEvent.submit(screen.getByTestId('button'), { preventDefault: () => {} });
});
await waitFor(() => {
expect(screen.getByTestId('toast')).toHaveTextContent(/this username is unavailable/i);
});
});
it('handles generic errors', async() => {
__stub(mock => {
mock.onPost('/api/v1/pepe/accounts').reply(500, {});
});
render(<Registration />);
await waitFor(() => {
fireEvent.submit(screen.getByTestId('button'), { preventDefault: () => {} });
});
await waitFor(() => {
expect(screen.getByTestId('toast')).toHaveTextContent(/failed to register your account/i);
});
});
});
describe('validations', () => {
it('should undisable button with valid password', async() => {
render(<Registration />);
expect(screen.getByTestId('button')).toBeDisabled();
fireEvent.change(screen.getByTestId('password-input'), { target: { value: 'Password' } });
expect(screen.getByTestId('button')).not.toBeDisabled();
});
it('should disable button with invalid password', async() => {
render(<Registration />);
fireEvent.change(screen.getByTestId('password-input'), { target: { value: 'Passwor' } });
expect(screen.getByTestId('button')).toBeDisabled();
});
});
});

@ -1,72 +0,0 @@
import React, { useEffect, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Stack } from 'soapbox/components/ui';
import ValidationCheckmark from 'soapbox/components/validation-checkmark';
const messages = defineMessages({
minimumCharacters: {
id: 'registration.validation.minimum_characters',
defaultMessage: '8 characters',
},
capitalLetter: {
id: 'registration.validation.capital_letter',
defaultMessage: '1 capital letter',
},
lowercaseLetter: {
id: 'registration.validation.lowercase_letter',
defaultMessage: '1 lowercase letter',
},
});
const hasUppercaseCharacter = (string: string) => {
for (let i = 0; i < string.length; i++) {
if (string.charAt(i) === string.charAt(i).toUpperCase() && string.charAt(i).match(/[a-z]/i)) {
return true;
}
}
return false;
};
const hasLowercaseCharacter = (string: string) => {
return string.toUpperCase() !== string;
};
interface IPasswordIndicator {
onChange(isValid: boolean): void
password: string
}
const PasswordIndicator = ({ onChange, password }: IPasswordIndicator) => {
const intl = useIntl();
const meetsLengthRequirements = useMemo(() => password.length >= 8, [password]);
const meetsCapitalLetterRequirements = useMemo(() => hasUppercaseCharacter(password), [password]);
const meetsLowercaseLetterRequirements = useMemo(() => hasLowercaseCharacter(password), [password]);
const hasValidPassword = meetsLengthRequirements && meetsCapitalLetterRequirements && meetsLowercaseLetterRequirements;
useEffect(() => {
onChange(hasValidPassword);
}, [hasValidPassword]);
return (
<Stack className='mt-2' space={1}>
<ValidationCheckmark
isValid={meetsLengthRequirements}
text={intl.formatMessage(messages.minimumCharacters)}
/>
<ValidationCheckmark
isValid={meetsCapitalLetterRequirements}
text={intl.formatMessage(messages.capitalLetter)}
/>
<ValidationCheckmark
isValid={meetsLowercaseLetterRequirements}
text={intl.formatMessage(messages.lowercaseLetter)}
/>
</Stack>
);
};
export default PasswordIndicator;

@ -1,167 +0,0 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory, useParams } from 'react-router-dom';
import { confirmEmailVerification } from 'soapbox/actions/verification';
import { Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { ChallengeTypes } from './index';
import type { AxiosError } from 'axios';
const Statuses = {
IDLE: 'IDLE',
SUCCESS: 'SUCCESS',
GENERIC_FAIL: 'GENERIC_FAIL',
TOKEN_NOT_FOUND: 'TOKEN_NOT_FOUND',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
};
const messages = defineMessages({
emailConfirmedHeading: { id: 'email_passthru.confirmed.heading', defaultMessage: 'Email Confirmed!' },
emailConfirmedBody: { id: 'email_passthru.confirmed.body', defaultMessage: 'Close this tab and continue the registration process on the {bold} from which you sent this email confirmation.' },
genericFailHeading: { id: 'email_passthru.generic_fail.heading', defaultMessage: 'Something Went Wrong' },
genericFailBody: { id: 'email_passthru.generic_fail.body', defaultMessage: 'Please request a new email confirmation.' },
tokenNotFoundHeading: { id: 'email_passthru.token_not_found.heading', defaultMessage: 'Invalid Token' },
tokenNotFoundBody: { id: 'email_passthru.token_not_found.body', defaultMessage: 'Your email token was not found. Please request a new email confirmation from the {bold} from which you sent this email confirmation.' },
tokenExpiredHeading: { id: 'email_passthru.token_expired.heading', defaultMessage: 'Token Expired' },
tokenExpiredBody: { id: 'email_passthru.token_expired.body', defaultMessage: 'Your email token has expired. Please request a new email confirmation from the {bold} from which you sent this email confirmation.' },
emailConfirmed: { id: 'email_passthru.success', defaultMessage: 'Your email has been verified!' },
genericFail: { id: 'email_passthru.fail.generic', defaultMessage: 'Unable to confirm your email' },
tokenExpired: { id: 'email_passthru.fail.expired', defaultMessage: 'Your email token has expired' },
tokenNotFound: { id: 'email_passthru.fail.not_found', defaultMessage: 'Your email token is invalid.' },
invalidToken: { id: 'email_passthru.fail.invalid_token', defaultMessage: 'Your token is invalid' },
});
const Success = () => {
const intl = useIntl();
const history = useHistory();
const currentChallenge = useAppSelector((state) => state.verification.currentChallenge as ChallengeTypes);
React.useEffect(() => {
// Bypass the user straight to the next step.
if (currentChallenge === ChallengeTypes.SMS) {
history.push('/verify');
}
}, [currentChallenge]);
return (
<Stack space={4} alignItems='center'>
<Icon src={require('@tabler/icons/circle-check.svg')} className='h-10 w-10 text-primary-600 dark:text-primary-400' />
<Text size='3xl' weight='semibold' align='center'>
{intl.formatMessage(messages.emailConfirmedHeading)}
</Text>
<Text theme='muted' align='center'>
{intl.formatMessage(messages.emailConfirmedBody, { bold: <Text tag='span' weight='medium'>same device</Text> })}
</Text>
</Stack>
);
};
const GenericFail = () => {
const intl = useIntl();
return (
<Stack space={4} alignItems='center'>
<Icon src={require('@tabler/icons/circle-x.svg')} className='h-10 w-10 text-danger-600' />
<Text size='3xl' weight='semibold' align='center'>
{intl.formatMessage(messages.genericFailHeading)}
</Text>
<Text theme='muted' align='center'>
{intl.formatMessage(messages.genericFailBody)}
</Text>
</Stack>
);
};
const TokenNotFound = () => {
const intl = useIntl();
return (
<Stack space={4} alignItems='center'>
<Icon src={require('@tabler/icons/circle-x.svg')} className='h-10 w-10 text-danger-600' />
<Text size='3xl' weight='semibold' align='center'>
{intl.formatMessage(messages.tokenNotFoundHeading)}
</Text>
<Text theme='muted' align='center'>
{intl.formatMessage(messages.tokenNotFoundBody, { bold: <Text tag='span' weight='medium'>same device</Text> })}
</Text>
</Stack>
);
};
const TokenExpired = () => {
const intl = useIntl();
return (
<Stack space={4} alignItems='center'>
<Icon src={require('@tabler/icons/circle-x.svg')} className='h-10 w-10 text-danger-600' />
<Text size='3xl' weight='semibold' align='center'>
{intl.formatMessage(messages.tokenExpiredHeading)}
</Text>
<Text theme='muted' align='center'>
{intl.formatMessage(messages.tokenExpiredBody, { bold: <Text tag='span' weight='medium'>same device</Text> })}
</Text>
</Stack>
);
};
const EmailPassThru = () => {
const { token } = useParams<{ token: string }>();
const dispatch = useAppDispatch();
const intl = useIntl();
const [status, setStatus] = React.useState(Statuses.IDLE);
React.useEffect(() => {
if (token) {
dispatch(confirmEmailVerification(token))
.then(() => {
setStatus(Statuses.SUCCESS);
toast.success(intl.formatMessage(messages.emailConfirmed));
})
.catch((error: AxiosError<any>) => {
const errorKey = error?.response?.data?.error;
let message = intl.formatMessage(messages.genericFail);
if (errorKey) {
switch (errorKey) {
case 'token_expired':
message = intl.formatMessage(messages.tokenExpired);
setStatus(Statuses.TOKEN_EXPIRED);
break;
case 'token_not_found':
message = intl.formatMessage(messages.tokenNotFound);
message = intl.formatMessage(messages.invalidToken);
setStatus(Statuses.TOKEN_NOT_FOUND);
break;
default:
setStatus(Statuses.GENERIC_FAIL);
break;
}
}
toast.error(message);
});
}
}, [token]);
switch (status) {
case Statuses.SUCCESS:
return <Success />;
case Statuses.TOKEN_EXPIRED:
return <TokenExpired />;
case Statuses.TOKEN_NOT_FOUND:
return <TokenNotFound />;
case Statuses.GENERIC_FAIL:
return <GenericFail />;
default:
return <Spinner />;
}
};
export default EmailPassThru;

@ -1,56 +0,0 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import { fetchVerificationConfig } from 'soapbox/actions/verification';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import Registration from './registration';
import AgeVerification from './steps/age-verification';
import EmailVerification from './steps/email-verification';
import SmsVerification from './steps/sms-verification';
export enum ChallengeTypes {
EMAIL = 'email',
SMS = 'sms',
AGE = 'age',
}
const verificationSteps = {
email: EmailVerification,
sms: SmsVerification,
age: AgeVerification,
};
const Verification = () => {
const dispatch = useAppDispatch();
const isInstanceReady = useAppSelector((state) => state.verification.instance.get('isReady') === true);
const isRegistrationOpen = useAppSelector(state => state.verification.instance.get('registrations') === true);
const currentChallenge = useAppSelector((state) => state.verification.currentChallenge as ChallengeTypes);
const isVerificationComplete = useAppSelector((state) => state.verification.isComplete);
const StepToRender = verificationSteps[currentChallenge];
React.useEffect(() => {
dispatch(fetchVerificationConfig());
}, []);
if (isInstanceReady && !isRegistrationOpen) {
return <Redirect to='/' />;
}
if (isVerificationComplete) {
return (
<Registration />
);
}
if (!currentChallenge) {
return null;
}
return (
<StepToRender />
);
};
export default Verification;

@ -1,161 +0,0 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Redirect } from 'react-router-dom';
import { logIn, verifyCredentials } from 'soapbox/actions/auth';
import { fetchInstance } from 'soapbox/actions/instance';
import { startOnboarding } from 'soapbox/actions/onboarding';
import { createAccount, removeStoredVerification } from 'soapbox/actions/verification';
import { Button, Form, FormGroup, Input, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useInstance, useSoapboxConfig } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { getRedirectUrl } from 'soapbox/utils/redirect';
import PasswordIndicator from './components/password-indicator';
import type { AxiosError } from 'axios';
const messages = defineMessages({
success: { id: 'registrations.success', defaultMessage: 'Welcome to {siteTitle}!' },
usernameLabel: { id: 'registrations.username.label', defaultMessage: 'Your username' },
usernameHint: { id: 'registrations.username.hint', defaultMessage: 'May only contain A-Z, 0-9, and underscores' },
usernameTaken: { id: 'registrations.unprocessable_entity', defaultMessage: 'This username has already been taken.' },
passwordLabel: { id: 'registrations.password.label', defaultMessage: 'Password' },
error: { id: 'registrations.error', defaultMessage: 'Failed to register your account.' },
});
const initialState = {
username: '',
password: '',
};
const Registration = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const instance = useInstance();
const soapboxConfig = useSoapboxConfig();
const { links } = soapboxConfig;
const isLoading = useAppSelector((state) => state.verification.isLoading as boolean);
const [state, setState] = React.useState(initialState);
const [shouldRedirect, setShouldRedirect] = React.useState<boolean>(false);
const [hasValidPassword, setHasValidPassword] = React.useState<boolean>(false);
const { username, password } = state;
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
event.preventDefault();
dispatch(createAccount(username, password))
.then(() => dispatch(logIn(username, password)))
.then(({ access_token }: any) => dispatch(verifyCredentials(access_token)))
.then(() => dispatch(fetchInstance()))
.then(() => {
setShouldRedirect(true);
removeStoredVerification();
dispatch(startOnboarding());
toast.success(
intl.formatMessage(messages.success, { siteTitle: instance.title }),
);
})
.catch((errorResponse: AxiosError<{ error: string, message: string }>) => {
const error = errorResponse.response?.data?.error;
if (error) {
toast.error(errorResponse.response?.data?.message || intl.formatMessage(messages.usernameTaken));
} else {
toast.error(intl.formatMessage(messages.error));
}
});
}, [username, password]);
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((event) => {
event.persist();
setState((prevState) => ({ ...prevState, [event.target.name]: event.target.value }));
}, []);
if (shouldRedirect) {
const redirectUri = getRedirectUrl();
return <Redirect to={redirectUri} />;
}
return (
<div>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
<h1 className='text-center text-2xl font-bold'>
<FormattedMessage id='registration.header' defaultMessage='Register your account' />
</h1>
</div>
<div className='mx-auto space-y-4 sm:w-2/3 sm:pt-10 md:w-1/2'>
<Form onSubmit={handleSubmit}>
<FormGroup labelText={intl.formatMessage(messages.usernameLabel)} hintText={intl.formatMessage(messages.usernameHint)}>
<Input
name='username'
type='text'
value={username}
onChange={handleInputChange}
required
icon={require('@tabler/icons/at.svg')}
placeholder='LibertyForAll'
/>
</FormGroup>
<FormGroup labelText={intl.formatMessage(messages.passwordLabel)}>
<Input
name='password'
type='password'
value={password}
onChange={handleInputChange}
required
data-testid='password-input'
/>
<PasswordIndicator password={password} onChange={setHasValidPassword} />
</FormGroup>
<div className='space-y-2 text-center'>
<Button
block
theme='primary'
type='submit'
disabled={isLoading || !hasValidPassword}
>
<FormattedMessage id='header.register.label' defaultMessage='Register' />
</Button>
{(links.get('termsOfService') && links.get('privacyPolicy')) ? (
<Text theme='muted' size='xs'>
<FormattedMessage
id='registration.acceptance'
defaultMessage='By registering, you agree to the {terms} and {privacy}.'
values={{
terms: (
<a href={links.get('termsOfService')} target='_blank' className='text-primary-600 hover:underline dark:text-primary-400'>
<FormattedMessage
id='registration.tos'
defaultMessage='Terms of Service'
/>
</a>
),
privacy: (
<a href={links.get('privacyPolicy')} target='_blank' className='text-primary-600 hover:underline dark:text-primary-400'>
<FormattedMessage
id='registration.privacy'
defaultMessage='Privacy Policy'
/>
</a>
),
}}
/>
</Text>
) : null}
</div>
</Form>
</div>
</div>
);
};
export default Registration;

@ -1,53 +0,0 @@
import userEvent from '@testing-library/user-event';
import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { __stub } from 'soapbox/api';
import { fireEvent, render, screen } from 'soapbox/jest/test-helpers';
import AgeVerification from '../age-verification';
describe('<AgeVerification />', () => {
let store: any;
beforeEach(() => {
store = {
verification: ImmutableMap({
ageMinimum: 13,
}),
};
__stub(mock => {
mock.onPost('/api/v1/pepe/verify_age/confirm')
.reply(200, {});
});
});
it('successfully renders the Birthday step', async() => {
render(
<AgeVerification />,
{},
store,
);
expect(screen.getByRole('heading')).toHaveTextContent('Enter your birth date');
});
it('selects a date', async() => {
render(
<AgeVerification />,
{},
store,
);
await userEvent.selectOptions(
screen.getByTestId('datepicker-year'),
screen.getByRole('option', { name: '2020' }),
);
fireEvent.submit(
screen.getByRole('button'), {
preventDefault: () => {},
},
);
});
});

@ -1,68 +0,0 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { __stub } from 'soapbox/api';
import { fireEvent, render, screen, waitFor } from 'soapbox/jest/test-helpers';
import EmailVerification from '../email-verification';
describe('<EmailVerification />', () => {
it('successfully renders the Email step', async() => {
render(<EmailVerification />);
expect(screen.getByRole('heading')).toHaveTextContent('Enter your email address');
});
describe('with valid data', () => {
beforeEach(() => {
__stub(mock => {
mock.onPost('/api/v1/pepe/verify_email/request')
.reply(200, {});
});
});
it('successfully submits', async() => {
render(<EmailVerification />);
await userEvent.type(screen.getByLabelText('E-mail address'), 'foo@bar.com{enter}');
await waitFor(() => {
fireEvent.submit(
screen.getByTestId('button'), {
preventDefault: () => {},
},
);
});
expect(screen.getByTestId('button')).toHaveTextContent('Resend verification email');
});
});
describe('with invalid data', () => {
beforeEach(() => {
__stub(mock => {
mock.onPost('/api/v1/pepe/verify_email/request')
.reply(422, {
error: 'email_taken',
});
});
});
it('renders errors', async() => {
render(<EmailVerification />);
await userEvent.type(screen.getByLabelText('E-mail address'), 'foo@bar.com{enter}');
await waitFor(() => {
fireEvent.submit(
screen.getByTestId('button'), {
preventDefault: () => {},
},
);
});
await waitFor(() => {
expect(screen.getByTestId('form-group-error')).toHaveTextContent('is taken');
});
});
});
});

@ -1,120 +0,0 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { toast } from 'react-hot-toast';
import { __stub } from 'soapbox/api';
import { fireEvent, render, screen, waitFor } from 'soapbox/jest/test-helpers';
import SmsVerification from '../sms-verification';
describe('<SmsVerification />', () => {
it('successfully renders the SMS step', async() => {
render(<SmsVerification />);
expect(screen.getByRole('heading')).toHaveTextContent('Enter your phone number');
});
describe('with valid data', () => {
beforeEach(() => {
__stub(mock => {
mock.onPost('/api/v1/pepe/verify_sms/request').reply(200, {});
});
});
it('successfully submits', async() => {
__stub(mock => {
mock.onPost('/api/v1/pepe/verify_sms/confirm').reply(200, {});
});
render(<SmsVerification />);
await userEvent.type(screen.getByLabelText('Phone number'), '+1 (555) 555-5555');
await waitFor(() => {
fireEvent.submit(
screen.getByRole('button', { name: 'Next' }), {
preventDefault: () => {},
},
);
});
await waitFor(() => {
expect(screen.getByRole('heading')).toHaveTextContent('Verification code');
expect(screen.getByTestId('toast')).toHaveTextContent('A verification code has been sent to your phone number.');
});
act(() => {
toast.remove();
});
await userEvent.type(screen.getByLabelText('Please enter verification code. Digit 1'), '1');
await userEvent.type(screen.getByLabelText('Digit 2'), '2');
await userEvent.type(screen.getByLabelText('Digit 3'), '3');
await userEvent.type(screen.getByLabelText('Digit 4'), '4');
await userEvent.type(screen.getByLabelText('Digit 5'), '5');
await userEvent.type(screen.getByLabelText('Digit 6'), '6');
});
it('handle expired tokens', async() => {
__stub(mock => {
mock.onPost('/api/v1/pepe/verify_sms/confirm').reply(422, {});
});
render(<SmsVerification />);
await userEvent.type(screen.getByLabelText('Phone number'), '+1 (555) 555-5555');
await waitFor(() => {
fireEvent.submit(
screen.getByRole('button', { name: 'Next' }), {
preventDefault: () => {},
},
);
});
await waitFor(() => {
expect(screen.getByRole('heading')).toHaveTextContent('Verification code');
expect(screen.getByTestId('toast')).toHaveTextContent('A verification code has been sent to your phone number.');
});
act(() => {
toast.remove();
});
await userEvent.type(screen.getByLabelText('Please enter verification code. Digit 1'), '1');
await userEvent.type(screen.getByLabelText('Digit 2'), '2');
await userEvent.type(screen.getByLabelText('Digit 3'), '3');
await userEvent.type(screen.getByLabelText('Digit 4'), '4');
await userEvent.type(screen.getByLabelText('Digit 5'), '5');
await userEvent.type(screen.getByLabelText('Digit 6'), '6');
await waitFor(() => {
expect(screen.getByTestId('toast')).toHaveTextContent('Your SMS token has expired.');
});
});
});
describe('with invalid data', () => {
beforeEach(() => {
__stub(mock => {
mock.onPost('/api/v1/pepe/verify_sms/request')
.reply(422, {});
});
});
it('renders errors', async() => {
render(<SmsVerification />);
await userEvent.type(screen.getByLabelText('Phone number'), '+1 (555) 555-5555');
await waitFor(() => {
fireEvent.submit(
screen.getByRole('button', { name: 'Next' }), {
preventDefault: () => {},
},
);
});
await waitFor(() => {
expect(screen.getByTestId('toast')).toHaveTextContent('Failed to send SMS message to your phone number.');
});
});
});
});

@ -1,84 +0,0 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { verifyAge } from 'soapbox/actions/verification';
import { Button, Datepicker, Form, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
import toast from 'soapbox/toast';
const messages = defineMessages({
fail: {
id: 'age_verification.fail',
defaultMessage: 'You must be {ageMinimum, plural, one {# year} other {# years}} old or older.',
},
});
function meetsAgeMinimum(birthday: Date, ageMinimum: number) {
const month = birthday.getUTCMonth();
const day = birthday.getUTCDate();
const year = birthday.getUTCFullYear();
return new Date(year + ageMinimum, month, day) <= new Date();
}
const AgeVerification = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const instance = useInstance();
const isLoading = useAppSelector((state) => state.verification.isLoading) as boolean;
const ageMinimum = useAppSelector((state) => state.verification.ageMinimum) as any;
const [date, setDate] = React.useState<Date>();
const isValid = typeof date === 'object';
const onChange = React.useCallback((date: Date) => setDate(date), []);
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
event.preventDefault();
const birthday = new Date(date!);
if (meetsAgeMinimum(birthday, ageMinimum)) {
dispatch(verifyAge(birthday));
} else {
toast.error(intl.formatMessage(messages.fail, { ageMinimum }));
}
}, [date, ageMinimum]);
return (
<div>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
<h1 className='text-center text-2xl font-bold'>
<FormattedMessage id='age_verification.header' defaultMessage='Enter your birth date' />
</h1>
</div>
<div className='mx-auto sm:pt-10 md:w-2/3'>
<Form onSubmit={handleSubmit}>
<Datepicker onChange={onChange} />
<Text theme='muted' size='sm'>
<FormattedMessage
id='age_verification.body'
defaultMessage='{siteTitle} requires users to be at least {ageMinimum, plural, one {# year} other {# years}} old to access its platform. Anyone under the age of {ageMinimum, plural, one {# year} other {# years}} old cannot access this platform.'
values={{
siteTitle: instance.title,
ageMinimum,
}}
/>
</Text>
<div className='text-center'>
<Button block theme='primary' type='submit' disabled={isLoading || !isValid}>
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
</Button>
</div>
</Form>
</div>
</div>
);
};
export default AgeVerification;

@ -1,146 +0,0 @@
import { AxiosError } from 'axios';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { checkEmailVerification, postEmailVerification, requestEmailVerification } from 'soapbox/actions/verification';
import Icon from 'soapbox/components/icon';
import { Button, Form, FormGroup, Input, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import toast from 'soapbox/toast';
const messages = defineMessages({
verificationSuccess: { id: 'email_verification.success', defaultMessage: 'Verification email sent successfully.' },
verificationFail: { id: 'email_verification.fail', defaultMessage: 'Failed to request email verification.' },
verificationFailTakenAlert: { id: 'email_verifilcation.exists', defaultMessage: 'This email has already been taken.' },
verificationFailTaken: { id: 'email_verification.taken', defaultMessage: 'is taken' },
emailLabel: { id: 'email_verification.email.label', defaultMessage: 'E-mail address' },
});
const Statuses = {
IDLE: 'IDLE',
REQUESTED: 'REQUESTED',
FAIL: 'FAIL',
};
const EMAIL_REGEX = /^[^@\s]+@[^@\s]+$/;
interface IEmailSent {
handleSubmit: React.FormEventHandler
}
const EmailSent: React.FC<IEmailSent> = ({ handleSubmit }) => {
const dispatch = useAppDispatch();
const checkEmailConfirmation = () => {
dispatch(checkEmailVerification())
.then(() => dispatch(postEmailVerification()))
.catch(() => null);
};
React.useEffect(() => {
const intervalId = setInterval(() => checkEmailConfirmation(), 2500);
return () => clearInterval(intervalId);
}, []);
return (
<div className='mx-auto flex flex-col items-center justify-center sm:pt-10'>
<Icon src={require('@tabler/icons/send.svg')} className='mb-5 h-12 w-12 text-primary-600 dark:text-primary-400' />
<div className='mb-4 space-y-2 text-center'>
<Text weight='bold' size='3xl'>We sent you an email</Text>
<Text theme='muted'>Click on the link in the email to validate your email.</Text>
</div>
<Button theme='tertiary' onClick={handleSubmit}>Resend verification email</Button>
</div>
);
};
const EmailVerification = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const isLoading = useAppSelector((state) => state.verification.isLoading) as boolean;
const [email, setEmail] = React.useState('');
const [status, setStatus] = React.useState(Statuses.IDLE);
const [errors, setErrors] = React.useState<Array<string>>([]);
const isValid = email.length > 0 && EMAIL_REGEX.test(email);
const onChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((event) => {
setEmail(event.target.value);
}, []);
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
event.preventDefault();
setErrors([]);
submitEmailForVerification();
}, [email]);
const submitEmailForVerification = () => {
return dispatch(requestEmailVerification((email)))
.then(() => {
setStatus(Statuses.REQUESTED);
toast.success(intl.formatMessage(messages.verificationSuccess));
})
.catch((error: AxiosError) => {
const errorMessage = (error.response?.data as any)?.error;
const isEmailTaken = errorMessage === 'email_taken';
let message = intl.formatMessage(messages.verificationFail);
if (isEmailTaken) {
message = intl.formatMessage(messages.verificationFailTakenAlert);
} else if (errorMessage) {
message = errorMessage;
}
if (isEmailTaken) {
setErrors([intl.formatMessage(messages.verificationFailTaken)]);
}
toast.error(message);
setStatus(Statuses.FAIL);
});
};
if (status === Statuses.REQUESTED) {
return <EmailSent handleSubmit={handleSubmit} />;
}
return (
<div>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
<h1 className='text-center text-2xl font-bold'>
<FormattedMessage id='email_verification.header' defaultMessage='Enter your email address' />
</h1>
</div>
<div className='mx-auto sm:w-2/3 sm:pt-10 md:w-1/2'>
<Form onSubmit={handleSubmit}>
<FormGroup labelText={intl.formatMessage(messages.emailLabel)} errors={errors}>
<Input
type='email'
value={email}
name='email'
onChange={onChange}
placeholder='you@email.com'
required
/>
</FormGroup>
<div className='text-center'>
<Button block theme='primary' type='submit' disabled={isLoading || !isValid}>
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
</Button>
</div>
</Form>
</div>
</div>
);
};
export default EmailVerification;

@ -1,151 +0,0 @@
import { AxiosError } from 'axios';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import OtpInput from 'react-otp-input';
import { confirmPhoneVerification, requestPhoneVerification } from 'soapbox/actions/verification';
import { Button, Form, FormGroup, PhoneInput, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import toast from 'soapbox/toast';
const messages = defineMessages({
verificationInvalid: { id: 'sms_verification.invalid', defaultMessage: 'Please enter a valid phone number.' },
verificationSuccess: { id: 'sms_verification.success', defaultMessage: 'A verification code has been sent to your phone number.' },
verificationFail: { id: 'sms_verification.fail', defaultMessage: 'Failed to send SMS message to your phone number.' },
verificationExpired: { id: 'sms_verification.expired', defaultMessage: 'Your SMS token has expired.' },
phoneLabel: { id: 'sms_verification.phone.label', defaultMessage: 'Phone number' },
});
const Statuses = {
IDLE: 'IDLE',
REQUESTED: 'REQUESTED',
FAIL: 'FAIL',
};
const SmsVerification = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const isLoading = useAppSelector((state) => state.verification.isLoading) as boolean;
const [phone, setPhone] = React.useState<string>();
const [status, setStatus] = React.useState(Statuses.IDLE);
const [verificationCode, setVerificationCode] = React.useState('');
const [requestedAnother, setAlreadyRequestedAnother] = React.useState(false);
const isValid = !!phone;
const onChange = React.useCallback((phone?: string) => {
setPhone(phone);
}, []);
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
event.preventDefault();
if (!isValid) {
setStatus(Statuses.IDLE);
toast.error(intl.formatMessage(messages.verificationInvalid));
return;
}
dispatch(requestPhoneVerification(phone!)).then(() => {
toast.success(intl.formatMessage(messages.verificationSuccess));
setStatus(Statuses.REQUESTED);
}).catch((error: AxiosError) => {
const message = (error.response?.data as any)?.message || intl.formatMessage(messages.verificationFail);
toast.error(message);
setStatus(Statuses.FAIL);
});
}, [phone, isValid]);
const resendVerificationCode: React.MouseEventHandler = React.useCallback((event) => {
setAlreadyRequestedAnother(true);
handleSubmit(event);
}, [isValid]);
const submitVerification = () => {
// TODO: handle proper validation from Pepe -- expired vs invalid
dispatch(confirmPhoneVerification(verificationCode))
.catch(() => {
toast.error(intl.formatMessage(messages.verificationExpired));
});
};
React.useEffect(() => {
if (verificationCode.length === 6) {
submitVerification();
}
}, [verificationCode]);
if (status === Statuses.REQUESTED) {
return (
<div>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
<h1 className='text-center text-2xl font-bold'>
<FormattedMessage id='sms_verification.sent.header' defaultMessage='Verification code' />
</h1>
</div>
<div className='mx-auto space-y-4 sm:w-2/3 sm:pt-10 md:w-1/2'>
<Text theme='muted' size='sm' align='center'>
<FormattedMessage id='sms_verification.sent.body' defaultMessage='We sent you a 6-digit code via SMS. Enter it below.' />
</Text>
<OtpInput
value={verificationCode}
onChange={setVerificationCode}
numInputs={6}
isInputNum
shouldAutoFocus
isDisabled={isLoading}
containerStyle='flex justify-center mt-2 space-x-4'
inputStyle='w-10i border-gray-300 dark:bg-gray-800 dark:border-gray-800 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500'
/>
<div className='text-center'>
<Button
size='sm'
type='button'
theme='tertiary'
onClick={resendVerificationCode}
disabled={requestedAnother}
>
<FormattedMessage id='sms_verification.sent.actions.resend' defaultMessage='Resend verification code?' />
</Button>
</div>
</div>
</div>
);
}
return (
<div>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
<h1 className='text-center text-2xl font-bold'>
<FormattedMessage id='sms_verification.header' defaultMessage='Enter your phone number' />
</h1>
</div>
<div className='mx-auto sm:w-2/3 sm:pt-10 md:w-1/2'>
<Form onSubmit={handleSubmit}>
<FormGroup labelText={intl.formatMessage(messages.phoneLabel)}>
<PhoneInput
value={phone}
onChange={onChange}
required
/>
</FormGroup>
<div className='text-center'>
<Button block theme='primary' type='submit' disabled={isLoading || !isValid}>
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
</Button>
</div>
</Form>
</div>
</div>
);
};
export { SmsVerification as default };

@ -1,79 +0,0 @@
import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { logOut } from 'soapbox/actions/auth';
import { openModal } from 'soapbox/actions/modals';
import LandingGradient from 'soapbox/components/landing-gradient';
import SiteLogo from 'soapbox/components/site-logo';
import { Button, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useInstance, useOwnAccount } from 'soapbox/hooks';
const WaitlistPage = () => {
const dispatch = useAppDispatch();
const instance = useInstance();
const { account: me } = useOwnAccount();
const isSmsVerified = me?.source?.sms_verified ?? true;
const onClickLogOut: React.MouseEventHandler = (event) => {
event.preventDefault();
dispatch(logOut());
};
const openVerifySmsModal = () => dispatch(openModal('VERIFY_SMS'));
useEffect(() => {
if (!isSmsVerified) {
openVerifySmsModal();
}
}, []);
return (
<div>
<LandingGradient />
<main className='relative mx-auto flex h-screen max-w-7xl flex-col px-2 sm:px-6 lg:px-8'>
<header className='relative flex h-16 justify-between'>
<div className='relative flex flex-1 items-stretch justify-center'>
<Link to='/' className='flex shrink-0 cursor-pointer items-center'>
<SiteLogo alt='Logo' className='h-7' />
</Link>
<div className='absolute inset-y-0 right-0 flex items-center pr-2'>
<Button onClick={onClickLogOut} theme='primary' to='/logout'>
<FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' />
</Button>
</div>
</div>
</header>
<div className='-mt-16 flex h-full flex-col items-center justify-center'>
<div className='max-w-xl'>
<Stack space={4}>
<img src='/instance/images/waitlist.png' className='mx-auto h-32 w-32' alt='Waitlisted' />
<Stack space={2}>
<Text size='lg' theme='muted' align='center' weight='medium'>
<FormattedMessage
id='waitlist.body'
defaultMessage='Welcome back to {title}! You were previously placed on our waitlist. Please verify your phone number to receive immediate access to your account!'
values={{ title: instance.title }}
/>
</Text>
<div className='text-center'>
<Button onClick={openVerifySmsModal} theme='primary'>
<FormattedMessage id='waitlist.actions.verify_number' defaultMessage='Verify phone number' />
</Button>
</div>
</Stack>
</Stack>
</div>
</div>
</main>
</div>
);
};
export default WaitlistPage;

@ -1,46 +0,0 @@
import { storeClosed, storeOpen, storePepeClosed, storePepeOpen } from 'soapbox/jest/mock-stores';
import { renderHook } from 'soapbox/jest/test-helpers';
import { useRegistrationStatus } from '../useRegistrationStatus';
describe('useRegistrationStatus()', () => {
test('Registrations open', () => {
const { result } = renderHook(useRegistrationStatus, undefined, storeOpen);
expect(result.current).toMatchObject({
isOpen: true,
pepeEnabled: false,
pepeOpen: false,
});
});
test('Registrations closed', () => {
const { result } = renderHook(useRegistrationStatus, undefined, storeClosed);
expect(result.current).toMatchObject({
isOpen: false,
pepeEnabled: false,
pepeOpen: false,
});
});
test('Registrations closed, Pepe enabled & open', () => {
const { result } = renderHook(useRegistrationStatus, undefined, storePepeOpen);
expect(result.current).toMatchObject({
isOpen: true,
pepeEnabled: true,
pepeOpen: true,
});
});
test('Registrations closed, Pepe enabled & closed', () => {
const { result } = renderHook(useRegistrationStatus, undefined, storePepeClosed);
expect(result.current).toMatchObject({
isOpen: false,
pepeEnabled: true,
pepeOpen: false,
});
});
});

@ -1,22 +0,0 @@
import { useAppSelector } from './useAppSelector';
import { useFeatures } from './useFeatures';
import { useInstance } from './useInstance';
import { useSoapboxConfig } from './useSoapboxConfig';
export const useRegistrationStatus = () => {
const instance = useInstance();
const features = useFeatures();
const soapboxConfig = useSoapboxConfig();
const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true);
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
return {
/** Registrations are open, either through Pepe or traditional account creation. */
isOpen: (features.accountCreation && instance.registrations) || (pepeEnabled && pepeOpen),
/** Whether Pepe is open. */
pepeOpen,
/** Whether Pepe is enabled. */
pepeEnabled,
};
};

@ -1,42 +0,0 @@
import { fromJS } from 'immutable';
import alexJson from 'soapbox/__fixtures__/pleroma-account.json';
import { normalizeInstance } from 'soapbox/normalizers';
import { buildAccount } from './factory';
/** Store with registrations open. */
const storeOpen = { instance: normalizeInstance({ registrations: true }) };
/** Store with registrations closed. */
const storeClosed = { instance: normalizeInstance({ registrations: false }) };
/** Store with registrations closed, and Pepe enabled & open. */
const storePepeOpen = {
instance: normalizeInstance({ registrations: false }),
soapbox: fromJS({ extensions: { pepe: { enabled: true } } }),
verification: { instance: fromJS({ registrations: true }) },
};
/** Store with registrations closed, and Pepe enabled & closed. */
const storePepeClosed = {
instance: normalizeInstance({ registrations: false }),
soapbox: fromJS({ extensions: { pepe: { enabled: true } } }),
verification: { instance: fromJS({ registrations: false }) },
};
/** Store with a logged-in user. */
const storeLoggedIn = {
me: alexJson.id,
accounts: {
[alexJson.id]: buildAccount(alexJson as any),
},
};
export {
storeOpen,
storeClosed,
storePepeOpen,
storePepeClosed,
storeLoggedIn,
};

@ -1,42 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Ad, getProvider } from 'soapbox/features/ads/providers';
import { useAppDispatch } from 'soapbox/hooks';
import { adSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import { isExpired } from 'soapbox/utils/ads';
const AdKeys = {
ads: ['ads'] as const,
};
function useAds() {
const dispatch = useAppDispatch();
const getAds = async () => {
return dispatch(async (_, getState) => {
const provider = await getProvider(getState);
if (provider) {
return provider.getAds(getState);
} else {
return [];
}
});
};
const result = useQuery<Ad[]>(AdKeys.ads, getAds, {
placeholderData: [],
});
// Filter out expired ads.
const data = filteredArray(adSchema)
.parse(result.data)
.filter(ad => !isExpired(ad));
return {
...result,
data,
};
}
export { useAds as default, AdKeys };

@ -1,177 +0,0 @@
import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
import {
Challenge,
FETCH_CHALLENGES_SUCCESS,
FETCH_TOKEN_SUCCESS,
SET_CHALLENGES_COMPLETE,
SET_LOADING,
SET_NEXT_CHALLENGE,
} from 'soapbox/actions/verification';
import reducer from '../verification';
describe('verfication reducer', () => {
it('returns the initial state', () => {
expect(reducer(undefined, {} as any)).toMatchObject({
ageMinimum: null,
currentChallenge: null,
isLoading: false,
isComplete: false,
token: null,
instance: ImmutableMap(),
});
});
describe('FETCH_CHALLENGES_SUCCESS', () => {
it('sets the state', () => {
const state = ImmutableRecord({
ageMinimum: null,
currentChallenge: null,
isLoading: true,
isComplete: null,
token: null,
instance: ImmutableMap<string, any>(),
})();
const action = {
type: FETCH_CHALLENGES_SUCCESS,
ageMinimum: 13,
currentChallenge: 'email',
isComplete: false,
};
const expected = {
ageMinimum: 13,
currentChallenge: 'email',
isLoading: false,
isComplete: false,
token: null,
instance: ImmutableMap(),
};
expect(reducer(state, action)).toMatchObject(expected);
});
});
describe('FETCH_TOKEN_SUCCESS', () => {
it('sets the state', () => {
const state = ImmutableRecord({
ageMinimum: null,
currentChallenge: 'email' as Challenge,
isLoading: true,
isComplete: false,
token: null,
instance: ImmutableMap<string, any>(),
})();
const action = { type: FETCH_TOKEN_SUCCESS, value: '123' };
const expected = {
ageMinimum: null,
currentChallenge: 'email',
isLoading: false,
isComplete: false,
token: '123',
instance: ImmutableMap(),
};
expect(reducer(state, action)).toMatchObject(expected);
});
});
describe('SET_CHALLENGES_COMPLETE', () => {
it('sets the state', () => {
const state = ImmutableRecord({
ageMinimum: null,
currentChallenge: null,
isLoading: true,
isComplete: false,
token: null,
instance: ImmutableMap<string, any>(),
})();
const action = { type: SET_CHALLENGES_COMPLETE };
const expected = {
ageMinimum: null,
currentChallenge: null,
isLoading: false,
isComplete: true,
token: null,
instance: ImmutableMap(),
};
expect(reducer(state, action)).toMatchObject(expected);
});
});
describe('SET_NEXT_CHALLENGE', () => {
it('sets the state', () => {
const state = ImmutableRecord({
ageMinimum: null,
currentChallenge: null,
isLoading: true,
isComplete: false,
token: null,
instance: ImmutableMap<string, any>(),
})();
const action = {
type: SET_NEXT_CHALLENGE,
challenge: 'sms',
};
const expected = {
ageMinimum: null,
currentChallenge: 'sms',
isLoading: false,
isComplete: false,
token: null,
instance: ImmutableMap(),
};
expect(reducer(state, action)).toMatchObject(expected);
});
});
describe('SET_LOADING with no value', () => {
it('sets the state', () => {
const state = ImmutableRecord({
ageMinimum: null,
currentChallenge: null,
isLoading: false,
isComplete: false,
token: null,
instance: ImmutableMap<string, any>(),
})();
const action = { type: SET_LOADING };
const expected = {
ageMinimum: null,
currentChallenge: null,
isLoading: true,
isComplete: false,
token: null,
instance: ImmutableMap(),
};
expect(reducer(state, action)).toMatchObject(expected);
});
});
describe('SET_LOADING with a value', () => {
it('sets the state', () => {
const state = ImmutableRecord({
ageMinimum: null,
currentChallenge: null,
isLoading: true,
isComplete: false,
token: null,
instance: ImmutableMap<string, any>(),
})();
const action = { type: SET_LOADING, value: false };
const expected = {
ageMinimum: null,
currentChallenge: null,
isLoading: false,
isComplete: false,
token: null,
instance: ImmutableMap(),
};
expect(reducer(state, action)).toMatchObject(expected);
});
});
});

@ -1,51 +0,0 @@
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
import {
PEPE_FETCH_INSTANCE_SUCCESS,
FETCH_CHALLENGES_SUCCESS,
FETCH_TOKEN_SUCCESS,
SET_CHALLENGES_COMPLETE,
SET_LOADING,
SET_NEXT_CHALLENGE,
Challenge,
} from '../actions/verification';
import type { AnyAction } from 'redux';
const ReducerRecord = ImmutableRecord({
ageMinimum: null as string | null,
currentChallenge: null as Challenge | null,
isLoading: false,
isComplete: false as boolean | null,
token: null as string | null,
instance: ImmutableMap<string, any>(),
});
export default function verification(state = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case PEPE_FETCH_INSTANCE_SUCCESS:
return state.set('instance', ImmutableMap(fromJS(action.instance)));
case FETCH_CHALLENGES_SUCCESS:
return state
.set('ageMinimum', action.ageMinimum)
.set('currentChallenge', action.currentChallenge)
.set('isLoading', false)
.set('isComplete', action.isComplete);
case FETCH_TOKEN_SUCCESS:
return state
.set('isLoading', false)
.set('token', action.value);
case SET_CHALLENGES_COMPLETE:
return state
.set('isLoading', false)
.set('isComplete', true);
case SET_NEXT_CHALLENGE:
return state
.set('currentChallenge', action.challenge)
.set('isLoading', false);
case SET_LOADING:
return state.set('isLoading', typeof action.value === 'boolean' ? action.value : true);
default:
return state;
}
}

@ -1,14 +0,0 @@
import { z } from 'zod';
import { cardSchema } from '../card';
const adSchema = z.object({
card: cardSchema,
impression: z.string().optional().catch(undefined),
expires_at: z.string().datetime().optional().catch(undefined),
reason: z.string().optional().catch(undefined),
});
type Ad = z.infer<typeof adSchema>;
export { adSchema, type Ad };

@ -1,23 +0,0 @@
import { buildAd } from 'soapbox/jest/factory';
import { isExpired } from '../ads';
/** 3 minutes in milliseconds. */
const threeMins = 3 * 60 * 1000;
/** 5 minutes in milliseconds. */
const fiveMins = 5 * 60 * 1000;
test('isExpired()', () => {
const now = new Date();
const iso = now.toISOString();
const epoch = now.getTime();
// Sanity tests.
expect(isExpired(buildAd({ expires_at: iso }))).toBe(true);
expect(isExpired(buildAd({ expires_at: new Date(epoch + 999999).toISOString() }))).toBe(false);
// Testing the 5-minute mark.
expect(isExpired(buildAd({ expires_at: new Date(epoch + threeMins).toISOString() }), fiveMins)).toBe(true);
expect(isExpired(buildAd({ expires_at: new Date(epoch + fiveMins + 1000).toISOString() }), fiveMins)).toBe(false);
});

@ -1,16 +0,0 @@
import type { Ad } from 'soapbox/schemas';
/** Time (ms) window to not display an ad if it's about to expire. */
const AD_EXPIRY_THRESHOLD = 5 * 60 * 1000;
/** Whether the ad is expired or about to expire. */
const isExpired = (ad: Pick<Ad, 'expires_at'>, threshold = AD_EXPIRY_THRESHOLD): boolean => {
if (ad.expires_at) {
const now = new Date();
return now.getTime() > (new Date(ad.expires_at).getTime() - threshold);
} else {
return false;
}
};
export { isExpired };

@ -1,42 +0,0 @@
import { danger, warn, message } from 'danger';
// App changes
const app = danger.git.fileMatch('app/soapbox/**');
// Docs changes
const docs = danger.git.fileMatch('docs/**/*.md');
if (docs.edited) {
message('Thanks - We :heart: our [documentarians](http://www.writethedocs.org/)!');
}
// Enforce CHANGELOG.md additions
const changelog = danger.git.fileMatch('CHANGELOG.md');
if (app.edited && !changelog.edited) {
warn('You have not updated `CHANGELOG.md`. If this change directly impacts admins or users, please update the changelog. Otherwise you can ignore this message. See: https://keepachangelog.com');
}
// UI components
const uiCode = danger.git.fileMatch('app/soapbox/components/ui/**');
const uiTests = danger.git.fileMatch('app/soapbox/components/ui/**/__tests__/**');
if (uiCode.edited && !uiTests.edited) {
warn('You have UI changes (`soapbox/components/ui`) without tests.');
}
// Actions
const actionsCode = danger.git.fileMatch('app/soapbox/actions/**');
const actionsTests = danger.git.fileMatch('app/soapbox/actions/**__tests__/**');
if (actionsCode.edited && !actionsTests.edited) {
warn('You have actions changes (`soapbox/actions`) without tests.');
}
// Reducers
const reducersCode = danger.git.fileMatch('app/soapbox/reducers/**');
const reducersTests = danger.git.fileMatch('app/soapbox/reducers/**__tests__/**');
if (reducersCode.edited && !reducersTests.edited) {
warn('You have reducer changes (`soapbox/reducers`) without tests.');
}

@ -11,7 +11,7 @@ The best way to get Soapbox builds is from a GitLab CI job.
The official build URL is here:
```
https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/develop/download?job=build-production
https://dl.soapbox.pub/main/soapbox.zip
```
(Note that `develop` in that URL can be replaced with any git ref, eg `v2.0.0`, and thus will be updated with the latest zip whenever a new commit is pushed to `develop`.)
@ -44,7 +44,7 @@ location ~ ^/(api|oauth|admin) {
}
```
We recommend trying [`mastodon.conf`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/installation/mastodon.conf) as a starting point.
We recommend trying [`mastodon.conf`](https://gitlab.com/soapbox-pub/soapbox/-/blob/main/installation/mastodon.conf) as a starting point.
It is fine-tuned, includes support for federation, and should work with any backend.
## The ServiceWorker

@ -7,7 +7,7 @@ If you want to install Soapbox to a Pleroma instance installed using [YunoHost](
First, download the latest build of Soapbox from GitLab.
```sh
curl -L https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/develop/download?job=build-production -o soapbox-fe.zip
curl -O https://dl.soapbox.pub/main/soapbox.zip
```
## 2. Unzip the build
@ -15,7 +15,7 @@ curl -L https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/develop/download
Then, unzip the build to the Pleroma directory under YunoHost's directory:
```sh
busybox unzip soapbox-fe.zip -o -d /home/yunohost.app/pleroma/
busybox unzip soapbox.zip -o -d /home/yunohost.app/pleroma/static
```
## 3. Change YunoHost Admin Static directory

@ -8,16 +8,16 @@ To do so, shell into your server and unpack Soapbox:
```sh
mkdir -p /opt/soapbox
curl -L https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/develop/download?job=build-production -o soapbox-fe.zip
curl -O https://dl.soapbox.pub/main/soapbox.zip
busybox unzip soapbox-fe.zip -o -d /opt/soapbox
busybox unzip soapbox.zip -o -d /opt/soapbox
```
Now create an Nginx file for Soapbox with Mastodon.
If you already have one, replace it:
```sh
curl https://gitlab.com/soapbox-pub/soapbox/-/raw/develop/installation/mastodon.conf > /etc/nginx/sites-available/mastodon
curl https://gitlab.com/soapbox-pub/soapbox/-/raw/main/installation/mastodon.conf > /etc/nginx/sites-available/mastodon
```
Edit this file and replace all occurrences of `example.com` with your domain name.

@ -1,6 +1,6 @@
# Updating Soapbox
You should always check the [release notes/changelog](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/CHANGELOG.md) in case there are deprecations, special update changes, etc.
You should always check the [release notes/changelog](https://gitlab.com/soapbox-pub/soapbox/-/blob/main/CHANGELOG.md) in case there are deprecations, special update changes, etc.
Besides that, it's relatively pretty easy to update Soapbox. There's two ways to go about it: with the command line or with an unofficial script.
@ -10,15 +10,10 @@ To update Soapbox via the command line, do the following:
```
# Download the build.
curl -L https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/develop/download?job=build-production -o soapbox-fe.zip
# Remove all the current Soapbox build in Pleroma's instance directory.
rm -R /opt/pleroma/instance/static/packs
rm /opt/pleroma/instance/static/index.html
rm -R /opt/pleroma/instance/static/sounds
curl -O https://dl.soapbox.pub/main/soapbox.zip
# Unzip the new build to Pleroma's instance directory.
busybox unzip soapbox-fe.zip -o -d /opt/pleroma/instance
busybox unzip soapbox.zip -o -d /opt/pleroma/instance/static
```
## After updating Soapbox

@ -15,7 +15,7 @@ When contributing to Soapbox, please first discuss the change you wish to make b
When you push to a branch, the CI pipeline will run.
[Soapbox uses GitLab CI](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/.gitlab-ci.yml) to lint, run tests, and verify changes.
[Soapbox uses GitLab CI](https://gitlab.com/soapbox-pub/soapbox/-/blob/main/.gitlab-ci.yml) to lint, run tests, and verify changes.
It's important this pipeline passes, otherwise we cannot merge the change.
New users of gitlab.com may see a "detatched pipeline" error.
@ -31,4 +31,4 @@ We recommend developing Soapbox with [VSCodium](https://vscodium.com/) (or its p
This will help give you feedback about your changes _in the editor itself_ before GitLab CI performs linting, etc.
When this project is opened in Code it will automatically recommend extensions.
See [`.vscode/extensions.json`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/.vscode/extensions.json) for the full list.
See [`.vscode/extensions.json`](https://gitlab.com/soapbox-pub/soapbox/-/blob/main/.vscode/extensions.json) for the full list.

@ -71,7 +71,7 @@ For example:
}
```
See `app/soapbox/utils/features.js` for the full list of features.
See `src/utils/features.js` for the full list of features.
### Embedded app (`custom/app.json`)
@ -118,7 +118,7 @@ When compiling Soapbox, environment variables may be passed to change the build
For example:
```sh
NODE_ENV="production" FE_BUILD_DIR="public" FE_SUBDIRECTORY="/soapbox" yarn build
NODE_ENV="production" FE_SUBDIRECTORY="/soapbox" yarn build
```
### `NODE_ENV`
@ -147,16 +147,6 @@ Options:
Default: `""`
### `FE_BUILD_DIR`
The folder to put build files in. This is mostly useful for CI tasks like GitLab Pages.
Options:
- Any directory name, eg `"public"`
Default: `"static"`
### `FE_SUBDIRECTORY`
Subdirectory to host Soapbox out of.

@ -48,7 +48,7 @@ Typically checks are done against `BACKEND_NAME` and `VERSION`.
The version string is similar in purpose to a [User-Agent](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent) string.
The format was first invented by Pleroma, but is now widely used, including by Pixelfed, Mitra, and Soapbox BE.
See [`features.ts`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/app/soapbox/utils/features.ts) for the complete list of features.
See [`features.ts`](https://gitlab.com/soapbox-pub/soapbox/-/blob/main/src/utils/features.ts) for the complete list of features.
## Forks of other software
@ -73,4 +73,4 @@ For Pleroma forks, the fork name should be in the compat section (eg Soapbox BE)
## Adding support for a new backend
If the backend conforms to the above format, please modify [`features.ts`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/app/soapbox/utils/features.ts) and submit a merge request to enable features for your backend!
If the backend conforms to the above format, please modify [`features.ts`](https://gitlab.com/soapbox-pub/soapbox/-/blob/main/src/utils/features.ts) and submit a merge request to enable features for your backend!

@ -18,7 +18,7 @@ location / {
}
```
(See [`mastodon.conf`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/installation/mastodon.conf) for a full example.)
(See [`mastodon.conf`](https://gitlab.com/soapbox-pub/soapbox/-/blob/main/installation/mastodon.conf) for a full example.)
Soapbox incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/), [Pleroma API](https://api.pleroma.social/), and more.
It detects features supported by the backend to provide the right experience for the backend.

@ -40,5 +40,5 @@ Try again.
## Troubleshooting: it's not working!
Run `node -V` and compare your Node.js version with the version in [`.tool-versions`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/.tool-versions).
Run `node -V` and compare your Node.js version with the version in [`.tool-versions`](https://gitlab.com/soapbox-pub/soapbox/-/blob/main/.tool-versions).
If they don't match, try installing [asdf](https://asdf-vm.com/).

@ -12,7 +12,7 @@ NODE_ENV=development
- `yarn dev` - Run the local dev server.
## Building
- `yarn build` - Compile without a dev server, into `/static` directory.
- `yarn build` - Compile without a dev server, into `/dist` directory.
## Translations
- `yarn i18n` - Rebuilds app and updates English locale to prepare for translations in other languages. Should always be run after editing i18n strings.

@ -10,9 +10,9 @@ First, follow the instructions to [install Pleroma](https://docs-develop.pleroma
The Soapbox frontend is the main component of Soapbox. Once you've installed Pleroma, installing Soapbox is a breeze.
First, ssh into the server and download a .zip of the latest build: ``curl -L https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/develop/download?job=build-production -o soapbox-fe.zip``
First, ssh into the server and download a .zip of the latest build: `curl -O https://dl.soapbox.pub/main/soapbox.zip`
Then unpack it into Pleroma's ``instance`` directory: ``busybox unzip soapbox-fe.zip -o -d /opt/pleroma/instance``
Then unpack it into Pleroma's `instance` directory: `busybox unzip soapbox.zip -o -d /opt/pleroma/instance/static`
**That's it! 🎉 Soapbox is installed.** The change will take effect immediately, just refresh your browser tab. It's not necessary to restart the Pleroma service.

@ -7,7 +7,8 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<link href="/manifest.json" rel="manifest">
<!--server-generated-meta-->
<script type="module" src="./soapbox/main.tsx"></script>
<script type="module" src="./src/main.tsx"></script>
<%- snippets %>
</head>
<body class="theme-mode-light no-reduce-motion">
<div id="soapbox" class="h-full">

@ -33,7 +33,7 @@ server {
listen 80;
listen [::]:80;
server_name example.com;
root /opt/soapbox/static;
root /opt/soapbox;
location /.well-known/acme-challenge/ { allow all; }
location / { return 301 https://$host$request_uri; }
}
@ -58,7 +58,7 @@ server {
sendfile on;
client_max_body_size 80m;
root /opt/soapbox/static;
root /opt/soapbox;
gzip on;
gzip_disable "msie6";

@ -29,7 +29,7 @@
"test:all": "${npm_execpath} run test:coverage && ${npm_execpath} run lint",
"lint": "${npm_execpath} run lint:js && ${npm_execpath} run lint:sass",
"lint:js": "npx eslint --ext .js,.jsx,.cjs,.mjs,.ts,.tsx . --cache",
"lint:sass": "npx stylelint app/styles/**/*.scss",
"lint:sass": "npx stylelint src/styles/**/*.scss",
"prepare": "husky install"
},
"license": "AGPL-3.0-or-later",
@ -66,7 +66,6 @@
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@tanstack/react-query": "^4.0.10",
"@testing-library/react": "^14.0.0",
"@types/escape-html": "^1.0.1",
"@types/http-link-header": "^1.0.3",
"@types/leaflet": "^1.8.0",
@ -83,29 +82,28 @@
"@types/react-sparklines": "^1.7.2",
"@types/react-swipeable-views": "^0.13.1",
"@types/redux-mock-store": "^1.0.3",
"@types/seedrandom": "^3.0.2",
"@types/semver": "^7.3.9",
"@types/uuid": "^9.0.0",
"@vitejs/plugin-react": "^4.0.4",
"autoprefixer": "^10.4.15",
"axios": "^1.2.2",
"axios-mock-adapter": "^1.21.1",
"axios-mock-adapter": "^1.22.0",
"babel-plugin-preval": "^5.1.0",
"babel-plugin-react-intl": "^7.5.20",
"blurhash": "^2.0.0",
"bootstrap-icons": "^1.5.0",
"bowser": "^2.11.0",
"browserslist": "^4.16.6",
"clsx": "^1.2.1",
"clsx": "^2.0.0",
"core-js": "^3.27.2",
"cryptocurrency-icons": "^0.18.1",
"cssnano": "^5.1.10",
"cssnano": "^6.0.0",
"detect-passive-events": "^2.0.0",
"dotenv": "^16.0.0",
"emoji-datasource": "14.0.0",
"emoji-mart": "^5.5.2",
"escape-html": "^1.0.3",
"exif-js": "^2.3.0",
"exifr": "^7.1.3",
"graphemesplit": "^2.4.4",
"http-link-header": "^1.0.2",
"immer": "^10.0.0",
@ -113,9 +111,8 @@
"intersection-observer": "^0.12.2",
"intl-messageformat": "9.13.0",
"intl-messageformat-parser": "^6.0.0",
"intl-pluralrules": "^1.3.1",
"intl-pluralrules": "^2.0.0",
"leaflet": "^1.8.0",
"libphonenumber-js": "^1.10.8",
"line-awesome": "^1.3.0",
"localforage": "^1.10.0",
"lodash": "^4.7.11",
@ -134,10 +131,9 @@
"react-hot-toast": "^2.4.0",
"react-hotkeys": "^1.1.4",
"react-immutable-pure-component": "^2.2.2",
"react-inlinesvg": "^3.0.0",
"react-inlinesvg": "^4.0.0",
"react-intl": "^5.0.0",
"react-motion": "^0.5.2",
"react-otp-input": "^2.4.0",
"react-overlays": "^0.9.0",
"react-popper": "^2.3.0",
"react-redux": "^8.0.0",
@ -156,7 +152,6 @@
"reselect": "^4.0.0",
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.66.1",
"seedrandom": "^3.0.5",
"semver": "^7.3.8",
"stringz": "^2.0.0",
"substring-trie": "^1.0.2",
@ -164,7 +159,7 @@
"ts-node": "^10.9.1",
"tslib": "^2.3.1",
"twemoji": "https://github.com/twitter/twemoji#v14.0.2",
"type-fest": "^3.12.0",
"type-fest": "^4.0.0",
"typescript": "^5.1.3",
"util": "^0.12.4",
"uuid": "^9.0.0",
@ -179,14 +174,16 @@
"devDependencies": {
"@gitbeaker/node": "^35.8.0",
"@jedmao/redux-mock-store": "^3.0.5",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.4.3",
"@testing-library/user-event": "^14.5.1",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"babel-plugin-transform-require-context": "^0.1.1",
"cross-env": "^7.0.3",
"danger": "^11.0.7",
"eslint": "^8.49.0",
"eslint-import-resolver-typescript": "^3.6.0",
"eslint-plugin-compat": "^4.2.0",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsdoc": "^46.8.1",

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save