diff --git a/.dockerignore b/.dockerignore index 41b87855a..2d53bc7e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,10 +12,11 @@ yarn-error.log* /junit.xml +/dist/ /static/ -/static-test/ /public/ /dist/ +/soapbox.zip .idea .DS_Store diff --git a/.eslintignore b/.eslintignore index 1ab6f8d8c..dc7fe3106 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,6 +1,6 @@ /node_modules/** +/dist/** /static/** -/static-test/** /public/** /tmp/** /coverage/** diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 786d52d13..0105144fb 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -19,8 +19,6 @@ module.exports = { ATTACHMENT_HOST: false, }, - parser: '@babel/eslint-parser', - plugins: [ 'react', 'jsdoc', @@ -50,9 +48,8 @@ module.exports = { '\\.(css|scss|json)$', ], 'import/resolver': { - node: { - paths: ['app'], - }, + typescript: true, + node: true, }, polyfills: [ 'es:all', // core-js @@ -79,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'], @@ -260,7 +258,6 @@ module.exports = { alphabetize: { order: 'asc' }, }, ], - '@typescript-eslint/no-duplicate-imports': 'error', '@typescript-eslint/member-delimiter-style': [ 'error', { @@ -298,7 +295,7 @@ module.exports = { { // Only enforce JSDoc comments on UI components for now. // https://www.npmjs.com/package/eslint-plugin-jsdoc - files: ['app/soapbox/components/ui/**/*'], + files: ['src/components/ui/**/*'], rules: { 'jsdoc/require-jsdoc': ['error', { publicOnly: true, diff --git a/.gitignore b/.gitignore index 92e9362d8..176314352 100644 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,14 @@ /.vs/ yarn-error.log* /junit.xml +*.timestamp-* +*.bundled_* +/dist/ /static/ -/static-test/ /public/ /dist/ +/soapbox.zip .idea .DS_Store diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 97892e166..defe89e3b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,7 @@ image: node:20 variables: NODE_ENV: test + DS_EXCLUDED_ANALYZERS: gemnasium-python default: interruptible: true @@ -30,20 +31,9 @@ deps: <<: *cache policy: push -danger: +lint: stage: test - script: - # https://github.com/danger/danger-js/issues/1029#issuecomment-998915436 - - export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!} - - npx danger ci - except: - variables: - - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME - allow_failure: true - -lint-js: - stage: test - script: yarn lint:js + script: yarn lint only: changes: - "**/*.js" @@ -52,63 +42,25 @@ lint-js: - "**/*.mjs" - "**/*.ts" - "**/*.tsx" - - ".eslintignore" - - ".eslintrc.cjs" - -lint-sass: - stage: test - script: yarn lint:sass - only: - changes: - "**/*.scss" - "**/*.css" + - ".eslintignore" + - ".eslintrc.cjs" - ".stylelintrc.json" -jest: +build: stage: test - script: yarn test:coverage --runInBand - only: - changes: - - "**/*.js" - - "**/*.json" - - "app/soapbox/**/*" - - "webpack/**/*" - - "custom/**/*" - - "jest.config.cjs" - - "package.json" - - "yarn.lock" - - ".gitlab-ci.yml" - coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/ - artifacts: - reports: - junit: junit.xml - coverage_report: - coverage_format: cobertura - path: .coverage/cobertura-coverage.xml - -nginx-test: - stage: test - image: nginx:latest before_script: - - cp installation/mastodon.conf /etc/nginx/conf.d/default.conf - script: nginx -t - only: - changes: - - "installation/mastodon.conf" - -build-production: - stage: test + - apt-get update -y && apt-get install -y zip script: - yarn build - - yarn manage:translations en - # Fail if files got changed. - # https://stackoverflow.com/a/9066385 - - git diff --quiet + - cp dist/index.html dist/404.html + - cd dist && zip -r ../soapbox.zip . && cd .. variables: NODE_ENV: production artifacts: paths: - - static + - soapbox.zip docs-deploy: stage: deploy @@ -128,16 +80,20 @@ review: environment: name: review/$CI_COMMIT_REF_NAME url: https://$CI_COMMIT_REF_SLUG.git.soapbox.pub + before_script: + - apt-get update -y && apt-get install -y unzip script: - - npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub + - unzip soapbox.zip -d dist + - npx -y surge dist $CI_COMMIT_REF_SLUG.git.soapbox.pub allow_failure: true pages: stage: deploy - before_script: [] + before_script: + - apt-get update -y && apt-get install -y unzip script: # artifacts are kept between jobs - - mv static public + - unzip soapbox.zip -d public variables: NODE_ENV: production artifacts: @@ -149,9 +105,9 @@ pages: docker: stage: deploy - image: docker:23.0.0 + image: docker:24.0.6 services: - - docker:23.0.0-dind + - docker:24.0.6-dind tags: - dind # https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df @@ -173,4 +129,3 @@ release: include: - template: Jobs/Dependency-Scanning.gitlab-ci.yml - - template: Security/License-Scanning.gitlab-ci.yml \ No newline at end of file diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 97bad7f28..fc508d7e3 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -4,5 +4,5 @@ "*.mjs": "eslint --cache", "*.ts": "eslint --cache", "*.tsx": "eslint --cache", - "app/styles/**/*.scss": "stylelint" + "src/styles/**/*.scss": "stylelint" } diff --git a/.storybook/main.ts b/.storybook/main.ts deleted file mode 100644 index bb4c1d232..000000000 --- a/.storybook/main.ts +++ /dev/null @@ -1,43 +0,0 @@ -import sharedConfig from '../webpack/shared'; - -import type { StorybookConfig } from '@storybook/core-common'; - -const config: StorybookConfig = { - stories: [ - '../stories/**/*.stories.mdx', - '../stories/**/*.stories.@(js|jsx|ts|tsx)' - ], - addons: [ - '@storybook/addon-links', - '@storybook/addon-essentials', - '@storybook/addon-interactions', - 'storybook-react-intl', - { - name: '@storybook/addon-postcss', - options: { - postcssLoaderOptions: { - implementation: require('postcss'), - }, - }, - }, - ], - framework: '@storybook/react', - core: { - builder: '@storybook/builder-webpack5', - }, - webpackFinal: async (config) => { - config.resolve!.alias = { - ...sharedConfig.resolve!.alias, - ...config.resolve!.alias, - }; - - config.resolve!.modules = [ - ...sharedConfig.resolve!.modules!, - ...config.resolve!.modules!, - ]; - - return config; - }, -}; - -export default config; \ No newline at end of file diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx deleted file mode 100644 index df2195f0c..000000000 --- a/.storybook/preview.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import '../app/styles/tailwind.css'; -import '../stories/theme.css'; - -import { addDecorator, Story } from '@storybook/react'; -import { IntlProvider } from 'react-intl'; -import React from 'react'; - -const withProvider = (Story: Story) => ( - -); - -addDecorator(withProvider); - -export const parameters = { - actions: { argTypesRegex: '^on[A-Z].*' }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, - }, - }, -}; diff --git a/.stylelintrc.json b/.stylelintrc.json index 1a610d5eb..d01dd67b6 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -16,7 +16,6 @@ "no-invalid-position-at-import-rule": null, "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["tailwind", "apply", "layer", "config"]}], "scss/operator-no-unspaced": null, - "selector-class-pattern": null, - "string-quotes": "single" + "selector-class-pattern": null } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ca09934..baaf46aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reactions: Support custom emoji reactions - Compatbility: Support Mastodon v2 timeline filters. - Compatbility: Preliminary support for Ditto backend. +- Compatibility: Support Firefish. - Posts: Support dislikes on Friendica. - UI: added a character counter to some textareas. - UI: added new experience for viewing Media +- Hotkeys: Added `/` as a hotkey for search field. ### Changed - Posts: truncate Nostr pubkeys in reply mentions. @@ -23,10 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - UI: unified design of "approve" and "reject" buttons in follow requests and waitlist. - UI: added sticky column header. - UI: add specific zones the user can drag-and-drop files. +- UI: disable toast notifications for API errors. +- Chats: Display year for older messages creation date. ### Fixed - Posts: fixed emojis being cut off in reactions modal. - Posts: fix audio player progress bar visibility. +- Posts: fix audio player avatar aspect ratio for non-square avatars. - Posts: added missing gap in pending status. - Compatibility: fixed quote posting compatibility with custom Pleroma forks. - Profile: fix "load more" button height on account gallery page. @@ -36,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - UI: fixed various overflow issues related to long usernames. - UI: fixed display of Markdown code blocks in the reply indicator. - Auth: fixed too many API requests when the server has an error. +- Auth: Don't display "username or e-mail" if username is not allowed. ## [3.2.0] - 2023-02-15 diff --git a/Dockerfile b/Dockerfile index b02bf86e1..2765f2053 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,4 @@ ENV FALLBACK_PORT=4444 ENV BACKEND_URL=http://localhost:4444 ENV CSP= COPY installation/docker.conf.template /etc/nginx/templates/default.conf.template -COPY --from=build /app/static /usr/share/nginx/html +COPY --from=build /app/dist /usr/share/nginx/html diff --git a/app/soapbox/actions/__tests__/account-notes.test.ts b/app/soapbox/actions/__tests__/account-notes.test.ts deleted file mode 100644 index fdae30838..000000000 --- a/app/soapbox/actions/__tests__/account-notes.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; - -import { __stub } from 'soapbox/api'; -import { buildAccount, buildRelationship } from 'soapbox/jest/factory'; -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; -import { ReducerRecord, EditRecord } from 'soapbox/reducers/account-notes'; - -import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes'; - -describe('submitAccountNote()', () => { - let store: ReturnType; - - beforeEach(() => { - const state = rootState - .set('account_notes', ReducerRecord({ edit: EditRecord({ account: '1', comment: 'hello' }) })); - store = mockStore(state); - }); - - describe('with a successful API request', () => { - beforeEach(() => { - __stub((mock) => { - mock.onPost('/api/v1/accounts/1/note').reply(200, {}); - }); - }); - - it('post the note to the API', async() => { - const expectedActions = [ - { type: 'ACCOUNT_NOTE_SUBMIT_REQUEST' }, - { type: 'MODAL_CLOSE', modalType: undefined }, - { type: 'ACCOUNT_NOTE_SUBMIT_SUCCESS', relationship: {} }, - ]; - await store.dispatch(submitAccountNote()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with an unsuccessful API request', () => { - beforeEach(() => { - __stub((mock) => { - mock.onPost('/api/v1/accounts/1/note').networkError(); - }); - }); - - it('should dispatch failed action', async() => { - const expectedActions = [ - { type: 'ACCOUNT_NOTE_SUBMIT_REQUEST' }, - { - type: 'ACCOUNT_NOTE_SUBMIT_FAIL', - error: new Error('Network Error'), - }, - ]; - await store.dispatch(submitAccountNote()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); - -describe('initAccountNoteModal()', () => { - let store: ReturnType; - - beforeEach(() => { - const state = rootState - .set('relationships', ImmutableMap({ '1': buildRelationship({ note: 'hello' }) })); - store = mockStore(state); - }); - - it('dispatches the proper actions', async() => { - const account = buildAccount({ - id: '1', - acct: 'justin-username', - display_name: 'Justin L', - avatar: 'test.jpg', - verified: true, - }); - const expectedActions = [ - { type: 'ACCOUNT_NOTE_INIT_MODAL', account, comment: 'hello' }, - { type: 'MODAL_CLOSE', modalType: 'ACCOUNT_NOTE' }, - { type: 'MODAL_OPEN', modalType: 'ACCOUNT_NOTE' }, - ]; - await store.dispatch(initAccountNoteModal(account)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); - -describe('changeAccountNoteComment()', () => { - let store: ReturnType; - - beforeEach(() => { - const state = rootState; - store = mockStore(state); - }); - - it('dispatches the proper actions', async() => { - const comment = 'hello world'; - const expectedActions = [ - { type: 'ACCOUNT_NOTE_CHANGE_COMMENT', comment }, - ]; - await store.dispatch(changeAccountNoteComment(comment)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); diff --git a/app/soapbox/actions/account-notes.ts b/app/soapbox/actions/account-notes.ts deleted file mode 100644 index 691f63fc3..000000000 --- a/app/soapbox/actions/account-notes.ts +++ /dev/null @@ -1,82 +0,0 @@ -import api from '../api'; - -import { openModal, closeModal } from './modals'; - -import type { AxiosError } from 'axios'; -import type { AnyAction } from 'redux'; -import type { Account } from 'soapbox/schemas'; -import type { AppDispatch, RootState } from 'soapbox/store'; - -const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; -const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; -const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; - -const ACCOUNT_NOTE_INIT_MODAL = 'ACCOUNT_NOTE_INIT_MODAL'; - -const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT'; - -const submitAccountNote = () => (dispatch: React.Dispatch, getState: () => RootState) => { - dispatch(submitAccountNoteRequest()); - - const id = getState().account_notes.edit.account; - - return api(getState) - .post(`/api/v1/accounts/${id}/note`, { - comment: getState().account_notes.edit.comment, - }) - .then(response => { - dispatch(closeModal()); - dispatch(submitAccountNoteSuccess(response.data)); - }) - .catch(error => dispatch(submitAccountNoteFail(error))); -}; - -function submitAccountNoteRequest() { - return { - type: ACCOUNT_NOTE_SUBMIT_REQUEST, - }; -} - -function submitAccountNoteSuccess(relationship: any) { - return { - type: ACCOUNT_NOTE_SUBMIT_SUCCESS, - relationship, - }; -} - -function submitAccountNoteFail(error: AxiosError) { - return { - type: ACCOUNT_NOTE_SUBMIT_FAIL, - error, - }; -} - -const initAccountNoteModal = (account: Account) => (dispatch: AppDispatch, getState: () => RootState) => { - const comment = getState().relationships.get(account.id)!.note; - - dispatch({ - type: ACCOUNT_NOTE_INIT_MODAL, - account, - comment, - }); - - dispatch(openModal('ACCOUNT_NOTE')); -}; - -function changeAccountNoteComment(comment: string) { - return { - type: ACCOUNT_NOTE_CHANGE_COMMENT, - comment, - }; -} - -export { - submitAccountNote, - initAccountNoteModal, - changeAccountNoteComment, - ACCOUNT_NOTE_SUBMIT_REQUEST, - ACCOUNT_NOTE_SUBMIT_SUCCESS, - ACCOUNT_NOTE_SUBMIT_FAIL, - ACCOUNT_NOTE_INIT_MODAL, - ACCOUNT_NOTE_CHANGE_COMMENT, -}; diff --git a/app/soapbox/build-config.js b/app/soapbox/build-config.js deleted file mode 100644 index a11faa0e8..000000000 --- a/app/soapbox/build-config.js +++ /dev/null @@ -1,46 +0,0 @@ -// @preval -/** - * Build config: configuration set at build time. - * @module soapbox/build-config - */ - -const trim = require('lodash/trim'); -const trimEnd = require('lodash/trimEnd'); - -const { - NODE_ENV, - BACKEND_URL, - FE_SUBDIRECTORY, - FE_BUILD_DIR, - FE_INSTANCE_SOURCE_DIR, - SENTRY_DSN, -} = process.env; - -const sanitizeURL = url => { - try { - return trimEnd(new URL(url).toString(), '/'); - } catch { - return ''; - } -}; - -const sanitizeBasename = path => { - return `/${trim(path, '/')}`; -}; - -const sanitizePath = path => { - return trim(path, '/'); -}; - -// JSON.parse/stringify is to emulate what @preval is doing and avoid any -// inconsistent behavior in dev mode -const sanitize = obj => JSON.parse(JSON.stringify(obj)); - -module.exports = sanitize({ - NODE_ENV: NODE_ENV || 'development', - BACKEND_URL: sanitizeURL(BACKEND_URL), - FE_SUBDIRECTORY: sanitizeBasename(FE_SUBDIRECTORY), - FE_BUILD_DIR: sanitizePath(FE_BUILD_DIR) || 'static', - FE_INSTANCE_SOURCE_DIR: FE_INSTANCE_SOURCE_DIR || 'instance', - SENTRY_DSN, -}); diff --git a/app/soapbox/features/compose/containers/warning-container.tsx b/app/soapbox/features/compose/containers/warning-container.tsx deleted file mode 100644 index fd073cda8..000000000 --- a/app/soapbox/features/compose/containers/warning-container.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import { FormattedMessage } from 'react-intl'; -import { Link } from 'react-router-dom'; - -import { useAppSelector, useCompose } from 'soapbox/hooks'; - -import Warning from '../components/warning'; - -const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i; - -interface IWarningWrapper { - composeId: string -} - -const WarningWrapper: React.FC = ({ composeId }) => { - const compose = useCompose(composeId); - - const me = useAppSelector((state) => state.me); - - const needsLockWarning = useAppSelector((state) => compose.privacy === 'private' && !state.accounts.get(me)!.locked); - const hashtagWarning = (compose.privacy !== 'public' && compose.privacy !== 'group') && APPROX_HASHTAG_RE.test(compose.text); - const directMessageWarning = compose.privacy === 'direct'; - - if (needsLockWarning) { - return }} />} />; - } - - if (hashtagWarning) { - return } />; - } - - if (directMessageWarning) { - const message = ( - - - {/* */} - - ); - - return ; - } - - return null; -}; - -export default WarningWrapper; diff --git a/app/soapbox/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx deleted file mode 100644 index bf906ce01..000000000 --- a/app/soapbox/features/hashtag-timeline/index.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; - -import { connectHashtagStream } from 'soapbox/actions/streaming'; -import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags'; -import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines'; -import List, { ListItem } from 'soapbox/components/list'; -import { Column, Toggle } from 'soapbox/components/ui'; -import Timeline from 'soapbox/features/ui/components/timeline'; -import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; - -import type { Tag as TagEntity } from 'soapbox/types/entities'; - -type Mode = 'any' | 'all' | 'none'; - -type Tag = { value: string }; -type Tags = { [k in Mode]: Tag[] }; - -const messages = defineMessages({ - any: { id: 'hashtag.column_header.tag_mode.any', defaultMessage: 'or {additional}' }, - all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {additional}' }, - none: { id: 'hashtag.column_header.tag_mode.none', defaultMessage: 'without {additional}' }, - empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' }, -}); - -interface IHashtagTimeline { - params?: { - id?: string - tags?: Tags - } -} - -export const HashtagTimeline: React.FC = ({ params }) => { - const intl = useIntl(); - const id = params?.id || ''; - const tags = params?.tags || { any: [], all: [], none: [] }; - - const features = useFeatures(); - const dispatch = useAppDispatch(); - const disconnects = useRef<(() => void)[]>([]); - const tag = useAppSelector((state) => state.tags.get(id)); - const next = useAppSelector(state => state.timelines.get(`hashtag:${id}`)?.next); - - // Mastodon supports displaying results from multiple hashtags. - // https://github.com/mastodon/mastodon/issues/6359 - const title = (): string => { - const title: string[] = [`#${id}`]; - - if (additionalFor('any')) { - title.push(' ', intl.formatMessage(messages.any, { additional: additionalFor('any') })); - } - - if (additionalFor('all')) { - title.push(' ', intl.formatMessage(messages.any, { additional: additionalFor('all') })); - } - - if (additionalFor('none')) { - title.push(' ', intl.formatMessage(messages.any, { additional: additionalFor('none') })); - } - - return title.join(''); - }; - - const additionalFor = (mode: Mode) => { - if (tags && (tags[mode] || []).length > 0) { - return tags[mode].map(tag => tag.value).join('/'); - } else { - return ''; - } - }; - - const subscribe = () => { - const any = tags.any.map(tag => tag.value); - const all = tags.all.map(tag => tag.value); - const none = tags.none.map(tag => tag.value); - - [id, ...any].map(tag => { - disconnects.current.push(dispatch(connectHashtagStream(id, tag, status => { - const tags = status.tags.map((tag: TagEntity) => tag.name); - - return all.filter(tag => tags.includes(tag)).length === all.length && - none.filter(tag => tags.includes(tag)).length === 0; - }))); - }); - }; - - const unsubscribe = () => { - disconnects.current.map(disconnect => disconnect()); - disconnects.current = []; - }; - - const handleLoadMore = (maxId: string) => { - dispatch(expandHashtagTimeline(id, { url: next, maxId, tags })); - }; - - const handleFollow = () => { - if (tag?.following) { - dispatch(unfollowHashtag(id)); - } else { - dispatch(followHashtag(id)); - } - }; - - useEffect(() => { - subscribe(); - dispatch(expandHashtagTimeline(id, { tags })); - dispatch(fetchHashtag(id)); - - return () => { - unsubscribe(); - }; - }, []); - - useEffect(() => { - unsubscribe(); - subscribe(); - dispatch(clearTimeline(`hashtag:${id}`)); - dispatch(expandHashtagTimeline(id, { tags })); - }, [id]); - - return ( - - {features.followHashtags && ( - - } - > - - - - )} - - - ); -}; - -export default HashtagTimeline; diff --git a/app/soapbox/features/ui/__tests__/index.test.tsx b/app/soapbox/features/ui/__tests__/index.test.tsx deleted file mode 100644 index 6fb4b1e83..000000000 --- a/app/soapbox/features/ui/__tests__/index.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react'; -import { Route, Switch } from 'react-router-dom'; - -import { buildAccount } from 'soapbox/jest/factory'; - -import { render, screen, waitFor } from '../../../jest/test-helpers'; -import { normalizeInstance } from '../../../normalizers'; -import UI from '../index'; -import { WrappedRoute } from '../util/react-router-helpers'; - -const TestableComponent = () => ( - - - - - Sign in - - {/* WrappedRount will redirect to /login for logged out users... which will resolve to the route above! */} - null} /> - -); - -describe('', () => { - let store: any; - - beforeEach(() => { - store = { - me: false, - accounts: { - '1': buildAccount({ - id: '1', - acct: 'username', - display_name: 'My name', - avatar: 'test.jpg', - }), - }, - instance: normalizeInstance({ registrations: true }), - }; - }); - - describe('when logged out', () => { - describe('when viewing a Profile Page', () => { - it('should render the Profile page', async() => { - render( - , - {}, - store, - { initialEntries: ['/@username'] }, - ); - - await waitFor(() => { - expect(screen.getByTestId('cta-banner')).toHaveTextContent('Sign up now to discuss'); - }, { - timeout: 5000, - }); - }); - }); - - describe('when viewing a Status Page', () => { - it('should render the Status page', async() => { - render( - , - {}, - store, - { initialEntries: ['/@username/posts/12'] }, - ); - - await waitFor(() => { - expect(screen.getByTestId('cta-banner')).toHaveTextContent('Sign up now to discuss'); - }); - }); - }); - - describe('when viewing Notifications', () => { - it('should redirect to the login page', async() => { - render( - , - {}, - store, - { initialEntries: ['/notifications'] }, - ); - - await waitFor(() => { - expect(screen.getByTestId('sign-in')).toHaveTextContent('Sign in'); - }); - }); - }); - }); -}); diff --git a/app/soapbox/features/ui/components/modals/account-note-modal.tsx b/app/soapbox/features/ui/components/modals/account-note-modal.tsx deleted file mode 100644 index 95afc614e..000000000 --- a/app/soapbox/features/ui/components/modals/account-note-modal.tsx +++ /dev/null @@ -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 = e => { - dispatch(changeAccountNoteComment(e.target.value)); - }; - - const handleSubmit = () => { - dispatch(submitAccountNote()); - }; - - const handleKeyDown: React.KeyboardEventHandler = e => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - handleSubmit(); - } - }; - - return ( - } - onClose={onClose} - confirmationAction={handleSubmit} - confirmationText={intl.formatMessage(messages.save)} - confirmationDisabled={isSubmitting} - > - - - - -