diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 01c6314c9..cdf22dd2e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,6 +25,7 @@ deps: cache: <<: *cache policy: push + interruptible: true danger: stage: test @@ -33,6 +34,7 @@ danger: - export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!} - npx danger ci allow_failure: true + interruptible: true lint-js: stage: test @@ -45,6 +47,7 @@ lint-js: - "**/*.tsx" - ".eslintignore" - ".eslintrc.js" + interruptible: true lint-sass: stage: test @@ -54,6 +57,7 @@ lint-sass: - "**/*.scss" - "**/*.css" - ".stylelintrc.json" + interruptible: true jest: stage: test @@ -76,6 +80,7 @@ jest: coverage_report: coverage_format: cobertura path: .coverage/cobertura-coverage.xml + interruptible: true nginx-test: stage: test @@ -85,6 +90,7 @@ nginx-test: only: changes: - "installation/mastodon.conf" + interruptible: true build-production: stage: test @@ -94,6 +100,7 @@ build-production: artifacts: paths: - static + interruptible: true docs-deploy: stage: deploy @@ -107,6 +114,7 @@ docs-deploy: - develop changes: - "docs/**/*" + interruptible: true # Supposed to fail when translations are outdated, instead always passes # @@ -127,6 +135,7 @@ review: script: - npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub allow_failure: true + interruptible: true pages: stage: deploy @@ -142,6 +151,7 @@ pages: only: refs: - develop + interruptible: true docker: stage: deploy @@ -157,4 +167,5 @@ docker: - docker push $CI_REGISTRY_IMAGE only: refs: - - develop \ No newline at end of file + - develop + interruptible: true \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 62ceaf17b..b17e16bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Events: ability to create, view, and comment on Events (on Rebased). - Onboarding: display an introduction wizard to newly registered accounts. - Posts: translate foreign language posts into your native language (on Rebased, Mastodon; if configured by the admin). +- Posts: ability to view quotes of a post (on Rebased). - Posts: hover the "replying to" line to see a preview card of the parent post. - Chats: ability to leave a chat (on Rebased, Truth Social). - Chats: ability to disable chats for yourself. @@ -32,10 +33,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Posts: changed the thumbs-up icon to a heart. - Posts: move instance favicon beside username instead of post timestamp. - Posts: changed the behavior of content warnings. CWs and sensitive media are unified into one design. +- Posts: redesigned interaction counters to use text instead of icons. - Profile: overhauled user profiles to be consistent with the rest of the UI. - Composer: move emoji button alongside other composer buttons, add numerical counter. - Birthdays: move today's birthdays out of notifications into right sidebar. - Performance: improve scrolling/navigation between feeds by using a virtual window library. +- Admin: reorganize UI into 3-column layout. +- Admin: include external link to frontend repo for the running commit. ### Removed - Theme: Halloween theme. diff --git a/README.md b/README.md index 754e68d4c..715703c97 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ busybox unzip soapbox.zip -o -d /opt/pleroma/instance The change will take effect immediately, just refresh your browser tab. It's not necessary to restart the Pleroma service. -***For OTP releases,*** *unpack to /var/lib/pleroma instead.* +**_For OTP releases,_** _unpack to /var/lib/pleroma instead._ To remove Soapbox and revert to the default pleroma-fe, simply `rm /opt/pleroma/instance/static/index.html` (you can delete other stuff in there too, but be careful not to delete your own HTML files). @@ -150,15 +150,19 @@ NODE_ENV=development ``` #### Local dev server + - `yarn dev` - Run the local dev server. #### Building + - `yarn build` - Compile without a dev server, into `/static` directory. #### Translations + - `yarn manage:translations` - Normalizes translation files. Should always be run after editing i18n strings. #### Tests + - `yarn test:all` - Runs all tests and linters. - `yarn test` - Runs Jest for frontend unit tests. @@ -174,6 +178,9 @@ NODE_ENV=development We welcome contributions to this project. To contribute, see [Contributing to Soapbox](docs/contributing.md). +Translators can help by providing [translations through Weblate](http://hosted.weblate.org/soapbox-pub/soapbox/). +Native speakers from all around the world are welcome! + # Customization Soapbox supports customization of the user interface, to allow per-instance branding and other features. @@ -205,8 +212,8 @@ the Free Software Foundation, either version 3 of the License, or Soapbox is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License -along with Soapbox. If not, see . +along with Soapbox. If not, see . diff --git a/app/soapbox/components/radio.tsx b/app/soapbox/components/radio.tsx new file mode 100644 index 000000000..ef204b06f --- /dev/null +++ b/app/soapbox/components/radio.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import List, { ListItem } from './list'; + +interface IRadioGroup { + onChange: React.ChangeEventHandler + children: React.ReactElement<{ onChange: React.ChangeEventHandler }>[] +} + +const RadioGroup = ({ onChange, children }: IRadioGroup) => { + const childrenWithProps = React.Children.map(children, child => + React.cloneElement(child, { onChange }), + ); + + return {childrenWithProps}; +}; + +interface IRadioItem { + label: React.ReactNode, + hint?: React.ReactNode, + value: string, + checked: boolean, + onChange?: React.ChangeEventHandler, +} + +const RadioItem: React.FC = ({ label, hint, checked = false, onChange, value }) => { + return ( + + + + ); +}; + +export { + RadioGroup, + RadioItem, +}; \ No newline at end of file diff --git a/app/soapbox/features/admin/components/dashcounter.tsx b/app/soapbox/features/admin/components/dashcounter.tsx new file mode 100644 index 000000000..a42986535 --- /dev/null +++ b/app/soapbox/features/admin/components/dashcounter.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { FormattedNumber } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { Text } from 'soapbox/components/ui'; +import { isNumber } from 'soapbox/utils/numbers'; + +interface IDashCounter { + count: number | undefined + label: React.ReactNode + to?: string + percent?: boolean +} + +/** Displays a (potentially clickable) dashboard statistic. */ +const DashCounter: React.FC = ({ count, label, to = '#', percent = false }) => { + + if (!isNumber(count)) { + return null; + } + + return ( + + + + + + {label} + + + ); +}; + +interface IDashCounters { + children: React.ReactNode +} + +/** Wrapper container for dash counters. */ +const DashCounters: React.FC = ({ children }) => { + return ( +
+ {children} +
+ ); +}; + +export { + DashCounter, + DashCounters, +}; \ No newline at end of file diff --git a/app/soapbox/features/admin/components/registration-mode-picker.tsx b/app/soapbox/features/admin/components/registration-mode-picker.tsx index 2c830153a..eab7f2f94 100644 --- a/app/soapbox/features/admin/components/registration-mode-picker.tsx +++ b/app/soapbox/features/admin/components/registration-mode-picker.tsx @@ -3,12 +3,7 @@ import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { updateConfig } from 'soapbox/actions/admin'; import snackbar from 'soapbox/actions/snackbar'; -import { - SimpleForm, - FieldsGroup, - RadioGroup, - RadioItem, -} from 'soapbox/features/forms'; +import { RadioGroup, RadioItem } from 'soapbox/components/radio'; import { useAppDispatch, useInstance } from 'soapbox/hooks'; import type { Instance } from 'soapbox/types/entities'; @@ -54,33 +49,26 @@ const RegistrationModePicker: React.FC = () => { }; return ( - - - } - onChange={onChange} - > - } - hint={} - checked={mode === 'open'} - value='open' - /> - } - hint={} - checked={mode === 'approval'} - value='approval' - /> - } - hint={} - checked={mode === 'closed'} - value='closed' - /> - - - + + } + hint={} + checked={mode === 'open'} + value='open' + /> + } + hint={} + checked={mode === 'approval'} + value='approval' + /> + } + hint={} + checked={mode === 'closed'} + value='closed' + /> + ); }; diff --git a/app/soapbox/features/admin/components/unapproved-account.tsx b/app/soapbox/features/admin/components/unapproved-account.tsx index c1dae46fb..514fcb370 100644 --- a/app/soapbox/features/admin/components/unapproved-account.tsx +++ b/app/soapbox/features/admin/components/unapproved-account.tsx @@ -4,7 +4,7 @@ import { defineMessages, useIntl } from 'react-intl'; import { approveUsers } from 'soapbox/actions/admin'; import { rejectUserModal } from 'soapbox/actions/moderation'; import snackbar from 'soapbox/actions/snackbar'; -import IconButton from 'soapbox/components/icon-button'; +import { Stack, HStack, Text, IconButton } from 'soapbox/components/ui'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; @@ -45,16 +45,31 @@ const UnapprovedAccount: React.FC = ({ accountId }) => { }; return ( -
-
-
@{account.get('acct')}
-
{adminAccount?.invite_request || ''}
-
-
- - -
-
+ + + + @{account.get('acct')} + + + {adminAccount?.invite_request || ''} + + + + + + + + ); }; diff --git a/app/soapbox/features/admin/moderation-log.tsx b/app/soapbox/features/admin/moderation-log.tsx index baf002eb4..324f1f202 100644 --- a/app/soapbox/features/admin/moderation-log.tsx +++ b/app/soapbox/features/admin/moderation-log.tsx @@ -3,8 +3,9 @@ import { defineMessages, FormattedDate, useIntl } from 'react-intl'; import { fetchModerationLog } from 'soapbox/actions/admin'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Column } from 'soapbox/components/ui'; +import { Column, Stack, Text } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { AdminLog } from 'soapbox/types/entities'; const messages = defineMessages({ heading: { id: 'column.admin.moderation_log', defaultMessage: 'Moderation Log' }, @@ -18,6 +19,7 @@ const ModerationLog = () => { const items = useAppSelector((state) => { return state.admin_log.index.map((i) => state.admin_log.items.get(String(i))); }); + const hasMore = useAppSelector((state) => state.admin_log.total - state.admin_log.index.count() > 0); const [isLoading, setIsLoading] = useState(true); @@ -54,26 +56,38 @@ const ModerationLog = () => { emptyMessage={intl.formatMessage(messages.emptyMessage)} hasMore={hasMore} onLoadMore={handleLoadMore} + className='divide-y divide-solid divide-gray-200 dark:divide-gray-800' > - {items.map((item) => item && ( -
-
{item.message}
-
- -
-
+ {items.map(item => item && ( + ))} ); }; +interface ILogItem { + log: AdminLog +} + +const LogItem: React.FC = ({ log }) => { + return ( + + {log.message} + + + + + + ); +}; + export default ModerationLog; diff --git a/app/soapbox/features/admin/tabs/awaiting-approval.tsx b/app/soapbox/features/admin/tabs/awaiting-approval.tsx index 0a412f400..36a86b61a 100644 --- a/app/soapbox/features/admin/tabs/awaiting-approval.tsx +++ b/app/soapbox/features/admin/tabs/awaiting-approval.tsx @@ -33,9 +33,12 @@ const AwaitingApproval: React.FC = () => { showLoading={showLoading} scrollKey='awaiting-approval' emptyMessage={intl.formatMessage(messages.emptyMessage)} + className='divide-y divide-solid divide-gray-200 dark:divide-gray-800' > {accountIds.map(id => ( - +
+ +
))} ); diff --git a/app/soapbox/features/admin/tabs/dashboard.tsx b/app/soapbox/features/admin/tabs/dashboard.tsx index a43bd8d83..dd0f9f1bd 100644 --- a/app/soapbox/features/admin/tabs/dashboard.tsx +++ b/app/soapbox/features/admin/tabs/dashboard.tsx @@ -1,19 +1,21 @@ import React from 'react'; -import { FormattedMessage, FormattedNumber } from 'react-intl'; -import { Link } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; +import { useHistory } from 'react-router-dom'; import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email-list'; -import { Text } from 'soapbox/components/ui'; +import List, { ListItem } from 'soapbox/components/list'; +import { CardTitle, Icon, IconButton, Stack } from 'soapbox/components/ui'; import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks'; import sourceCode from 'soapbox/utils/code'; import { download } from 'soapbox/utils/download'; import { parseVersion } from 'soapbox/utils/features'; -import { isNumber } from 'soapbox/utils/numbers'; +import { DashCounter, DashCounters } from '../components/dashcounter'; import RegistrationModePicker from '../components/registration-mode-picker'; const Dashboard: React.FC = () => { const dispatch = useAppDispatch(); + const history = useHistory(); const instance = useInstance(); const features = useFeatures(); const account = useOwnAccount(); @@ -39,6 +41,9 @@ const Dashboard: React.FC = () => { e.preventDefault(); }; + const navigateToSoapboxConfig = () => history.push('/soapbox/config'); + const navigateToModerationLog = () => history.push('/soapbox/admin/log'); + const v = parseVersion(instance.version); const userCount = instance.stats.get('user_count'); @@ -46,87 +51,121 @@ const Dashboard: React.FC = () => { const domainCount = instance.stats.get('domain_count'); const mau = instance.pleroma.getIn(['stats', 'mau']) as number | undefined; - const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : null; + const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : undefined; if (!account) return null; return ( - <> -
- {isNumber(mau) && ( -
- - - - - - -
- )} - {isNumber(userCount) && ( - - - - - - - - - )} - {isNumber(retention) && ( -
- - {retention}% - - - - -
- )} - {isNumber(statusCount) && ( - - - - - - - - - )} - {isNumber(domainCount) && ( -
- - - - - - -
- )} -
- - {account.admin && } - -
-
-

-
    -
  • {sourceCode.displayName} {sourceCode.version}
  • -
  • {v.software + (v.build ? `+${v.build}` : '')} {v.version}
  • -
-
- {features.emailList && account.admin && ( - + + + } + /> + } + /> + } + percent + /> + } + /> + } + /> + + + + {account.admin && ( + } + /> )} -
- + + } + /> + + + {account.admin && ( + <> + } + /> + + + + )} + + } + /> + + + }> + + {sourceCode.displayName} {sourceCode.version} + + + + + + }> + {v.software + (v.build ? `+${v.build}` : '')} {v.version} + + + + {(features.emailList && account.admin) && ( + <> + } + /> + + + + + + + + + + + + + + + + )} + ); }; diff --git a/app/soapbox/features/forms/index.tsx b/app/soapbox/features/forms/index.tsx index b4b13a503..5d853e581 100644 --- a/app/soapbox/features/forms/index.tsx +++ b/app/soapbox/features/forms/index.tsx @@ -1,8 +1,8 @@ import classNames from 'clsx'; -import React, { useState, useRef } from 'react'; +import React, { useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import { Text, Select } from '../../components/ui'; +import { Select } from '../../components/ui'; interface IInputContainer { label?: React.ReactNode, @@ -175,52 +175,6 @@ export const Checkbox: React.FC = (props) => ( ); -interface IRadioGroup { - label?: React.ReactNode, - onChange?: React.ChangeEventHandler, -} - -export const RadioGroup: React.FC = (props) => { - const { label, children, onChange } = props; - - const childrenWithProps = React.Children.map(children, child => - // @ts-ignore - React.cloneElement(child, { onChange }), - ); - - return ( -
-
- -
    {childrenWithProps}
-
-
- ); -}; - -interface IRadioItem { - label?: React.ReactNode, - hint?: React.ReactNode, - value: string, - checked: boolean, - onChange?: React.ChangeEventHandler, -} - -export const RadioItem: React.FC = (props) => { - const { current: id } = useRef(uuidv4()); - const { label, hint, checked = false, ...rest } = props; - - return ( -
  • - -
  • - ); -}; - interface ISelectDropdown { label?: React.ReactNode, hint?: React.ReactNode, diff --git a/app/soapbox/features/ui/components/bundle.tsx b/app/soapbox/features/ui/components/bundle.tsx index 79851b978..f2fe4413d 100644 --- a/app/soapbox/features/ui/components/bundle.tsx +++ b/app/soapbox/features/ui/components/bundle.tsx @@ -45,7 +45,7 @@ class Bundle extends React.PureComponent { this.load(this.props); } - componentWillReceiveProps(nextProps: BundleProps) { + UNSAFE_componentWillReceiveProps(nextProps: BundleProps) { if (nextProps.fetchComponent !== this.props.fetchComponent) { this.load(nextProps); } diff --git a/app/soapbox/reducers/admin-log.ts b/app/soapbox/reducers/admin-log.ts index fc02a1c5c..2dc1e92c4 100644 --- a/app/soapbox/reducers/admin-log.ts +++ b/app/soapbox/reducers/admin-log.ts @@ -8,7 +8,7 @@ import { ADMIN_LOG_FETCH_SUCCESS } from 'soapbox/actions/admin'; import type { AnyAction } from 'redux'; -const LogEntryRecord = ImmutableRecord({ +export const LogEntryRecord = ImmutableRecord({ data: ImmutableMap(), id: 0, message: '', diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index 70b51857d..0670d4fce 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -24,10 +24,12 @@ import { StatusRecord, TagRecord, } from 'soapbox/normalizers'; +import { LogEntryRecord } from 'soapbox/reducers/admin-log'; import type { Record as ImmutableRecord } from 'immutable'; type AdminAccount = ReturnType; +type AdminLog = ReturnType; type AdminReport = ReturnType; type Announcement = ReturnType; type AnnouncementReaction = ReturnType; @@ -68,6 +70,7 @@ type EmbeddedEntity = null | string | ReturnType new URL(url).pathname.substring(1); const trimHash = hash => hash.substring(0, 7); @@ -10,14 +12,12 @@ const tryGit = cmd => { try { return String(execSync(cmd)); } catch (e) { - return null; + return undefined; } }; const version = pkg => { // Try to discern from GitLab CI first - const { CI_COMMIT_TAG, CI_COMMIT_REF_NAME, CI_COMMIT_SHA } = process.env; - if (CI_COMMIT_TAG === `v${pkg.version}` || CI_COMMIT_REF_NAME === 'stable') { return pkg.version; } @@ -43,4 +43,5 @@ module.exports = { repository: shortRepoName(pkg.repository.url), version: version(pkg), homepage: pkg.homepage, + ref: CI_COMMIT_TAG || CI_COMMIT_SHA || tryGit('git rev-parse HEAD'), }; diff --git a/app/styles/application.scss b/app/styles/application.scss index ff2bf20de..e64ccc15e 100644 --- a/app/styles/application.scss +++ b/app/styles/application.scss @@ -50,7 +50,6 @@ @import 'components/profile-hover-card'; @import 'components/filters'; @import 'components/snackbar'; -@import 'components/admin'; @import 'components/backups'; @import 'components/crypto-donate'; @import 'components/aliases'; diff --git a/app/styles/components/admin.scss b/app/styles/components/admin.scss deleted file mode 100644 index bba02ffe8..000000000 --- a/app/styles/components/admin.scss +++ /dev/null @@ -1,67 +0,0 @@ -.dashcounters { - @apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mb-4; -} - -.dashcounter { - @apply bg-gray-200 dark:bg-gray-800 p-4 rounded flex flex-col items-center space-y-2 hover:-translate-y-1 transition-transform cursor-pointer; -} - -.dashwidgets { - display: flex; - flex-wrap: wrap; - margin: 0 -5px; - padding: 0 20px 20px 20px; -} - -.dashwidget { - flex: 1; - margin-bottom: 20px; - padding: 0 5px; - - h4 { - text-transform: uppercase; - font-size: 13px; - font-weight: 700; - color: hsla(var(--primary-text-color_hsl), 0.6); - padding-bottom: 8px; - margin-bottom: 8px; - border-bottom: 1px solid var(--accent-color--med); - } - - a { - color: var(--brand-color); - } -} - -.unapproved-account { - padding: 15px 20px; - font-size: 14px; - display: flex; - - &__nickname { - font-weight: bold; - } - - &__actions { - margin-left: auto; - display: flex; - flex-wrap: nowrap; - column-gap: 10px; - padding-left: 20px; - - .svg-icon { - height: 24px; - width: 24px; - } - } -} - -.logentry { - padding: 15px; - - &__timestamp { - color: var(--primary-text-color--faint); - font-size: 13px; - text-align: right; - } -} diff --git a/dangerfile.ts b/dangerfile.ts index 66d25d65a..6ed716fbc 100644 --- a/dangerfile.ts +++ b/dangerfile.ts @@ -1,5 +1,8 @@ import { danger, warn, message } from 'danger'; +// App changes +const app = danger.git.fileMatch('app/soapbox/**'); + // Docs changes const docs = danger.git.fileMatch('docs/**/*.md'); @@ -10,7 +13,7 @@ if (docs.edited) { // Enforce CHANGELOG.md additions const changelog = danger.git.fileMatch('CHANGELOG.md'); -if (!changelog.edited) { +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'); }