diff --git a/.sass-lint.yml b/.sass-lint.yml deleted file mode 100644 index 7952298d4..000000000 --- a/.sass-lint.yml +++ /dev/null @@ -1,37 +0,0 @@ -# Linter Documentation: -# https://github.com/sasstools/sass-lint/tree/v1.13.1/docs/options - -files: - include: app/styles/**/*.scss - ignore: - - app/styles/reset.scss - -rules: - # Disallows - no-color-literals: 0 - no-css-comments: 0 - no-duplicate-properties: 0 - no-ids: 0 - no-important: 0 - no-mergeable-selectors: 0 - no-misspelled-properties: 0 - no-qualifying-elements: 0 - no-transition-all: 0 - no-vendor-prefixes: 0 - - # Nesting - force-element-nesting: 0 - force-attribute-nesting: 0 - force-pseudo-nesting: 0 - - # Name Formats - class-name-format: 0 - leading-zero: 0 - - # Style Guide - attribute-quotes: 0 - hex-length: 0 - indentation: 0 - nesting-depth: 0 - property-sort-order: 0 - quotes: 0 diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 000000000..7a710d4da --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,15 @@ +{ + "extends": ["stylelint-config-standard"], + "ignoreFiles": ["app/styles/reset.scss"], + "plugins": ["stylelint-scss"], + "rules": { + "at-rule-no-unknown": null, + "at-rule-empty-line-before": ["always", { "ignore": ["after-comment", "first-nested", "inside-block", "blockless-after-same-name-blockless", "blockless-after-blockless"] }], + "declaration-colon-newline-after": null, + "declaration-empty-line-before": "never", + "font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "OpenDyslexic", "soapbox"] }], + "no-descending-specificity": null, + "no-duplicate-selectors": null, + "scss/at-rule-no-unknown": true + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd983eec..fe15dd098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,90 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] -### Added -- Audio player for audio uploads. -- Integration with Patron recurring donations platform. - -## [Unreleased patch] +## [1.1.0] - 2020-10-05 ### Fixed +- General user interface and ease-of-use improvements for both mobile and desktop +- General loading and performance improvements, including shrinking bundle size +- GIF handling: AutoPlayGif Preference support, including avatars and profile banners +- Sidebar menu browser compatibility +- React 17.x compatibility +- Timeline jumping during scroll +- Collapse of compose modal after privacy scope change +- Media attachment rendering +- Thread view reply post rendering +- Thread view scroll to selected post rendering +- Bookmarking of posts +- Edit Profile: checkbox handling +- Edit Profile: multi-line bio with link support +- Muted Users: posts of muted users now appear in profile view +- Forms: security issue resolved with POST method on all forms +- Internationalization: increased elements that are internationalizable - Composer: Forcing the scope to default after settings save. +### Added +- Chats, currently one-to-one, evolving with Pleroma BE capabilities, including: + - Initiate chat via `Message` button on profile + - Up to 4 open foreground chat windows in desktop, with open/minimize/close and notification counter + - Browser tab notification counter includes total chat and post notifications + - Chats list with total chats notification counter and audio notification toggle + - Unique chat audio notification + - Add attachment + - Delete chat message + - Report chat account + - Chats icon with notification counter in top navbar in mobile view + - Chats marked read on chat hover or on chat key event +- Audio player for audio uploads, including ogg, oga, and wav support +- Integration with Patron recurring donations platform +- Profile hover panels, with click to Follow/Unfollow +- Posts: Favicon of user's home instance included on post +- Soapbox configuration page, including: + - Site preview, including light/dark theme toggle rendering + - Logo + - Brand color using color picker + - Copyright footer + - Promo panel custom links for timeline pages + - Home footer custom links for static pages + - Editable JSON based configuration option +- Themes: Light/dark theme toggle in top navbar +- Themes: Halloween mode in Preferences page +- Markdown support in post composer, as default +- Loading indicator general improvements +- Polls: Add media attachments +- Polls: Mouseover hint on poll compose radiobutton to teach single/multi-choice poll type toggling +- Polls: Remove blank poll by either toggling Poll icon or by removing poll options +- Registration: Support for `Account approval required` setting in Pleroma AdminFE, via dynamic `Why do you want to join?` textarea on registration page +- Filtering: `Muted Words` menu item and page +- Filtering: Direct messages filter toggle on Home timeline +- Floating top navbar during scroll +- Import Data: `Import follows` and `import blocks` +- Profile: Media panel +- Media: Media gallery thumbnails +- Media: Any media type as attachment +- General documentation improvements +- Delete Account feature for user self-deletion in Security page +- Registration: Captcha reload on image click +- Fediverse timeline explanation accordion toggle +- Tests: React reducers tests +- Profile: Max profile meta fields defined by Pleroma BE capability +- Profile: Verified user checkbox +- Admin: Reports counter and top navbar element for admin accounts, linked to Pleroma AdminFE +- [Renovate.json](https://docs.renovatebot.com/configuration-options/) support + +### Changed +- Revoke OAuth token on logout +- Home sidebar rearrangement +- Compose form icons +- User event notifications: improved rendering and added color coding +- Home timeline: `Show reposts` filter toggle default to `off` +- Direct Messages: Changed API usage from `conversations` to `direct` +- Project documentation management system, using CI +- Documentation: site customization and installation on sub-domain +- Redux update + ### Removed -- Removed the app name on statuses. +- FontAwesome dependencies, with full switch to ForkAwesome +- Requirement for use of soapbox.json for configuration +- Direct Message links from menus, partial deprecation due to chats ## [1.0.0] - 2020-06-15 ### Added diff --git a/README.md b/README.md index 584868d7c..0399dc761 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Installing Soapbox FE on an existing Pleroma server is extremely easy. Just ssh into the server and download a .zip of the latest build: ```sh -curl -L https://gitlab.com/soapbox-pub/soapbox-fe/-/jobs/artifacts/v1.0.0/download?job=build-production -o soapbox-fe.zip +curl -L https://gitlab.com/soapbox-pub/soapbox-fe/-/jobs/artifacts/v1.1.0/download?job=build-production -o soapbox-fe.zip ``` Then unpack it into Pleroma's `instance` directory: diff --git a/app/images/pro_bg/floral--dark.svg b/app/images/pro_bg/floral--dark.svg deleted file mode 100644 index 96fbda7ec..000000000 --- a/app/images/pro_bg/floral--dark.svg +++ /dev/null @@ -1,535 +0,0 @@ - -image/svg+xml \ No newline at end of file diff --git a/app/images/pro_bg/floral--light.svg b/app/images/pro_bg/floral--light.svg deleted file mode 100644 index e6c68b0cd..000000000 --- a/app/images/pro_bg/floral--light.svg +++ /dev/null @@ -1,535 +0,0 @@ - -image/svg+xml \ No newline at end of file diff --git a/app/soapbox/actions/__tests__/auth-test.js b/app/soapbox/actions/__tests__/auth-test.js index a013c700f..0e1e3d2b4 100644 --- a/app/soapbox/actions/__tests__/auth-test.js +++ b/app/soapbox/actions/__tests__/auth-test.js @@ -10,7 +10,7 @@ describe('logOut()', () => { it('creates expected actions', () => { const expectedActions = [ { type: AUTH_LOGGED_OUT }, - { type: ALERT_SHOW, title: 'Successfully logged out.', message: '' }, + { type: ALERT_SHOW, message: 'Logged out.', severity: 'success' }, ]; const store = mockStore(ImmutableMap()); diff --git a/app/soapbox/actions/accounts.js b/app/soapbox/actions/accounts.js index ec1d1cb6b..55e5926e4 100644 --- a/app/soapbox/actions/accounts.js +++ b/app/soapbox/actions/accounts.js @@ -111,7 +111,7 @@ export function fetchAccount(id) { dispatch, getState, db.transaction('accounts', 'read').objectStore('accounts').index('id'), - id + id, ).then(() => db.close(), error => { db.close(); throw error; diff --git a/app/soapbox/actions/alerts.js b/app/soapbox/actions/alerts.js index 33791369f..01fdd3ccf 100644 --- a/app/soapbox/actions/alerts.js +++ b/app/soapbox/actions/alerts.js @@ -1,4 +1,3 @@ -//test import { defineMessages } from 'react-intl'; const messages = defineMessages({ @@ -23,11 +22,12 @@ export function clearAlert() { }; }; -export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) { +export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, severity = 'info') { return { type: ALERT_SHOW, title, message, + severity, }; }; @@ -47,9 +47,9 @@ export function showAlertForError(error) { message = data.error; } - return showAlert(title, message); + return showAlert(title, message, 'error'); } else { console.error(error); - return showAlert(); + return showAlert(undefined, undefined, 'error'); } } diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index 09a225bf5..f6f5275ae 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -1,5 +1,5 @@ import api from '../api'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; import { fetchMe } from 'soapbox/actions/me'; export const AUTH_APP_CREATED = 'AUTH_APP_CREATED'; @@ -135,8 +135,10 @@ export function logIn(username, password) { }).catch(error => { if (error.response.data.error === 'mfa_required') { throw error; + } else if(error.response.data.error) { + dispatch(snackbar.error(error.response.data.error)); } else { - dispatch(showAlert('Login failed.', 'Invalid username or password.')); + dispatch(snackbar.error('Wrong username or password')); } throw error; }); @@ -156,7 +158,7 @@ export function logOut() { token: state.getIn(['auth', 'user', 'access_token']), }); - dispatch(showAlert('Successfully logged out.', '')); + dispatch(snackbar.success('Logged out.')); }; } @@ -172,9 +174,9 @@ export function register(params) { dispatch({ type: AUTH_REGISTER_SUCCESS, token: response.data }); dispatch(authLoggedIn(response.data)); if (needsConfirmation) { - return dispatch(showAlert('', 'Check your email for further instructions.')); + return dispatch(snackbar.info('You must confirm your email.')); } else if (needsApproval) { - return dispatch(showAlert('', 'Your account has been submitted for approval.')); + return dispatch(snackbar.info('Your account is pending review by an admin.')); } else { return dispatch(fetchMe()); } @@ -232,7 +234,7 @@ export function deleteAccount(password) { if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure dispatch({ type: DELETE_ACCOUNT_SUCCESS, response }); dispatch({ type: AUTH_LOGGED_OUT }); - dispatch(showAlert('Successfully logged out.', '')); + dispatch(snackbar.success('Logged out.')); }).catch(error => { dispatch({ type: DELETE_ACCOUNT_FAIL, error, skipAlert: true }); throw error; diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js index f4a644e87..782dee74a 100644 --- a/app/soapbox/actions/compose.js +++ b/app/soapbox/actions/compose.js @@ -224,7 +224,7 @@ export function uploadCompose(files) { let total = Array.from(files).reduce((a, v) => a + v.size, 0); if (files.length + media.size > uploadLimit) { - dispatch(showAlert(undefined, messages.uploadErrorLimit)); + dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error')); return; } diff --git a/app/soapbox/actions/emoji_reacts.js b/app/soapbox/actions/emoji_reacts.js index 9f36e8a21..7b041d4ee 100644 --- a/app/soapbox/actions/emoji_reacts.js +++ b/app/soapbox/actions/emoji_reacts.js @@ -29,7 +29,7 @@ export const simpleEmojiReact = (status, emoji) => { emojiReacts .filter(emojiReact => emojiReact.get('me') === true) .map(emojiReact => dispatch(unEmojiReact(status, emojiReact.get('name')))), - status.get('favourited') && dispatch(unfavourite(status)) + status.get('favourited') && dispatch(unfavourite(status)), ).then(() => { if (emoji === '👍') { dispatch(favourite(status)); diff --git a/app/soapbox/actions/filters.js b/app/soapbox/actions/filters.js index 3448e391c..e3ad557f5 100644 --- a/app/soapbox/actions/filters.js +++ b/app/soapbox/actions/filters.js @@ -1,5 +1,5 @@ import api from '../api'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; @@ -47,7 +47,7 @@ export function createFilter(phrase, expires_at, context, whole_word, irreversib expires_at, }).then(response => { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); - dispatch(showAlert('', 'Filter added')); + dispatch(snackbar.success('Filter added.')); }).catch(error => { dispatch({ type: FILTERS_CREATE_FAIL, error }); }); @@ -60,7 +60,7 @@ export function deleteFilter(id) { dispatch({ type: FILTERS_DELETE_REQUEST }); return api(getState).delete('/api/v1/filters/'+id).then(response => { dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data }); - dispatch(showAlert('', 'Filter deleted')); + dispatch(snackbar.success('Filter deleted.')); }).catch(error => { dispatch({ type: FILTERS_DELETE_FAIL, error }); }); diff --git a/app/soapbox/actions/import_data.js b/app/soapbox/actions/import_data.js index 251d2972d..6a0a3c254 100644 --- a/app/soapbox/actions/import_data.js +++ b/app/soapbox/actions/import_data.js @@ -1,5 +1,5 @@ import api from '../api'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; export const IMPORT_FOLLOWS_REQUEST = 'IMPORT_FOLLOWS_REQUEST'; export const IMPORT_FOLLOWS_SUCCESS = 'IMPORT_FOLLOWS_SUCCESS'; @@ -19,7 +19,7 @@ export function importFollows(params) { return api(getState) .post('/api/pleroma/follow_import', params) .then(response => { - dispatch(showAlert('', 'Followers imported successfully')); + dispatch(snackbar.success('Followers imported successfully')); dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_FOLLOWS_FAIL, error }); @@ -33,7 +33,7 @@ export function importBlocks(params) { return api(getState) .post('/api/pleroma/blocks_import', params) .then(response => { - dispatch(showAlert('', 'Blocks imported successfully')); + dispatch(snackbar.success('Blocks imported successfully')); dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_BLOCKS_FAIL, error }); @@ -47,7 +47,7 @@ export function importMutes(params) { return api(getState) .post('/api/pleroma/mutes_import', params) .then(response => { - dispatch(showAlert('', 'Mutes imported successfully')); + dispatch(snackbar.success('Mutes imported successfully')); dispatch({ type: IMPORT_MUTES_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_MUTES_FAIL, error }); diff --git a/app/soapbox/actions/interactions.js b/app/soapbox/actions/interactions.js index 1acfa9c57..b07feac1b 100644 --- a/app/soapbox/actions/interactions.js +++ b/app/soapbox/actions/interactions.js @@ -1,6 +1,6 @@ import api from '../api'; import { importFetchedAccounts, importFetchedStatus } from './importer'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; export const REBLOG_REQUEST = 'REBLOG_REQUEST'; export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; @@ -211,7 +211,7 @@ export function bookmark(status) { api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function(response) { dispatch(importFetchedStatus(response.data)); dispatch(bookmarkSuccess(status, response.data)); - dispatch(showAlert('', 'Bookmark added')); + dispatch(snackbar.success('Bookmark added')); }).catch(function(error) { dispatch(bookmarkFail(status, error)); }); @@ -225,7 +225,7 @@ export function unbookmark(status) { api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unbookmarkSuccess(status, response.data)); - dispatch(showAlert('', 'Bookmark removed')); + dispatch(snackbar.success('Bookmark removed')); }).catch(error => { dispatch(unbookmarkFail(status, error)); }); diff --git a/app/soapbox/actions/settings.js b/app/soapbox/actions/settings.js index 205250c3b..03b538bcc 100644 --- a/app/soapbox/actions/settings.js +++ b/app/soapbox/actions/settings.js @@ -9,7 +9,7 @@ export const SETTING_SAVE = 'SETTING_SAVE'; export const FE_NAME = 'soapbox_fe'; -const defaultSettings = ImmutableMap({ +export const defaultSettings = ImmutableMap({ onboarded: false, skinTone: 1, diff --git a/app/soapbox/actions/snackbar.js b/app/soapbox/actions/snackbar.js new file mode 100644 index 000000000..e6f0a6595 --- /dev/null +++ b/app/soapbox/actions/snackbar.js @@ -0,0 +1,25 @@ +import { ALERT_SHOW } from './alerts'; + +const show = (severity, message) => ({ + type: ALERT_SHOW, + message, + severity, +}); + +export function info(message) { + return show('info', message); +}; + +export function success(message) { + return show('success', message); +}; + +export function error(message) { + return show('error', message); +}; + +export default { + info, + success, + error, +}; diff --git a/app/soapbox/actions/streaming.js b/app/soapbox/actions/streaming.js index b3ca60fb3..3b66a0472 100644 --- a/app/soapbox/actions/streaming.js +++ b/app/soapbox/actions/streaming.js @@ -57,11 +57,13 @@ export function connectTimelineStream(timelineId, path, pollingRefresh = null, a case 'pleroma:chat_update': dispatch((dispatch, getState) => { const chat = JSON.parse(data.payload); - const messageOwned = !(chat.last_message && chat.last_message.account_id !== getState().get('me')); + const me = getState().get('me'); + const messageOwned = !(chat.last_message && chat.last_message.account_id !== me); dispatch({ type: STREAMING_CHAT_UPDATE, chat, + me, // Only play sounds for recipient messages meta: !messageOwned && getSettings(getState()).getIn(['chats', 'sound']) && { sound: 'chat' }, }); diff --git a/app/soapbox/components/__tests__/timeline_queue_button_header-test.js b/app/soapbox/components/__tests__/timeline_queue_button_header-test.js index 4e2ace540..9f0125a46 100644 --- a/app/soapbox/components/__tests__/timeline_queue_button_header-test.js +++ b/app/soapbox/components/__tests__/timeline_queue_button_header-test.js @@ -15,7 +15,7 @@ describe('', () => { onClick={() => {}} // eslint-disable-line react/jsx-no-bind count={0} message={messages.queue} - /> + />, ).toJSON()).toMatchSnapshot(); expect(createComponent( @@ -24,7 +24,7 @@ describe('', () => { onClick={() => {}} // eslint-disable-line react/jsx-no-bind count={1} message={messages.queue} - /> + />, ).toJSON()).toMatchSnapshot(); expect(createComponent( @@ -33,7 +33,7 @@ describe('', () => { onClick={() => {}} // eslint-disable-line react/jsx-no-bind count={9999999} message={messages.queue} - /> + />, ).toJSON()).toMatchSnapshot(); }); }); diff --git a/app/soapbox/components/autosuggest_textarea.js b/app/soapbox/components/autosuggest_textarea.js index d9a044022..ae44d3bfa 100644 --- a/app/soapbox/components/autosuggest_textarea.js +++ b/app/soapbox/components/autosuggest_textarea.js @@ -159,6 +159,19 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { this.textarea.focus(); } + shouldComponentUpdate(nextProps, nextState) { + // Skip updating when only the lastToken changes so the + // cursor doesn't jump around due to re-rendering unnecessarily + const lastTokenUpdated = this.state.lastToken !== nextState.lastToken; + const valueUpdated = this.props.value !== nextProps.value; + + if (lastTokenUpdated && !valueUpdated) { + return false; + } else { + return super.shouldComponentUpdate(nextProps, nextState); + } + } + componentDidUpdate(prevProps, prevState) { const { suggestions } = this.props; if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) { @@ -215,7 +228,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { {placeholder}