diff --git a/.dockerignore b/.dockerignore index 41b87855a..2d53bc7e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,10 +12,11 @@ yarn-error.log* /junit.xml +/dist/ /static/ -/static-test/ /public/ /dist/ +/soapbox.zip .idea .DS_Store diff --git a/.eslintignore b/.eslintignore index 1ab6f8d8c..dc7fe3106 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,6 +1,6 @@ /node_modules/** +/dist/** /static/** -/static-test/** /public/** /tmp/** /coverage/** diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0df30b234..4359767c2 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -19,9 +19,8 @@ module.exports = { ATTACHMENT_HOST: false, }, - parser: '@babel/eslint-parser', - plugins: [ + 'jsdoc', 'react', 'jsx-a11y', 'import', @@ -49,9 +48,8 @@ module.exports = { '\\.(css|scss|json)$', ], 'import/resolver': { - node: { - paths: ['app'], - }, + typescript: true, + node: true, }, polyfills: [ 'es:all', // core-js @@ -78,6 +76,7 @@ module.exports = { }, ], 'comma-style': ['warn', 'last'], + 'import/no-duplicates': 'error', 'space-before-function-paren': ['error', 'never'], 'space-infix-ops': 'error', 'space-in-parens': ['error', 'never'], @@ -259,7 +258,6 @@ module.exports = { alphabetize: { order: 'asc' }, }, ], - '@typescript-eslint/no-duplicate-imports': 'error', '@typescript-eslint/member-delimiter-style': [ 'error', { @@ -294,5 +292,23 @@ module.exports = { }, parser: '@typescript-eslint/parser', }, + { + // Only enforce JSDoc comments on UI components for now. + // https://www.npmjs.com/package/eslint-plugin-jsdoc + files: ['src/components/ui/**/*'], + rules: { + 'jsdoc/require-jsdoc': ['error', { + publicOnly: true, + require: { + ArrowFunctionExpression: true, + ClassDeclaration: true, + ClassExpression: true, + FunctionDeclaration: true, + FunctionExpression: true, + MethodDefinition: true, + }, + }], + }, + }, ], }; diff --git a/.gitignore b/.gitignore index 92e9362d8..176314352 100644 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,14 @@ /.vs/ yarn-error.log* /junit.xml +*.timestamp-* +*.bundled_* +/dist/ /static/ -/static-test/ /public/ /dist/ +/soapbox.zip .idea .DS_Store diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 97892e166..defe89e3b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,7 @@ image: node:20 variables: NODE_ENV: test + DS_EXCLUDED_ANALYZERS: gemnasium-python default: interruptible: true @@ -30,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" @@ -52,63 +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: +build: 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: - 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 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 @@ -128,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: @@ -149,9 +105,9 @@ pages: docker: stage: deploy - image: docker:23.0.0 + image: docker:24.0.6 services: - - docker:23.0.0-dind + - docker:24.0.6-dind tags: - dind # https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df @@ -173,4 +129,3 @@ release: include: - template: Jobs/Dependency-Scanning.gitlab-ci.yml - - template: Security/License-Scanning.gitlab-ci.yml \ No newline at end of file diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 97bad7f28..fc508d7e3 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -4,5 +4,5 @@ "*.mjs": "eslint --cache", "*.ts": "eslint --cache", "*.tsx": "eslint --cache", - "app/styles/**/*.scss": "stylelint" + "src/styles/**/*.scss": "stylelint" } diff --git a/.stylelintrc.json b/.stylelintrc.json index 1a610d5eb..d01dd67b6 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -16,7 +16,6 @@ "no-invalid-position-at-import-rule": null, "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["tailwind", "apply", "layer", "config"]}], "scss/operator-no-unspaced": null, - "selector-class-pattern": null, - "string-quotes": "single" + "selector-class-pattern": null } } diff --git a/Dockerfile b/Dockerfile index b02bf86e1..2765f2053 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/assets/icons/COPYING.md b/app/assets/icons/COPYING.md deleted file mode 100644 index a5dbe7d98..000000000 --- a/app/assets/icons/COPYING.md +++ /dev/null @@ -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 diff --git a/app/soapbox/__fixtures__/truthsocial-status-with-external-video.json b/app/soapbox/__fixtures__/truthsocial-status-with-external-video.json deleted file mode 100644 index eb6150192..000000000 --- a/app/soapbox/__fixtures__/truthsocial-status-with-external-video.json +++ /dev/null @@ -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": "

