Merge remote-tracking branch 'soapbox/main' into lexical
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
commit
fac42ab2c9
|
@ -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/**
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -9,11 +9,14 @@
|
|||
/.vs/
|
||||
yarn-error.log*
|
||||
/junit.xml
|
||||
*.timestamp-*
|
||||
*.bundled_*
|
||||
|
||||
/dist/
|
||||
/static/
|
||||
/static-test/
|
||||
/public/
|
||||
/dist/
|
||||
/soapbox.zip
|
||||
|
||||
.idea
|
||||
.DS_Store
|
||||
|
|
|
@ -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
|
|
@ -4,5 +4,5 @@
|
|||
"*.mjs": "eslint --cache",
|
||||
"*.ts": "eslint --cache",
|
||||
"*.tsx": "eslint --cache",
|
||||
"app/styles/**/*.scss": "stylelint"
|
||||
"src/styles/**/*.scss": "stylelint"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,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,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<typeof mockStore>;
|
||||
|
||||
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<typeof mockStore>;
|
||||
|
||||
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<typeof mockStore>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -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<AnyAction>, 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,
|
||||
};
|
|
@ -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,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 };
|
|
@ -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(/* 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<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,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<boolean>(passwordRequirements ? false : true);
|
||||
|
||||
const { currentPassword, newPassword, newPasswordConfirmation } = state;
|
||||
|
||||
const resetState = () => setState(initialState);
|
||||
|
||||
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = 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 (
|
||||
<Column label={intl.formatMessage(messages.header)} transparent withHeader={false}>
|
||||
<Card variant='rounded'>
|
||||
<CardHeader backHref='/settings'>
|
||||
<CardTitle title={intl.formatMessage(messages.header)} />
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormGroup labelText={intl.formatMessage(messages.oldPasswordFieldLabel)}>
|
||||
<Input
|
||||
type='password'
|
||||
name='currentPassword'
|
||||
onChange={handleInputChange}
|
||||
value={currentPassword}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={intl.formatMessage(messages.newPasswordFieldLabel)}>
|
||||
<Input
|
||||
type='password'
|
||||
name='newPassword'
|
||||
onChange={handleInputChange}
|
||||
value={newPassword}
|
||||
/>
|
||||
|
||||
{passwordRequirements && (
|
||||
<PasswordIndicator password={newPassword} onChange={setHasValidPassword} />
|
||||
)}
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={intl.formatMessage(messages.confirmationFieldLabel)}>
|
||||
<Input
|
||||
type='password'
|
||||
name='newPasswordConfirmation'
|
||||
onChange={handleInputChange}
|
||||
value={newPasswordConfirmation}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<Button to='/settings' theme='tertiary'>
|
||||
{intl.formatMessage(messages.cancel)}
|
||||
</Button>
|
||||
|
||||
<Button type='submit' theme='primary' disabled={isLoading || !hasValidPassword}>
|
||||
{intl.formatMessage(messages.submit)}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditPassword;
|
|
@ -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,67 +0,0 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { changeAccountNoteComment, submitAccountNote } from 'soapbox/actions/account-notes';
|
||||
import { closeModal } from 'soapbox/actions/modals';
|
||||
import { useAccount } from 'soapbox/api/hooks';
|
||||
import { Modal, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
|
||||
save: { id: 'account_note.save', defaultMessage: 'Save' },
|
||||
});
|
||||
|
||||
const AccountNoteModal = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const isSubmitting = useAppSelector((state) => state.account_notes.edit.isSubmitting);
|
||||
const accountId = useAppSelector((state) => state.account_notes.edit.account);
|
||||
const { account } = useAccount(accountId || undefined);
|
||||
const comment = useAppSelector((state) => state.account_notes.edit.comment);
|
||||
|
||||
const onClose = () => {
|
||||
dispatch(closeModal('ACCOUNT_NOTE'));
|
||||
};
|
||||
|
||||
const handleCommentChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
|
||||
dispatch(changeAccountNoteComment(e.target.value));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(submitAccountNote());
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = e => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<FormattedMessage id='account_note.target' defaultMessage='Note for @{target}' values={{ target: account!.acct }} />}
|
||||
onClose={onClose}
|
||||
confirmationAction={handleSubmit}
|
||||
confirmationText={intl.formatMessage(messages.save)}
|
||||
confirmationDisabled={isSubmitting}
|
||||
>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage id='account_note.hint' defaultMessage='You can keep notes about this user for yourself (this will not be shared with them):' />
|
||||
</Text>
|
||||
|
||||
<textarea
|
||||
className='setting-text light'
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={comment}
|
||||
onChange={handleCommentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountNoteModal;
|
|
@ -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,647 +0,0 @@
|
|||
export function EmojiPicker() {
|
||||
return import(/* webpackChunkName: "emoji_picker" */'../../emoji/components/emoji-picker');
|
||||
}
|
||||
|
||||
export function Notifications() {
|
||||
return import(/* webpackChunkName: "features/notifications" */'../../notifications');
|
||||
}
|
||||
|
||||
export function HomeTimeline() {
|
||||
return import(/* webpackChunkName: "features/home_timeline" */'../../home-timeline');
|
||||
}
|
||||
|
||||
export function PublicTimeline() {
|
||||
return import(/* webpackChunkName: "features/public_timeline" */'../../public-timeline');
|
||||
}
|
||||
|
||||
export function RemoteTimeline() {
|
||||
return import(/* webpackChunkName: "features/remote_timeline" */'../../remote-timeline');
|
||||
}
|
||||
|
||||
export function CommunityTimeline() {
|
||||
return import(/* webpackChunkName: "features/community_timeline" */'../../community-timeline');
|
||||
}
|
||||
|
||||
export function HashtagTimeline() {
|
||||
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag-timeline');
|
||||
}
|
||||
|
||||
export function DirectTimeline() {
|
||||
return import(/* webpackChunkName: "features/direct_timeline" */'../../direct-timeline');
|
||||
}
|
||||
|
||||
export function Conversations() {
|
||||
return import(/* webpackChunkName: "features/conversations" */'../../conversations');
|
||||
}
|
||||
|
||||
export function ListTimeline() {
|
||||
return import(/* webpackChunkName: "features/list_timeline" */'../../list-timeline');
|
||||
}
|
||||
|
||||
export function Lists() {
|
||||
return import(/* webpackChunkName: "features/lists" */'../../lists');
|
||||
}
|
||||
|
||||
export function Bookmarks() {
|
||||
return import(/* webpackChunkName: "features/bookmarks" */'../../bookmarks');
|
||||
}
|
||||
|
||||
export function Status() {
|
||||
return import(/* webpackChunkName: "features/status" */'../../status');
|
||||
}
|
||||
|
||||
export function PinnedStatuses() {
|
||||
return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned-statuses');
|
||||
}
|
||||
|
||||
export function AccountTimeline() {
|
||||
return import(/* webpackChunkName: "features/account_timeline" */'../../account-timeline');
|
||||
}
|
||||
|
||||
export function AccountGallery() {
|
||||
return import(/* webpackChunkName: "features/account_gallery" */'../../account-gallery');
|
||||
}
|
||||
|
||||
export function Followers() {
|
||||
return import(/* webpackChunkName: "features/followers" */'../../followers');
|
||||
}
|
||||
|
||||
export function Following() {
|
||||
return import(/* webpackChunkName: "features/following" */'../../following');
|
||||
}
|
||||
|
||||
export function FollowRequests() {
|
||||
return import(/* webpackChunkName: "features/follow_requests" */'../../follow-requests');
|
||||
}
|
||||
|
||||
export function GenericNotFound() {
|
||||
return import(/* webpackChunkName: "features/generic_not_found" */'../../generic-not-found');
|
||||
}
|
||||
|
||||
export function FavouritedStatuses() {
|
||||
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited-statuses');
|
||||
}
|
||||
|
||||
export function Blocks() {
|
||||
return import(/* webpackChunkName: "features/blocks" */'../../blocks');
|
||||
}
|
||||
|
||||
export function DomainBlocks() {
|
||||
return import(/* webpackChunkName: "features/domain_blocks" */'../../domain-blocks');
|
||||
}
|
||||
|
||||
export function Mutes() {
|
||||
return import(/* webpackChunkName: "features/mutes" */'../../mutes');
|
||||
}
|
||||
|
||||
export function MuteModal() {
|
||||
return import(/* webpackChunkName: "modals/mute_modal" */'../components/modals/mute-modal');
|
||||
}
|
||||
|
||||
export function Filters() {
|
||||
return import(/* webpackChunkName: "features/filters" */'../../filters');
|
||||
}
|
||||
|
||||
export function EditFilter() {
|
||||
return import(/* webpackChunkName: "features/filters" */'../../filters/edit-filter');
|
||||
}
|
||||
|
||||
export function ReportModal() {
|
||||
return import(/* webpackChunkName: "modals/report-modal/report-modal" */'../components/modals/report-modal/report-modal');
|
||||
}
|
||||
|
||||
export function AccountModerationModal() {
|
||||
return import(/* webpackChunkName: "modals/account-moderation-modal" */'../components/modals/account-moderation-modal/account-moderation-modal');
|
||||
}
|
||||
|
||||
export function PolicyModal() {
|
||||
return import(/* webpackChunkName: "modals/policy-modal" */'../components/modals/policy-modal');
|
||||
}
|
||||
|
||||
export function MediaGallery() {
|
||||
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media-gallery');
|
||||
}
|
||||
|
||||
export function Video() {
|
||||
return import(/* webpackChunkName: "features/video" */'../../video');
|
||||
}
|
||||
|
||||
export function Audio() {
|
||||
return import(/* webpackChunkName: "features/audio" */'../../audio');
|
||||
}
|
||||
|
||||
export function MediaModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/media-modal');
|
||||
}
|
||||
|
||||
export function VideoModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/video-modal');
|
||||
}
|
||||
|
||||
export function BoostModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/boost-modal');
|
||||
}
|
||||
|
||||
export function ConfirmationModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/confirmation-modal');
|
||||
}
|
||||
|
||||
export function MissingDescriptionModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/missing-description-modal');
|
||||
}
|
||||
|
||||
export function ActionsModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/actions-modal');
|
||||
}
|
||||
|
||||
export function HotkeysModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/hotkeys-modal');
|
||||
}
|
||||
|
||||
export function ComposeModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/compose-modal');
|
||||
}
|
||||
|
||||
export function ReplyMentionsModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/reply-mentions-modal');
|
||||
}
|
||||
|
||||
export function UnauthorizedModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/unauthorized-modal');
|
||||
}
|
||||
|
||||
export function EditFederationModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/edit-federation-modal');
|
||||
}
|
||||
|
||||
export function EmbedModal() {
|
||||
return import(/* webpackChunkName: "modals/embed_modal" */'../components/modals/embed-modal');
|
||||
}
|
||||
|
||||
export function ComponentModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/component-modal');
|
||||
}
|
||||
|
||||
export function ReblogsModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/reblogs-modal');
|
||||
}
|
||||
|
||||
export function FavouritesModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/favourites-modal');
|
||||
}
|
||||
|
||||
export function DislikesModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/dislikes-modal');
|
||||
}
|
||||
|
||||
export function ReactionsModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/reactions-modal');
|
||||
}
|
||||
|
||||
export function MentionsModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/mentions-modal');
|
||||
}
|
||||
|
||||
export function LandingPageModal() {
|
||||
return import(/* webpackChunkName: "features/ui/modals/landing-page-modal" */'../components/modals/landing-page-modal');
|
||||
}
|
||||
|
||||
export function BirthdaysModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/birthdays-modal');
|
||||
}
|
||||
|
||||
export function BirthdayPanel() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../../../components/birthday-panel');
|
||||
}
|
||||
|
||||
export function AccountNoteModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/account-note-modal');
|
||||
}
|
||||
|
||||
export function ListEditor() {
|
||||
return import(/* webpackChunkName: "features/list_editor" */'../../list-editor');
|
||||
}
|
||||
|
||||
export function ListAdder() {
|
||||
return import(/*webpackChunkName: "features/list_adder" */'../../list-adder');
|
||||
}
|
||||
|
||||
export function Search() {
|
||||
return import(/*webpackChunkName: "features/search" */'../../search');
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
return import(/* webpackChunkName: "features/auth_login" */'../../auth-login/components/login-page');
|
||||
}
|
||||
|
||||
export function ExternalLogin() {
|
||||
return import(/* webpackChunkName: "features/external_login" */'../../external-login');
|
||||
}
|
||||
|
||||
export function LogoutPage() {
|
||||
return import(/* webpackChunkName: "features/auth_login" */'../../auth-login/components/logout');
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
return import(/* webpackChunkName: "features/settings" */'../../settings');
|
||||
}
|
||||
|
||||
export function EditProfile() {
|
||||
return import(/* webpackChunkName: "features/edit_profile" */'../../edit-profile');
|
||||
}
|
||||
|
||||
export function EditEmail() {
|
||||
return import(/* webpackChunkName: "features/edit_email" */'../../edit-email');
|
||||
}
|
||||
|
||||
export function EmailConfirmation() {
|
||||
return import(/* webpackChunkName: "features/email_confirmation" */'../../email-confirmation');
|
||||
}
|
||||
|
||||
export function EditPassword() {
|
||||
return import(/* webpackChunkName: "features/edit_password" */'../../edit-password');
|
||||
}
|
||||
|
||||
export function DeleteAccount() {
|
||||
return import(/* webpackChunkName: "features/delete_account" */'../../delete-account');
|
||||
}
|
||||
|
||||
export function SoapboxConfig() {
|
||||
return import(/* webpackChunkName: "features/soapbox_config" */'../../soapbox-config');
|
||||
}
|
||||
|
||||
export function ExportData() {
|
||||
return import(/* webpackChunkName: "features/export_data" */ '../../export-data');
|
||||
}
|
||||
|
||||
export function ImportData() {
|
||||
return import(/* webpackChunkName: "features/import_data" */'../../import-data');
|
||||
}
|
||||
|
||||
export function Backups() {
|
||||
return import(/* webpackChunkName: "features/backups" */'../../backups');
|
||||
}
|
||||
|
||||
export function PasswordReset() {
|
||||
return import(/* webpackChunkName: "features/auth_login" */'../../auth-login/components/password-reset');
|
||||
}
|
||||
|
||||
export function PasswordResetConfirm() {
|
||||
return import(/* webpackChunkName: "features/auth_login/password_reset_confirm" */'../../auth-login/components/password-reset-confirm');
|
||||
}
|
||||
|
||||
export function MfaForm() {
|
||||
return import(/* webpackChunkName: "features/security/mfa_form" */'../../security/mfa-form');
|
||||
}
|
||||
|
||||
export function ChatIndex() {
|
||||
return import(/* webpackChunkName: "features/chats" */'../../chats');
|
||||
}
|
||||
|
||||
export function ChatWidget() {
|
||||
return import(/* webpackChunkName: "features/chats/components/chat-widget" */'../../chats/components/chat-widget/chat-widget');
|
||||
}
|
||||
|
||||
export function ServerInfo() {
|
||||
return import(/* webpackChunkName: "features/server_info" */'../../server-info');
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
return import(/* webpackChunkName: "features/admin" */'../../admin');
|
||||
}
|
||||
|
||||
export function ModerationLog() {
|
||||
return import(/* webpackChunkName: "features/admin/moderation_log" */'../../admin/moderation-log');
|
||||
}
|
||||
|
||||
export function ThemeEditor() {
|
||||
return import(/* webpackChunkName: "features/theme-editor" */'../../theme-editor');
|
||||
}
|
||||
|
||||
export function UserPanel() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/user-panel');
|
||||
}
|
||||
|
||||
export function PromoPanel() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/promo-panel');
|
||||
}
|
||||
|
||||
export function SignUpPanel() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/panels/sign-up-panel');
|
||||
}
|
||||
|
||||
export function CtaBanner() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/cta-banner');
|
||||
}
|
||||
|
||||
export function FundingPanel() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/funding-panel');
|
||||
}
|
||||
|
||||
export function TrendsPanel() {
|
||||
return import(/* webpackChunkName: "features/trends" */'../components/trends-panel');
|
||||
}
|
||||
|
||||
export function ProfileInfoPanel() {
|
||||
return import(/* webpackChunkName: "features/account_timeline" */'../components/profile-info-panel');
|
||||
}
|
||||
|
||||
export function ProfileMediaPanel() {
|
||||
return import(/* webpackChunkName: "features/account_gallery" */'../components/profile-media-panel');
|
||||
}
|
||||
|
||||
export function ProfileFieldsPanel() {
|
||||
return import(/* webpackChunkName: "features/account_timeline" */'../components/profile-fields-panel');
|
||||
}
|
||||
|
||||
export function PinnedAccountsPanel() {
|
||||
return import(/* webpackChunkName: "features/pinned_accounts" */'../components/pinned-accounts-panel');
|
||||
}
|
||||
|
||||
export function InstanceInfoPanel() {
|
||||
return import(/* webpackChunkName: "features/remote_timeline" */'../components/instance-info-panel');
|
||||
}
|
||||
|
||||
export function InstanceModerationPanel() {
|
||||
return import(/* webpackChunkName: "features/remote_timeline" */'../components/instance-moderation-panel');
|
||||
}
|
||||
|
||||
export function LatestAccountsPanel() {
|
||||
return import(/* webpackChunkName: "features/admin" */'../../admin/components/latest-accounts-panel');
|
||||
}
|
||||
|
||||
export function SidebarMenu() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../../../components/sidebar-menu');
|
||||
}
|
||||
|
||||
export function ModalContainer() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../containers/modal-container');
|
||||
}
|
||||
|
||||
export function ProfileHoverCard() {
|
||||
return import(/* webpackChunkName: "features/ui" */'soapbox/components/profile-hover-card');
|
||||
}
|
||||
|
||||
export function StatusHoverCard() {
|
||||
return import(/* webpackChunkName: "features/ui" */'soapbox/components/status-hover-card');
|
||||
}
|
||||
|
||||
export function CryptoDonate() {
|
||||
return import(/* webpackChunkName: "features/crypto_donate" */'../../crypto-donate');
|
||||
}
|
||||
|
||||
export function CryptoDonatePanel() {
|
||||
return import(/* webpackChunkName: "features/crypto_donate" */'../../crypto-donate/components/crypto-donate-panel');
|
||||
}
|
||||
|
||||
export function CryptoAddress() {
|
||||
return import(/* webpackChunkName: "features/crypto_donate" */'../../crypto-donate/components/crypto-address');
|
||||
}
|
||||
|
||||
export function CryptoDonateModal() {
|
||||
return import(/* webpackChunkName: "features/crypto_donate" */'../components/modals/crypto-donate-modal');
|
||||
}
|
||||
|
||||
export function ScheduledStatuses() {
|
||||
return import(/* webpackChunkName: "features/scheduled_statuses" */'../../scheduled-statuses');
|
||||
}
|
||||
|
||||
export function UserIndex() {
|
||||
return import(/* webpackChunkName: "features/admin/user_index" */'../../admin/user-index');
|
||||
}
|
||||
|
||||
export function FederationRestrictions() {
|
||||
return import(/* webpackChunkName: "features/federation_restrictions" */'../../federation-restrictions');
|
||||
}
|
||||
|
||||
export function Aliases() {
|
||||
return import(/* webpackChunkName: "features/aliases" */'../../aliases');
|
||||
}
|
||||
|
||||
export function Migration() {
|
||||
return import(/* webpackChunkName: "features/migration" */'../../migration');
|
||||
}
|
||||
|
||||
export function ScheduleForm() {
|
||||
return import(/* webpackChunkName: "features/compose" */'../../compose/components/schedule-form');
|
||||
}
|
||||
|
||||
export function WhoToFollowPanel() {
|
||||
return import(/* webpackChunkName: "features/follow_recommendations" */'../components/who-to-follow-panel');
|
||||
}
|
||||
|
||||
export function FollowRecommendations() {
|
||||
return import(/* webpackChunkName: "features/follow-recommendations" */'../../follow-recommendations');
|
||||
}
|
||||
|
||||
export function Directory() {
|
||||
return import(/* webpackChunkName: "features/directory" */'../../directory');
|
||||
}
|
||||
|
||||
export function RegisterInvite() {
|
||||
return import(/* webpackChunkName: "features/register_invite" */'../../register-invite');
|
||||
}
|
||||
|
||||
export function Share() {
|
||||
return import(/* webpackChunkName: "features/share" */'../../share');
|
||||
}
|
||||
|
||||
export function NewStatus() {
|
||||
return import(/* webpackChunkName: "features/new_status" */'../../new-status');
|
||||
}
|
||||
|
||||
export function IntentionalError() {
|
||||
return import(/* webpackChunkName: "error" */'../../intentional-error');
|
||||
}
|
||||
|
||||
export function Developers() {
|
||||
return import(/* webpackChunkName: "features/developers" */'../../developers');
|
||||
}
|
||||
|
||||
export function CreateApp() {
|
||||
return import(/* webpackChunkName: "features/developers" */'../../developers/apps/create');
|
||||
}
|
||||
|
||||
export function SettingsStore() {
|
||||
return import(/* webpackChunkName: "features/developers" */'../../developers/settings-store');
|
||||
}
|
||||
|
||||
export function TestTimeline() {
|
||||
return import(/* webpackChunkName: "features/test_timeline" */'../../test-timeline');
|
||||
}
|
||||
|
||||
export function ServiceWorkerInfo() {
|
||||
return import(/* webpackChunkName: "features/developers" */'../../developers/service-worker-info');
|
||||
}
|
||||
|
||||
export function DatePicker() {
|
||||
return import(/* webpackChunkName: "date_picker" */'../../birthdays/date-picker');
|
||||
}
|
||||
|
||||
export function OnboardingWizard() {
|
||||
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding/onboarding-wizard');
|
||||
}
|
||||
|
||||
export function WaitlistPage() {
|
||||
return import(/* webpackChunkName: "features/verification" */'../../verification/waitlist-page');
|
||||
}
|
||||
|
||||
export function CompareHistoryModal() {
|
||||
return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/modals/compare-history-modal');
|
||||
}
|
||||
|
||||
export function AuthTokenList() {
|
||||
return import(/* webpackChunkName: "features/auth_token_list" */'../../auth-token-list');
|
||||
}
|
||||
|
||||
export function VerifySmsModal() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/modals/verify-sms-modal');
|
||||
}
|
||||
|
||||
export function FamiliarFollowersModal() {
|
||||
return import(/*webpackChunkName: "modals/familiar_followers_modal" */'../components/modals/familiar-followers-modal');
|
||||
}
|
||||
|
||||
export function AnnouncementsPanel() {
|
||||
return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
|
||||
}
|
||||
|
||||
export function Quotes() {
|
||||
return import(/*webpackChunkName: "features/quotes" */'../../quotes');
|
||||
}
|
||||
|
||||
export function ComposeEventModal() {
|
||||
return import(/* webpackChunkName: "features/compose_event_modal" */'../components/modals/compose-event-modal/compose-event-modal');
|
||||
}
|
||||
|
||||
export function JoinEventModal() {
|
||||
return import(/* webpackChunkName: "features/join_event_modal" */'../components/modals/join-event-modal');
|
||||
}
|
||||
|
||||
export function EventHeader() {
|
||||
return import(/* webpackChunkName: "features/event" */'../../event/components/event-header');
|
||||
}
|
||||
|
||||
export function EventInformation() {
|
||||
return import(/* webpackChunkName: "features/event" */'../../event/event-information');
|
||||
}
|
||||
|
||||
export function EventDiscussion() {
|
||||
return import(/* webpackChunkName: "features/event" */'../../event/event-discussion');
|
||||
}
|
||||
|
||||
export function EventMapModal() {
|
||||
return import(/* webpackChunkName: "modals/event-map-modal" */'../components/modals/event-map-modal');
|
||||
}
|
||||
|
||||
export function EventParticipantsModal() {
|
||||
return import(/* webpackChunkName: "modals/event-participants-modal" */'../components/modals/event-participants-modal');
|
||||
}
|
||||
|
||||
export function Events() {
|
||||
return import(/* webpackChunkName: "features/events" */'../../events');
|
||||
}
|
||||
|
||||
export function Groups() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../groups');
|
||||
}
|
||||
|
||||
export function GroupsDiscover() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../groups/discover');
|
||||
}
|
||||
|
||||
export function GroupsPopular() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../groups/popular');
|
||||
}
|
||||
|
||||
export function GroupsSuggested() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../groups/suggested');
|
||||
}
|
||||
|
||||
export function GroupsTag() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../groups/tag');
|
||||
}
|
||||
|
||||
export function GroupsTags() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../groups/tags');
|
||||
}
|
||||
|
||||
export function PendingGroupRequests() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../groups/pending-requests');
|
||||
}
|
||||
|
||||
export function GroupMembers() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
||||
}
|
||||
|
||||
export function GroupTags() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-tags');
|
||||
}
|
||||
|
||||
export function GroupTagTimeline() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-tag-timeline');
|
||||
}
|
||||
|
||||
export function GroupTimeline() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline');
|
||||
}
|
||||
|
||||
export function ManageGroup() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/manage-group');
|
||||
}
|
||||
|
||||
export function EditGroup() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/edit-group');
|
||||
}
|
||||
|
||||
export function GroupBlockedMembers() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-blocked-members');
|
||||
}
|
||||
|
||||
export function GroupMembershipRequests() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-membership-requests');
|
||||
}
|
||||
|
||||
export function GroupGallery() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-gallery');
|
||||
}
|
||||
|
||||
export function CreateGroupModal() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/modals/manage-group-modal/create-group-modal');
|
||||
}
|
||||
|
||||
export function NewGroupPanel() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel');
|
||||
}
|
||||
|
||||
export function MyGroupsPanel() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/panels/my-groups-panel');
|
||||
}
|
||||
|
||||
export function SuggestedGroupsPanel() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/panels/suggested-groups-panel');
|
||||
}
|
||||
|
||||
export function GroupMediaPanel() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/group-media-panel');
|
||||
}
|
||||
|
||||
export function NewEventPanel() {
|
||||
return import(/* webpackChunkName: "features/events" */'../components/panels/new-event-panel');
|
||||
}
|
||||
|
||||
export function Announcements() {
|
||||
return import(/* webpackChunkName: "features/admin/announcements" */'../../admin/announcements');
|
||||
}
|
||||
|
||||
export function EditAnnouncementModal() {
|
||||
return import(/* webpackChunkName: "features/admin/announcements" */'../components/modals/edit-announcement-modal');
|
||||
}
|
||||
|
||||
export function FollowedTags() {
|
||||
return import(/* webpackChunkName: "features/followed-tags" */'../../followed-tags');
|
||||
}
|
||||
|
||||
export function ComposeEditor() {
|
||||
return import(/* webpackChunkName: "lexical" */'../../compose/editor');
|
||||
}
|
|
@ -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,43 +0,0 @@
|
|||
import { Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import {
|
||||
ACCOUNT_NOTE_INIT_MODAL,
|
||||
ACCOUNT_NOTE_CHANGE_COMMENT,
|
||||
ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||
ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||
ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
} from '../actions/account-notes';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
|
||||
export const EditRecord = ImmutableRecord({
|
||||
isSubmitting: false,
|
||||
account: null as string | null,
|
||||
comment: '',
|
||||
});
|
||||
|
||||
export const ReducerRecord = ImmutableRecord({
|
||||
edit: EditRecord(),
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
export default function account_notes(state: State = ReducerRecord(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case ACCOUNT_NOTE_INIT_MODAL:
|
||||
return state.withMutations((state) => {
|
||||
state.setIn(['edit', 'isSubmitting'], false);
|
||||
state.setIn(['edit', 'account'], action.account.get('id'));
|
||||
state.setIn(['edit', 'comment'], action.comment);
|
||||
});
|
||||
case ACCOUNT_NOTE_CHANGE_COMMENT:
|
||||
return state.setIn(['edit', 'comment'], action.comment);
|
||||
case ACCOUNT_NOTE_SUBMIT_REQUEST:
|
||||
return state.setIn(['edit', 'isSubmitting'], true);
|
||||
case ACCOUNT_NOTE_SUBMIT_FAIL:
|
||||
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
|
||||
return state.setIn(['edit', 'isSubmitting'], false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -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 +0,0 @@
|
|||
import './web-push-notifications';
|
|
@ -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,28 +0,0 @@
|
|||
import type { MetaMaskInpageProvider } from '@metamask/providers';
|
||||
|
||||
export const ethereum: () => MetaMaskInpageProvider | undefined = () => (window as any).ethereum;
|
||||
|
||||
export const hasEthereum = () => Boolean(ethereum());
|
||||
|
||||
// Requests an Ethereum wallet from the browser
|
||||
// Returns a Promise containing the Ethereum wallet address (string).
|
||||
export const getWallet: () => Promise<string> = () => {
|
||||
return ethereum()!.request({ method: 'eth_requestAccounts' })
|
||||
.then((wallets) => (wallets as Array<string>)[0]);
|
||||
};
|
||||
|
||||
// Asks the browser to sign a message with Ethereum.
|
||||
// Returns a Promise containing the signature (string).
|
||||
export const signMessage = (wallet: string, message: string) => {
|
||||
return ethereum()!.request({ method: 'personal_sign', params: [message, wallet] });
|
||||
};
|
||||
|
||||
// Combines the above functions.
|
||||
// Returns an object with the `wallet` and `signature`
|
||||
export const getWalletAndSign = (message: string) => {
|
||||
return getWallet().then(wallet => {
|
||||
return signMessage(wallet, message).then(signature => {
|
||||
return { wallet, signature };
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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-->
|
||||
<%= snippets %>
|
||||
<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";
|
||||
|
@ -83,13 +83,13 @@ server {
|
|||
|
||||
# Mastodon backend routes.
|
||||
# These are routes to Mastodon's API and important rendered pages.
|
||||
location ~ ^/(api|oauth|auth|admin|pghero|sidekiq|manifest.json|media|nodeinfo|unsubscribe|.well-known/(webfinger|host-meta|nodeinfo|change-password)|@(.+)/embed$) {
|
||||
location ~ ^/(api|inbox|oauth|auth|admin|pghero|sidekiq|manifest.json|media|nodeinfo|unsubscribe|.well-known/(webfinger|host-meta|nodeinfo|change-password)|@(.+)/embed$) {
|
||||
try_files /dev/null @mastodon;
|
||||
}
|
||||
|
||||
# Mastodon ActivityPub routes.
|
||||
# Conditionally send to Mastodon by Accept header.
|
||||
location ~ ^/(inbox|users|@(.+)) {
|
||||
location ~ ^/(users|@(.+)) {
|
||||
try_files /dev/null $activitypub_location;
|
||||
}
|
||||
|
||||
|
@ -101,6 +101,13 @@ server {
|
|||
try_files $uri @mastodon-packs;
|
||||
}
|
||||
|
||||
# Mastodon Media
|
||||
location /system {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
try_files $uri @mastodon-packs;
|
||||
}
|
||||
|
||||
# Soapbox configuration files.
|
||||
# Enable CORS so we can fetch them.
|
||||
location /instance {
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
const ASSET_EXTS = 'css|styl|less|sass|scss|png|jpg|svg|ogg|oga|mp3|ttf|woff|woff2';
|
||||
|
||||
module.exports = {
|
||||
'testPathIgnorePatterns': [
|
||||
'<rootDir>/node_modules/',
|
||||
'<rootDir>/vendor/',
|
||||
'<rootDir>/config/',
|
||||
'<rootDir>/log/',
|
||||
'<rootDir>/static/',
|
||||
'<rootDir>/tmp/',
|
||||
'<rootDir>/webpack/',
|
||||
'<rootDir>/app/soapbox/actions/',
|
||||
],
|
||||
'setupFiles': [
|
||||
'raf/polyfill',
|
||||
],
|
||||
'setupFilesAfterEnv': [
|
||||
'<rootDir>/app/soapbox/jest/test-setup.ts',
|
||||
],
|
||||
'collectCoverageFrom': [
|
||||
'app/soapbox/**/*.js',
|
||||
'app/soapbox/**/*.cjs',
|
||||
'app/soapbox/**/*.mjs',
|
||||
'app/soapbox/**/*.ts',
|
||||
'app/soapbox/**/*.tsx',
|
||||
'!app/soapbox/service-worker/entry.ts',
|
||||
'!app/soapbox/jest/test-setup.ts',
|
||||
'!app/soapbox/jest/test-helpers.ts',
|
||||
],
|
||||
'coverageDirectory': '<rootDir>/.coverage/',
|
||||
'coverageReporters': ['html', 'text', 'text-summary', 'cobertura'],
|
||||
'reporters': ['default', 'jest-junit'],
|
||||
'moduleDirectories': [
|
||||
'<rootDir>/node_modules',
|
||||
'<rootDir>/app',
|
||||
],
|
||||
'testMatch': ['**/*/__tests__/**/?(*.|*-)+(test).(ts|js)?(x)'],
|
||||
'testEnvironment': 'jsdom',
|
||||
'transformIgnorePatterns': [
|
||||
// FIXME: react-sticky-box doesn't provide a CJS build, so transform it for now
|
||||
// https://github.com/codecks-io/react-sticky-box/issues/79
|
||||
`/node_modules/(?!(react-sticky-box|blurhash|emoji-mart|.+\\.(${ASSET_EXTS})$))`,
|
||||
// Ignore node_modules, except static assets
|
||||
// `/node_modules/(?!.+\\.(${ASSET_EXTS})$)`,
|
||||
],
|
||||
'transform': {
|
||||
'\\.[jt]sx?$': 'babel-jest',
|
||||
[`\\.(${ASSET_EXTS})$`]: '<rootDir>/jest/assetTransformer.cjs',
|
||||
},
|
||||
};
|
|
@ -1,12 +0,0 @@
|
|||
const path = require('path');
|
||||
|
||||
// Custom Jest asset transformer
|
||||
// https://jestjs.io/docs/code-transformation#writing-custom-transformers
|
||||
// Tries to do basically what Webpack does
|
||||
module.exports = {
|
||||
process(src, filename, config, options) {
|
||||
return {
|
||||
code: `module.exports = "https://soapbox.test/assets/${path.basename(filename)}";`,
|
||||
};
|
||||
},
|
||||
};
|
141
package.json
141
package.json
|
@ -2,6 +2,7 @@
|
|||
"name": "soapbox",
|
||||
"displayName": "Soapbox",
|
||||
"version": "3.2.0",
|
||||
"type": "module",
|
||||
"description": "Soapbox frontend for the Fediverse.",
|
||||
"homepage": "https://soapbox.pub/",
|
||||
"repository": {
|
||||
|
@ -16,42 +17,39 @@
|
|||
"url": "https://gitlab.com/soapbox-pub/soapbox/-/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "npx webpack-dev-server",
|
||||
"start": "npx vite serve",
|
||||
"dev": "${npm_execpath} run start",
|
||||
"build": "npx webpack",
|
||||
"build": "npx vite build --emptyOutDir",
|
||||
"preview": "npx vite preview",
|
||||
"audit:fix": "npx yarn-audit-fix",
|
||||
"manage:translations": "npx ts-node ./scripts/translationRunner.ts",
|
||||
"i18n": "rm -rf build tmp && npx cross-env NODE_ENV=production ${npm_execpath} run build && ${npm_execpath} manage:translations en",
|
||||
"test": "npx cross-env NODE_ENV=test npx jest",
|
||||
"test": "npx vitest",
|
||||
"test:coverage": "${npm_execpath} run test --coverage",
|
||||
"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",
|
||||
"browserslist": [
|
||||
"> 0.2%",
|
||||
"> 0.5%",
|
||||
"last 2 versions",
|
||||
"last 4 years",
|
||||
"not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.20.12",
|
||||
"@babel/plugin-transform-react-inline-elements": "^7.18.6",
|
||||
"@babel/plugin-transform-runtime": "^7.19.6",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@akryum/flexsearch-es": "^0.7.32",
|
||||
"@babel/plugin-transform-react-inline-elements": "^7.22.5",
|
||||
"@babel/plugin-transform-runtime": "^7.22.15",
|
||||
"@babel/preset-env": "^7.22.15",
|
||||
"@babel/preset-react": "^7.22.15",
|
||||
"@babel/preset-typescript": "^7.22.15",
|
||||
"@emoji-mart/data": "^1.1.2",
|
||||
"@floating-ui/react": "^0.24.0",
|
||||
"@fontsource/inter": "^4.5.1",
|
||||
"@fontsource/roboto-mono": "^4.5.8",
|
||||
"@floating-ui/react": "^0.25.0",
|
||||
"@fontsource/inter": "^5.0.0",
|
||||
"@fontsource/roboto-mono": "^5.0.0",
|
||||
"@gamestdio/websocket": "^0.3.2",
|
||||
"@jest/globals": "^29.0.0",
|
||||
"@lcdp/offline-plugin": "^5.1.0",
|
||||
"@lexical/clipboard": "^0.11.3",
|
||||
"@lexical/code": "^0.11.3",
|
||||
"@lexical/hashtag": "^0.11.3",
|
||||
|
@ -63,7 +61,6 @@
|
|||
"@lexical/selection": "^0.11.3",
|
||||
"@lexical/table": "^0.11.3",
|
||||
"@lexical/utils": "^0.11.3",
|
||||
"@metamask/providers": "^10.0.0",
|
||||
"@mkljczk/lexical-remark": "^0.3.9",
|
||||
"@popperjs/core": "^2.11.5",
|
||||
"@reach/combobox": "^0.18.0",
|
||||
|
@ -76,16 +73,16 @@
|
|||
"@sentry/react": "^7.37.2",
|
||||
"@sentry/tracing": "^7.37.2",
|
||||
"@tabler/icons": "^2.0.0",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@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/jest": "^29.0.0",
|
||||
"@types/leaflet": "^1.8.0",
|
||||
"@types/lodash": "^4.14.180",
|
||||
"@types/object-assign": "^4.0.30",
|
||||
"@types/path-browserify": "^1.0.0",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-color": "^3.0.6",
|
||||
"@types/react-datepicker": "^4.4.2",
|
||||
|
@ -96,58 +93,45 @@
|
|||
"@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",
|
||||
"@types/webpack-assets-manifest": "^5.1.0",
|
||||
"@types/webpack-bundle-analyzer": "^4.6.0",
|
||||
"@types/webpack-deadcode-plugin": "^0.1.2",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"axios": "^1.2.2",
|
||||
"axios-mock-adapter": "^1.21.1",
|
||||
"babel-loader": "^9.0.0",
|
||||
"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",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"core-js": "^3.27.2",
|
||||
"cryptocurrency-icons": "^0.18.1",
|
||||
"css-loader": "^6.7.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",
|
||||
"flexsearch-ts": "^0.7.31",
|
||||
"fork-ts-checker-webpack-plugin": "^8.0.0",
|
||||
"exifr": "^7.1.3",
|
||||
"graphemesplit": "^2.4.4",
|
||||
"html-webpack-harddisk-plugin": "^2.0.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"http-link-header": "^1.0.2",
|
||||
"immer": "^9.0.19",
|
||||
"immer": "^10.0.0",
|
||||
"immutable": "^4.2.1",
|
||||
"imports-loader": "^4.0.0",
|
||||
"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",
|
||||
"lexical": "^0.11.3",
|
||||
"libphonenumber-js": "^1.10.8",
|
||||
"line-awesome": "^1.3.0",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.7.11",
|
||||
"mini-css-extract-plugin": "^2.6.0",
|
||||
"nostr-tools": "^1.8.1",
|
||||
"nostr-tools": "^1.14.2",
|
||||
"path-browserify": "^1.0.1",
|
||||
"postcss": "^8.4.14",
|
||||
"postcss-loader": "^7.0.0",
|
||||
"postcss": "^8.4.29",
|
||||
"process": "^0.11.10",
|
||||
"punycode": "^2.1.1",
|
||||
"qrcode.react": "^3.1.0",
|
||||
|
@ -159,10 +143,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",
|
||||
|
@ -180,69 +163,59 @@
|
|||
"redux-thunk": "^2.2.0",
|
||||
"reselect": "^4.0.0",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass": "^1.20.3",
|
||||
"sass-loader": "^13.0.0",
|
||||
"seedrandom": "^3.0.5",
|
||||
"sass": "^1.66.1",
|
||||
"semver": "^7.3.8",
|
||||
"stringz": "^2.0.0",
|
||||
"substring-trie": "^1.0.2",
|
||||
"terser-webpack-plugin": "^5.2.3",
|
||||
"tiny-queue": "^0.2.1",
|
||||
"ts-loader": "^9.3.0",
|
||||
"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",
|
||||
"webpack": "^5.72.1",
|
||||
"webpack-assets-manifest": "^5.1.0",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
"webpack-cli": "^5.0.0",
|
||||
"webpack-deadcode-plugin": "^0.1.16",
|
||||
"webpack-merge": "^5.8.0",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-compile-time": "^0.2.1",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"vite-plugin-require": "^1.1.10",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"wicg-inert": "^3.1.1",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.19.1",
|
||||
"@gitbeaker/node": "^35.8.0",
|
||||
"@jedmao/redux-mock-store": "^3.0.5",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@testing-library/jest-dom": "^5.16.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",
|
||||
"@typescript-eslint/eslint-plugin": "^5.15.0",
|
||||
"@typescript-eslint/parser": "^5.15.0",
|
||||
"babel-jest": "^29.4.1",
|
||||
"@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.0.0",
|
||||
"eslint-plugin-compat": "^4.0.2",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-jsdoc": "^46.0.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"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",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-promise": "^6.0.0",
|
||||
"eslint-plugin-react": "^7.25.1",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"eslint-plugin-tailwindcss": "^3.10.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-tailwindcss": "^3.13.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"husky": "^8.0.0",
|
||||
"jest": "^29.0.0",
|
||||
"jest-environment-jsdom": "^29.0.0",
|
||||
"jest-junit": "^15.0.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"lint-staged": ">=10",
|
||||
"raf": "^3.4.1",
|
||||
"react-intl-translations-manager": "^5.0.3",
|
||||
"react-refresh": "^0.14.0",
|
||||
"stylelint": "^14.0.0",
|
||||
"stylelint-config-standard-scss": "^6.1.0",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"ts-jest": "^29.0.0",
|
||||
"webpack-dev-server": "^4.9.1",
|
||||
"rollup-plugin-visualizer": "^5.9.2",
|
||||
"stylelint": "^15.10.3",
|
||||
"stylelint-config-standard-scss": "^11.0.0",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"vite-plugin-pwa": "^0.16.5",
|
||||
"vitest": "^0.34.4",
|
||||
"yargs": "^17.6.2"
|
||||
},
|
||||
"resolutions": {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue