diff --git a/app/soapbox/actions/aliases.js b/app/soapbox/actions/aliases.js new file mode 100644 index 000000000..cc62f8fa6 --- /dev/null +++ b/app/soapbox/actions/aliases.js @@ -0,0 +1,128 @@ +import { defineMessages } from 'react-intl'; +import api from '../api'; +import { importFetchedAccount, importFetchedAccounts } from './importer'; +import { showAlertForError } from './alerts'; +import snackbar from './snackbar'; +import { isLoggedIn } from 'soapbox/utils/auth'; +import { ME_PATCH_SUCCESS } from './me'; + +export const ALIASES_SUGGESTIONS_CHANGE = 'ALIASES_SUGGESTIONS_CHANGE'; +export const ALIASES_SUGGESTIONS_READY = 'ALIASES_SUGGESTIONS_READY'; +export const ALIASES_SUGGESTIONS_CLEAR = 'ALIASES_SUGGESTIONS_CLEAR'; + +export const ALIASES_ADD_REQUEST = 'ALIASES_ADD_REQUEST'; +export const ALIASES_ADD_SUCCESS = 'ALIASES_ADD_SUCCESS'; +export const ALIASES_ADD_FAIL = 'ALIASES_ADD_FAIL'; + +export const ALIASES_REMOVE_REQUEST = 'ALIASES_REMOVE_REQUEST'; +export const ALIASES_REMOVE_SUCCESS = 'ALIASES_REMOVE_SUCCESS'; +export const ALIASES_REMOVE_FAIL = 'ALIASES_REMOVE_FAIL'; + +const messages = defineMessages({ + createSuccess: { id: 'aliases.success.add', defaultMessage: 'Account alias created successfully' }, + removeSuccess: { id: 'aliases.success.remove', defaultMessage: 'Account alias removed successfully' }, +}); + +export const fetchAliasesSuggestions = q => (dispatch, getState) => { + if (!isLoggedIn(getState)) return; + + const params = { + q, + resolve: true, + limit: 4, + }; + + api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchAliasesSuggestionsReady(q, data)); + }).catch(error => dispatch(showAlertForError(error))); +}; + +export const fetchAliasesSuggestionsReady = (query, accounts) => ({ + type: ALIASES_SUGGESTIONS_READY, + query, + accounts, +}); + +export const clearAliasesSuggestions = () => ({ + type: ALIASES_SUGGESTIONS_CLEAR, +}); + +export const changeAliasesSuggestions = value => ({ + type: ALIASES_SUGGESTIONS_CHANGE, + value, +}); + +export const addToAliases = (intl, apId) => (dispatch, getState) => { + if (!isLoggedIn(getState)) return; + + const alsoKnownAs = getState().getIn(['meta', 'pleroma', 'also_known_as']); + + dispatch(addToAliasesRequest(apId)); + + api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, apId] }) + .then((response => { + dispatch(snackbar.success(intl.formatMessage(messages.createSuccess))); + dispatch(addToAliasesSuccess(response.data)); + })) + .catch(err => dispatch(addToAliasesFail(err))); +}; + +export const addToAliasesRequest = (apId) => ({ + type: ALIASES_ADD_REQUEST, + apId, +}); + +export const addToAliasesSuccess = me => dispatch => { + dispatch(importFetchedAccount(me)); + dispatch({ + type: ME_PATCH_SUCCESS, + me, + }); + dispatch({ + type: ALIASES_ADD_SUCCESS, + }); +}; + +export const addToAliasesFail = (apId, error) => ({ + type: ALIASES_ADD_FAIL, + apId, + error, +}); + +export const removeFromAliases = (intl, apId) => (dispatch, getState) => { + if (!isLoggedIn(getState)) return; + + const alsoKnownAs = getState().getIn(['meta', 'pleroma', 'also_known_as']); + + dispatch(removeFromAliasesRequest(apId)); + + api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter(id => id !== apId) }) + .then(response => { + dispatch(snackbar.success(intl.formatMessage(messages.removeSuccess))); + dispatch(removeFromAliasesSuccess(response.data)); + }) + .catch(err => dispatch(removeFromAliasesFail(apId, err))); +}; + +export const removeFromAliasesRequest = (apId) => ({ + type: ALIASES_REMOVE_REQUEST, + apId, +}); + +export const removeFromAliasesSuccess = me => dispatch => { + dispatch(importFetchedAccount(me)); + dispatch({ + type: ME_PATCH_SUCCESS, + me, + }); + dispatch({ + type: ALIASES_REMOVE_SUCCESS, + }); +}; + +export const removeFromAliasesFail = (apId, error) => ({ + type: ALIASES_REMOVE_FAIL, + apId, + error, +}); diff --git a/app/soapbox/components/sidebar_menu.js b/app/soapbox/components/sidebar_menu.js index b6881f355..82eaba8f5 100644 --- a/app/soapbox/components/sidebar_menu.js +++ b/app/soapbox/components/sidebar_menu.js @@ -33,6 +33,7 @@ const messages = defineMessages({ admin_settings: { id: 'navigation_bar.admin_settings', defaultMessage: 'Admin settings' }, soapbox_config: { id: 'navigation_bar.soapbox_config', defaultMessage: 'Soapbox config' }, import_data: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' }, + account_aliases: { id: 'navigation_bar.account_aliases', defaultMessage: 'Account aliases' }, security: { id: 'navigation_bar.security', defaultMessage: 'Security' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, lists: { id: 'column.lists', defaultMessage: 'Lists' }, @@ -258,6 +259,10 @@ class SidebarMenu extends ImmutablePureComponent { {intl.formatMessage(messages.import_data)} + + + {intl.formatMessage(messages.account_aliases)} + {intl.formatMessage(messages.security)} diff --git a/app/soapbox/features/aliases/components/account.js b/app/soapbox/features/aliases/components/account.js new file mode 100644 index 000000000..dcdfe7814 --- /dev/null +++ b/app/soapbox/features/aliases/components/account.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { makeGetAccount } from '../../../selectors'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import { addToAliases } from '../../../actions/aliases'; + +const messages = defineMessages({ + add: { id: 'aliases.account.add', defaultMessage: 'Create alias' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId, added }) => { + const account = getAccount(state, accountId); + const apId = account.getIn(['pleroma', 'ap_id']); + + return { + account, + apId, + added: typeof added === 'undefined' ? state.getIn(['meta', 'pleroma', 'also_known_as']).includes(apId) : added, + me: state.get('me'), + }; + }; + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch) => ({ + onAdd: (intl, apId) => dispatch(addToAliases(intl, apId)), +}); + +export default @connect(makeMapStateToProps, mapDispatchToProps) +@injectIntl +class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + apId: PropTypes.string.isRequired, + intl: PropTypes.object.isRequired, + onAdd: PropTypes.func.isRequired, + added: PropTypes.bool, + }; + + static defaultProps = { + added: false, + }; + + handleOnAdd = () => this.props.onAdd(this.props.intl, this.props.apId); + + render() { + const { account, accountId, intl, added, me } = this.props; + + let button; + + if (!added && accountId !== me) { + button = ( +
+ +
+ ); + } + + return ( +
+
+
+
+ +
+ + {button} +
+
+ ); + } + +} diff --git a/app/soapbox/features/aliases/components/search.js b/app/soapbox/features/aliases/components/search.js new file mode 100644 index 000000000..1416b0390 --- /dev/null +++ b/app/soapbox/features/aliases/components/search.js @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import { fetchAliasesSuggestions, clearAliasesSuggestions, changeAliasesSuggestions } from '../../../actions/aliases'; +import classNames from 'classnames'; +import Icon from 'soapbox/components/icon'; +import Button from 'soapbox/components/button'; + +const messages = defineMessages({ + search: { id: 'aliases.search', defaultMessage: 'Search your old account' }, + searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Search' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['aliases', 'suggestions', 'value']), +}); + +const mapDispatchToProps = dispatch => ({ + onSubmit: value => dispatch(fetchAliasesSuggestions(value)), + onClear: () => dispatch(clearAliasesSuggestions()), + onChange: value => dispatch(changeAliasesSuggestions(value)), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class Search extends React.PureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + }; + + handleChange = e => { + this.props.onChange(e.target.value); + } + + handleKeyUp = e => { + if (e.keyCode === 13) { + this.props.onSubmit(this.props.value); + } + } + + handleSubmit = () => { + this.props.onSubmit(this.props.value); + } + + handleClear = () => { + this.props.onClear(); + } + + render() { + const { value, intl } = this.props; + const hasValue = value.length > 0; + + return ( +
+ + +
+ + +
+ +
+ ); + } + +} diff --git a/app/soapbox/features/aliases/index.js b/app/soapbox/features/aliases/index.js new file mode 100644 index 000000000..d094c116d --- /dev/null +++ b/app/soapbox/features/aliases/index.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Column from '../ui/components/column'; +import ColumnSubheading from '../ui/components/column_subheading'; +import ScrollableList from '../../components/scrollable_list'; +import Icon from 'soapbox/components/icon'; +import Search from './components/search'; +import Account from './components/account'; +import { removeFromAliases } from '../../actions/aliases'; + +const messages = defineMessages({ + heading: { id: 'column.aliases', defaultMessage: 'Account aliases' }, + subheading_add_new: { id: 'column.aliases.subheading_add_new', defaultMessage: 'Add New Alias' }, + create_error: { id: 'column.aliases.create_error', defaultMessage: 'Error creating alias' }, + delete_error: { id: 'column.aliases.delete_error', defaultMessage: 'Error deleting alias' }, + subheading_aliases: { id: 'column.aliases.subheading_aliases', defaultMessage: 'Current aliases' }, + delete: { id: 'column.aliases.delete', defaultMessage: 'Delete' }, +}); + +const mapStateToProps = state => ({ + aliases: state.getIn(['meta', 'pleroma', 'also_known_as']), + searchAccountIds: state.getIn(['aliases', 'suggestions', 'items']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Aliases extends ImmutablePureComponent { + + handleFilterDelete = e => { + const { dispatch, intl } = this.props; + dispatch(removeFromAliases(intl, e.currentTarget.dataset.value)); + } + + render() { + const { intl, aliases, searchAccountIds } = this.props; + + const emptyMessage = ; + + return ( + + + +
+ {searchAccountIds.map(accountId => )} +
+ +
+ + {aliases.map((alias, i) => ( +
+
+ + {alias} +
+
+ + +
+
+ ))} +
+
+
+ ); + } + +} \ No newline at end of file diff --git a/app/soapbox/features/ui/components/link_footer.js b/app/soapbox/features/ui/components/link_footer.js index 424f00356..6e2d864fa 100644 --- a/app/soapbox/features/ui/components/link_footer.js +++ b/app/soapbox/features/ui/components/link_footer.js @@ -38,6 +38,7 @@ const LinkFooter = ({ onOpenHotkeys, account, onClickLogOut }) => ( {isAdmin(account) &&
  • } {isAdmin(account) &&
  • }
  • +
  • }
  • diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 49d58aca7..7557d09c7 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -98,6 +98,7 @@ import { ScheduledStatuses, UserIndex, FederationRestrictions, + Aliases, } from './util/async-components'; // Dummy import, to make sure that ends up in the application bundle. @@ -261,6 +262,7 @@ class SwitchingColumnsArea extends React.PureComponent { + diff --git a/app/soapbox/features/ui/util/async-components.js b/app/soapbox/features/ui/util/async-components.js index c1b34b608..1e5645869 100644 --- a/app/soapbox/features/ui/util/async-components.js +++ b/app/soapbox/features/ui/util/async-components.js @@ -249,3 +249,7 @@ export function UserIndex() { export function FederationRestrictions() { return import(/* webpackChunkName: "features/federation_restrictions" */'../../federation_restrictions'); } + +export function Aliases() { + return import(/* webpackChunkName: "features/aliases" */'../../aliases'); +} diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index 712330361..6938bf429 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -110,6 +110,12 @@ "alert.unexpected.message": "Wystąpił nieoczekiwany błąd.", "alert.unexpected.return_home": "Wróć na stronę główną", "alert.unexpected.title": "O nie!", + "aliases.account_label": "Stare konto:", + "aliases.account.add": "Utwórz alias", + "aliases.aliases_list_delete": "Odłącz alias", + "aliases.search": "Szukaj swojego starego konta", + "aliases.success.add": "Pomyślnie utworzono alias konta", + "aliases.success.remove": "Pomyślnie usunięto alias konta", "audio.close": "Zamknij dźwięk", "audio.expand": "Rozwiń dźwięk", "audio.hide": "Ukryj dźwięk", @@ -143,6 +149,12 @@ "column.admin.moderation_log": "Dziennik moderacyjny", "column.admin.reports": "Zgłoszenia", "column.admin.reports.menu.moderation_log": "Dziennik moderacji", + "column.aliases": "Aliasy kont", + "column.aliases.create_error": "Błąd tworzenia aliasu", + "column.aliases.delete": "Usuń", + "column.aliases.delete_error": "Błąd usuwania aliasu", + "column.aliases.subheading_add_new": "Dodaj nowy alias", + "column.aliases.subheading_aliases": "Istniejące aliasy", "column.backups": "Kopie zapasowe", "column.blocks": "Zablokowani użytkownicy", "column.bookmarks": "Załadki", @@ -305,6 +317,7 @@ "emoji_button.travel": "Podróże i miejsca", "empty_column.account_timeline": "Brak wpisów tutaj!", "empty_column.account_unavailable": "Profil niedostępny", + "empty_column.aliases": "Nie utworzyłeś(-aś) jeszcze żadnego aliasu konta.", "empty_column.blocks": "Nie zablokowałeś(-aś) jeszcze żadnego użytkownika.", "empty_column.bookmarks": "Nie masz jeszcze żadnej zakładki. Kiedy dodasz jakąś, pojawi się ona tutaj.", "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!", @@ -480,6 +493,7 @@ "morefollows.followers_label": "…i {count} więcej {count, plural, one {obserwujący(-a)} few {obserwujących} many {obserwujących} other {obserwujących}} na zdalnych stronach.", "morefollows.following_label": "…i {count} więcej {count, plural, one {obserwowany(-a)} few {obserwowanych} many {obserwowanych} other {obserwowanych}} na zdalnych stronach.", "mute_modal.hide_notifications": "Chcesz ukryć powiadomienia od tego użytkownika?", + "navigation_bar.account_aliases": "Aliasy kont", "navigation_bar.admin_settings": "Ustawienia administracyjne", "navigation_bar.blocks": "Zablokowani użytkownicy", "navigation_bar.compose": "Utwórz nowy wpis", diff --git a/app/soapbox/reducers/aliases.js b/app/soapbox/reducers/aliases.js new file mode 100644 index 000000000..6a63b4d7b --- /dev/null +++ b/app/soapbox/reducers/aliases.js @@ -0,0 +1,29 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + ALIASES_SUGGESTIONS_READY, + ALIASES_SUGGESTIONS_CLEAR, + ALIASES_SUGGESTIONS_CHANGE, +} from '../actions/aliases'; + +const initialState = ImmutableMap({ + suggestions: ImmutableMap({ + value: '', + items: ImmutableList(), + }), +}); + +export default function aliasesReducer(state = initialState, action) { + switch(action.type) { + case ALIASES_SUGGESTIONS_CHANGE: + return state.setIn(['suggestions', 'value'], action.value); + case ALIASES_SUGGESTIONS_READY: + return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id))); + case ALIASES_SUGGESTIONS_CLEAR: + return state.update('suggestions', suggestions => suggestions.withMutations(map => { + map.set('items', ImmutableList()); + map.set('value', ''); + })); + default: + return state; + } +}; diff --git a/app/soapbox/reducers/index.js b/app/soapbox/reducers/index.js index 751a04afb..cf62b4fdf 100644 --- a/app/soapbox/reducers/index.js +++ b/app/soapbox/reducers/index.js @@ -53,6 +53,7 @@ import backups from './backups'; import admin_log from './admin_log'; import security from './security'; import scheduled_statuses from './scheduled_statuses'; +import aliases from './aliases'; const appReducer = combineReducers({ dropdown_menu, @@ -107,6 +108,7 @@ const appReducer = combineReducers({ admin_log, security, scheduled_statuses, + aliases, }); // Clear the state (mostly) when the user logs out diff --git a/app/styles/application.scss b/app/styles/application.scss index d247b3a7b..09b2aa355 100644 --- a/app/styles/application.scss +++ b/app/styles/application.scss @@ -84,6 +84,7 @@ @import 'components/datepicker'; @import 'components/remote-timeline'; @import 'components/federation-restrictions'; +@import 'components/aliases'; // Holiday @import 'holiday/halloween'; diff --git a/app/styles/components/aliases.scss b/app/styles/components/aliases.scss new file mode 100644 index 000000000..0709e1715 --- /dev/null +++ b/app/styles/components/aliases.scss @@ -0,0 +1,102 @@ +.aliases { + &__accounts { + overflow-y: auto; + + .account__display-name { + &:hover strong { + text-decoration: none; + } + } + + .account__avatar { + cursor: default; + } + } + + &_search { + display: flex; + flex-direction: row; + margin: 10px; + + .search__input { + padding: 7px 30px 6px 10px; + } + + > label { + flex: 1 1; + } + + > .search__icon .fa { + top: 8px; + right: 102px !important; + } + + > .button { + width: 80px; + margin-left: 10px; + } + } +} + +.aliases-settings-panel { + flex: 1; + + .item-list article { + border-bottom: 1px solid var(--primary-text-color--faint); + + &:last-child { + border-bottom: 0; + } + } + + .alias__container { + padding: 20px; + display: flex; + justify-content: space-between; + font-size: 14px; + + span.alias__list-label { + padding-right: 5px; + color: var(--primary-text-color--faint); + } + + span.alias__list-value span { + padding-right: 5px; + text-transform: capitalize; + + &::after { + content: ','; + } + + &:last-of-type { + &::after { + content: ''; + } + } + } + + .alias__delete { + display: flex; + align-items: baseline; + cursor: pointer; + + span.alias__delete-label { + color: var(--primary-text-color--faint); + font-size: 14px; + font-weight: 800; + } + + .alias__delete-icon { + background: none; + color: var(--primary-text-color--faint); + padding: 0 5px; + margin: 0 auto; + font-size: 16px; + } + } + } + + .slist--flex { + height: 100%; + } +}