Launching Truth Social

", - "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": "", - "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 -} diff --git a/app/soapbox/actions/__tests__/account-notes.test.ts b/app/soapbox/actions/__tests__/account-notes.test.ts deleted file mode 100644 index fdae30838..000000000 --- a/app/soapbox/actions/__tests__/account-notes.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; - -import { __stub } from 'soapbox/api'; -import { buildAccount, buildRelationship } from 'soapbox/jest/factory'; -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; -import { ReducerRecord, EditRecord } from 'soapbox/reducers/account-notes'; - -import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes'; - -describe('submitAccountNote()', () => { - let store: ReturnType; - - beforeEach(() => { - const state = rootState - .set('account_notes', ReducerRecord({ edit: EditRecord({ account: '1', comment: 'hello' }) })); - store = mockStore(state); - }); - - describe('with a successful API request', () => { - beforeEach(() => { - __stub((mock) => { - mock.onPost('/api/v1/accounts/1/note').reply(200, {}); - }); - }); - - it('post the note to the API', async() => { - const expectedActions = [ - { type: 'ACCOUNT_NOTE_SUBMIT_REQUEST' }, - { type: 'MODAL_CLOSE', modalType: undefined }, - { type: 'ACCOUNT_NOTE_SUBMIT_SUCCESS', relationship: {} }, - ]; - await store.dispatch(submitAccountNote()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with an unsuccessful API request', () => { - beforeEach(() => { - __stub((mock) => { - mock.onPost('/api/v1/accounts/1/note').networkError(); - }); - }); - - it('should dispatch failed action', async() => { - const expectedActions = [ - { type: 'ACCOUNT_NOTE_SUBMIT_REQUEST' }, - { - type: 'ACCOUNT_NOTE_SUBMIT_FAIL', - error: new Error('Network Error'), - }, - ]; - await store.dispatch(submitAccountNote()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); - -describe('initAccountNoteModal()', () => { - let store: ReturnType; - - beforeEach(() => { - const state = rootState - .set('relationships', ImmutableMap({ '1': buildRelationship({ note: 'hello' }) })); - store = mockStore(state); - }); - - it('dispatches the proper actions', async() => { - const account = buildAccount({ - id: '1', - acct: 'justin-username', - display_name: 'Justin L', - avatar: 'test.jpg', - verified: true, - }); - const expectedActions = [ - { type: 'ACCOUNT_NOTE_INIT_MODAL', account, comment: 'hello' }, - { type: 'MODAL_CLOSE', modalType: 'ACCOUNT_NOTE' }, - { type: 'MODAL_OPEN', modalType: 'ACCOUNT_NOTE' }, - ]; - await store.dispatch(initAccountNoteModal(account)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); - -describe('changeAccountNoteComment()', () => { - let store: ReturnType; - - beforeEach(() => { - const state = rootState; - store = mockStore(state); - }); - - it('dispatches the proper actions', async() => { - const comment = 'hello world'; - const expectedActions = [ - { type: 'ACCOUNT_NOTE_CHANGE_COMMENT', comment }, - ]; - await store.dispatch(changeAccountNoteComment(comment)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); diff --git a/app/soapbox/actions/account-notes.ts b/app/soapbox/actions/account-notes.ts deleted file mode 100644 index 691f63fc3..000000000 --- a/app/soapbox/actions/account-notes.ts +++ /dev/null @@ -1,82 +0,0 @@ -import api from '../api'; - -import { openModal, closeModal } from './modals'; - -import type { AxiosError } from 'axios'; -import type { AnyAction } from 'redux'; -import type { Account } from 'soapbox/schemas'; -import type { AppDispatch, RootState } from 'soapbox/store'; - -const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; -const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; -const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; - -const ACCOUNT_NOTE_INIT_MODAL = 'ACCOUNT_NOTE_INIT_MODAL'; - -const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT'; - -const submitAccountNote = () => (dispatch: React.Dispatch, getState: () => RootState) => { - dispatch(submitAccountNoteRequest()); - - const id = getState().account_notes.edit.account; - - return api(getState) - .post(`/api/v1/accounts/${id}/note`, { - comment: getState().account_notes.edit.comment, - }) - .then(response => { - dispatch(closeModal()); - dispatch(submitAccountNoteSuccess(response.data)); - }) - .catch(error => dispatch(submitAccountNoteFail(error))); -}; - -function submitAccountNoteRequest() { - return { - type: ACCOUNT_NOTE_SUBMIT_REQUEST, - }; -} - -function submitAccountNoteSuccess(relationship: any) { - return { - type: ACCOUNT_NOTE_SUBMIT_SUCCESS, - relationship, - }; -} - -function submitAccountNoteFail(error: AxiosError) { - return { - type: ACCOUNT_NOTE_SUBMIT_FAIL, - error, - }; -} - -const initAccountNoteModal = (account: Account) => (dispatch: AppDispatch, getState: () => RootState) => { - const comment = getState().relationships.get(account.id)!.note; - - dispatch({ - type: ACCOUNT_NOTE_INIT_MODAL, - account, - comment, - }); - - dispatch(openModal('ACCOUNT_NOTE')); -}; - -function changeAccountNoteComment(comment: string) { - return { - type: ACCOUNT_NOTE_CHANGE_COMMENT, - comment, - }; -} - -export { - submitAccountNote, - initAccountNoteModal, - changeAccountNoteComment, - ACCOUNT_NOTE_SUBMIT_REQUEST, - ACCOUNT_NOTE_SUBMIT_SUCCESS, - ACCOUNT_NOTE_SUBMIT_FAIL, - ACCOUNT_NOTE_INIT_MODAL, - ACCOUNT_NOTE_CHANGE_COMMENT, -}; diff --git a/app/soapbox/actions/verification.ts b/app/soapbox/actions/verification.ts deleted file mode 100644 index 2273bb499..000000000 --- a/app/soapbox/actions/verification.ts +++ /dev/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, -}; diff --git a/app/soapbox/api/hooks/streaming/useNostrStream.ts b/app/soapbox/api/hooks/streaming/useNostrStream.ts deleted file mode 100644 index 6748f95ea..000000000 --- a/app/soapbox/api/hooks/streaming/useNostrStream.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useFeatures, useLoggedIn } from 'soapbox/hooks'; - -import { useTimelineStream } from './useTimelineStream'; - -function useNostrStream() { - const features = useFeatures(); - const { isLoggedIn } = useLoggedIn(); - - return useTimelineStream( - 'nostr', - 'nostr', - null, - null, - { - enabled: isLoggedIn && features.nostrSign && Boolean(window.nostr), - }, - ); -} - -export { useNostrStream }; \ No newline at end of file diff --git a/app/soapbox/components/ui/phone-input/country-code-dropdown.tsx b/app/soapbox/components/ui/phone-input/country-code-dropdown.tsx deleted file mode 100644 index 208db6952..000000000 --- a/app/soapbox/components/ui/phone-input/country-code-dropdown.tsx +++ /dev/null @@ -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 = ({ countryCode, onChange }) => { - return ( - - ); -}; - -export default CountryCodeDropdown; diff --git a/app/soapbox/components/ui/phone-input/phone-input.tsx b/app/soapbox/components/ui/phone-input/phone-input.tsx deleted file mode 100644 index d3072f2a1..000000000 --- a/app/soapbox/components/ui/phone-input/phone-input.tsx +++ /dev/null @@ -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, '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 = (props) => { - const { value, onChange, defaultCountryCode = '1', ...rest } = props; - - const [countryCode, setCountryCode] = useState(defaultCountryCode); - const [nationalNumber, setNationalNumber] = useState(''); - - const handleChange: React.ChangeEventHandler = ({ 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 ( - - } - {...rest} - /> - ); -}; - -export default PhoneInput; diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx deleted file mode 100644 index 4636795bc..000000000 --- a/app/soapbox/features/ads/components/ad.tsx +++ /dev/null @@ -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 = ({ ad }) => { - const queryClient = useQueryClient(); - const instance = useInstance(); - - const timer = useRef(undefined); - const infobox = useRef(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 ( -
- - - - - - - - - {instance.title} - - - - - - - - - - - - - - - - - - - - { }} horizontal /> - - - - {showInfo && ( -
- - - - - - - - {ad.reason ? ( - ad.reason - ) : ( - - )} - - - -
- )} -
- ); -}; - -export default Ad; diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts deleted file mode 100644 index 63067b81d..000000000 --- a/app/soapbox/features/ads/providers/index.ts +++ /dev/null @@ -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 Promise> = { - soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default, - truth: async() => (await import(/* webpackChunkName: "features/ads/truth" */'./truth')).default, -}; - -/** Ad server implementation. */ -interface AdProvider { - getAds(getState: () => RootState): Promise -} - -/** 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 => { - 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 }; diff --git a/app/soapbox/features/ads/providers/soapbox-config.ts b/app/soapbox/features/ads/providers/soapbox-config.ts deleted file mode 100644 index 21163729c..000000000 --- a/app/soapbox/features/ads/providers/soapbox-config.ts +++ /dev/null @@ -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; diff --git a/app/soapbox/features/ads/providers/truth.ts b/app/soapbox/features/ads/providers/truth.ts deleted file mode 100644 index 5582bd3cf..000000000 --- a/app/soapbox/features/ads/providers/truth.ts +++ /dev/null @@ -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; diff --git a/app/soapbox/features/auth-layout/index.tsx b/app/soapbox/features/auth-layout/index.tsx deleted file mode 100644 index 9ad78c184..000000000 --- a/app/soapbox/features/auth-layout/index.tsx +++ /dev/null @@ -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 ( -
- - -
-
-
-
- - - -
- - {(isLoginPage && isOpen) && ( -
- -
- )} -
- -
-
- - - - {/* If already logged in, redirect home. */} - {account && } - - - - - - - - - - - - - - - - -
-
- -
-
-
-
-
-
- ); -}; - -export default AuthLayout; diff --git a/app/soapbox/features/auth-login/components/login-form.tsx b/app/soapbox/features/auth-login/components/login-form.tsx deleted file mode 100644 index d7871fc75..000000000 --- a/app/soapbox/features/auth-login/components/login-form.tsx +++ /dev/null @@ -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 = ({ 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 ( -
-
-

-
- - -
- - - - - - - - } - > - - - - - - -
- - -
-
- ); -}; - -export default LoginForm; diff --git a/app/soapbox/features/edit-password/index.tsx b/app/soapbox/features/edit-password/index.tsx deleted file mode 100644 index eb6b1c100..000000000 --- a/app/soapbox/features/edit-password/index.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React from 'react'; -import { defineMessages, useIntl } from 'react-intl'; - -import { changePassword } from 'soapbox/actions/security'; -import { Button, Card, CardBody, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui'; -import { useAppDispatch, useFeatures } from 'soapbox/hooks'; -import toast from 'soapbox/toast'; - -import PasswordIndicator from '../verification/components/password-indicator'; - -const messages = defineMessages({ - updatePasswordSuccess: { id: 'security.update_password.success', defaultMessage: 'Password successfully updated.' }, - updatePasswordFail: { id: 'security.update_password.fail', defaultMessage: 'Update password failed.' }, - oldPasswordFieldLabel: { id: 'security.fields.old_password.label', defaultMessage: 'Current password' }, - newPasswordFieldLabel: { id: 'security.fields.new_password.label', defaultMessage: 'New password' }, - confirmationFieldLabel: { id: 'security.fields.password_confirmation.label', defaultMessage: 'New password (again)' }, - header: { id: 'edit_password.header', defaultMessage: 'Change Password' }, - submit: { id: 'security.submit', defaultMessage: 'Save changes' }, - cancel: { id: 'common.cancel', defaultMessage: 'Cancel' }, -}); - -const initialState = { currentPassword: '', newPassword: '', newPasswordConfirmation: '' }; - -const EditPassword = () => { - const intl = useIntl(); - const dispatch = useAppDispatch(); - const { passwordRequirements } = useFeatures(); - - const [state, setState] = React.useState(initialState); - const [isLoading, setLoading] = React.useState(false); - const [hasValidPassword, setHasValidPassword] = React.useState(passwordRequirements ? false : true); - - const { currentPassword, newPassword, newPasswordConfirmation } = state; - - const resetState = () => setState(initialState); - - const handleInputChange: React.ChangeEventHandler = React.useCallback((event) => { - event.persist(); - - setState((prevState) => ({ ...prevState, [event.target.name]: event.target.value })); - }, []); - - const handleSubmit = React.useCallback(() => { - setLoading(true); - dispatch(changePassword(currentPassword, newPassword, newPasswordConfirmation)).then(() => { - resetState(); - toast.success(intl.formatMessage(messages.updatePasswordSuccess)); - - }).finally(() => { - setLoading(false); - }).catch(() => { - resetState(); - toast.error(intl.formatMessage(messages.updatePasswordFail)); - }); - }, [currentPassword, newPassword, newPasswordConfirmation, dispatch, intl]); - - return ( - - - - - - - -
- - - - - - - - {passwordRequirements && ( - - )} - - - - - - - - - - - -
-
-
-
- ); -}; - -export default EditPassword; diff --git a/app/soapbox/features/external-login/index.tsx b/app/soapbox/features/external-login/index.tsx deleted file mode 100644 index 510014dc2..000000000 --- a/app/soapbox/features/external-login/index.tsx +++ /dev/null @@ -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 ; -}; - -export default ExternalLoginPage; diff --git a/app/soapbox/features/landing-page/__tests__/landing-page.test.tsx b/app/soapbox/features/landing-page/__tests__/landing-page.test.tsx deleted file mode 100644 index 6e96672ba..000000000 --- a/app/soapbox/features/landing-page/__tests__/landing-page.test.tsx +++ /dev/null @@ -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('', () => { - 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(, 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(, 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(, 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(, undefined, state); - - expect(screen.queryByTestId('registrations-closed')).toBeInTheDocument(); - expect(screen.queryByTestId('registrations-pepe')).not.toBeInTheDocument(); - expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument(); - }); -}); diff --git a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx deleted file mode 100644 index 55a54c514..000000000 --- a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx +++ /dev/null @@ -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(null); - const [selectedFile, setSelectedFile] = React.useState(); - const [isSubmitting, setSubmitting] = React.useState(false); - const [isDisabled, setDisabled] = React.useState(true); - const isDefault = account ? isDefaultAvatar(account.avatar) : false; - - const openFilePicker = () => { - fileInput.current?.click(); - }; - - const handleFileChange = (event: React.ChangeEvent) => { - 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 ( - - -
-
- - - - - - - - - -
- -
- -
- {account && ( - - )} - - {isSubmitting && ( -
- -
- )} - - - - -
- - - - - {isDisabled && ( - - )} - -
-
-
-
-
- ); -}; - -export default AvatarSelectionStep; diff --git a/app/soapbox/features/onboarding/steps/bio-step.tsx b/app/soapbox/features/onboarding/steps/bio-step.tsx deleted file mode 100644 index c0a029d80..000000000 --- a/app/soapbox/features/onboarding/steps/bio-step.tsx +++ /dev/null @@ -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(account?.source?.note ?? ''); - const [isSubmitting, setSubmitting] = React.useState(false); - const [errors, setErrors] = React.useState([]); - - 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 ( - - -
-
- - - - - - - - - -
- - -
- } - labelText={} - errors={errors} - > -