diff --git a/app/soapbox/__fixtures__/mastodon-3.0.0-instance.json b/app/soapbox/__fixtures__/mastodon-3.0.0-instance.json new file mode 100644 index 000000000..f1d0a5e6d --- /dev/null +++ b/app/soapbox/__fixtures__/mastodon-3.0.0-instance.json @@ -0,0 +1,43 @@ +{ + "uri": "animalliberation.social", + "title": "Animal Liberation Network", + "short_description": "", + "description": "Animal Liberation Network is a community for animal activists on the Fediverse. You can connect with other activists through the local timeline, as well as spread your activism to the outside world with the federated timeline.", + "email": "alex@alexgleason.me", + "version": "3.0.0", + "urls": { + "streaming_api": "wss://animalliberation.social" + }, + "stats": { + "user_count": 662, + "status_count": 2904, + "domain_count": 4003 + }, + "thumbnail": "https://animalliberation.social/packs/media/images/preview-9a17d32fc48369e8ccd910a75260e67d.jpg", + "languages": [ + "en" + ], + "registrations": true, + "approval_required": false, + "contact_account": { + "id": "1", + "username": "alex", + "acct": "alex", + "display_name": "Alex Gleason", + "locked": false, + "bot": false, + "created_at": "2016-11-30T22:19:42.956Z", + "note": "
Animal liberation free software Communist
", + "url": "https://animalliberation.social/@alex", + "avatar": "https://media.animalliberation.social/accounts/avatars/000/000/001/original/media.jpg", + "avatar_static": "https://media.animalliberation.social/accounts/avatars/000/000/001/original/media.jpg", + "header": "https://media.animalliberation.social/accounts/headers/000/000/001/original/09887023017e02c9.jpg", + "header_static": "https://media.animalliberation.social/accounts/headers/000/000/001/original/09887023017e02c9.jpg", + "followers_count": 236, + "following_count": 83, + "statuses_count": 357, + "last_status_at": "2021-02-20T19:28:24.353Z", + "emojis": [], + "fields": [] + } +} diff --git a/app/soapbox/__fixtures__/mastodon-instance.json b/app/soapbox/__fixtures__/mastodon-instance.json new file mode 100644 index 000000000..3c8a2f9d3 --- /dev/null +++ b/app/soapbox/__fixtures__/mastodon-instance.json @@ -0,0 +1,128 @@ +{ + "uri": "mastodon.social", + "title": "Mastodon", + "short_description": "Server run by the main developers of the project It is not focused on any particular niche interest - everyone is welcome as long as you follow our code of conduct!", + "description": "Server run by the main developers of the project It is not focused on any particular niche interest - everyone is welcome as long as you follow our code of conduct!", + "email": "staff@mastodon.social", + "version": "3.4.3", + "urls": { + "streaming_api": "wss://mastodon.social" + }, + "stats": { + "user_count": 619022, + "status_count": 33914684, + "domain_count": 21524 + }, + "thumbnail": "https://files.mastodon.social/site_uploads/files/000/000/001/original/vlcsnap-2018-08-27-16h43m11s127.png", + "languages": [ + "en" + ], + "registrations": true, + "approval_required": false, + "invites_enabled": true, + "configuration": { + "statuses": { + "max_characters": 500, + "max_media_attachments": 4, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/png", + "image/gif", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/ogg", + "audio/vorbis", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf" + ], + "image_size_limit": 10485760, + "image_matrix_limit": 16777216, + "video_size_limit": 41943040, + "video_frame_rate_limit": 60, + "video_matrix_limit": 2304000 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 50, + "min_expiration": 300, + "max_expiration": 2629746 + } + }, + "contact_account": { + "id": "1", + "username": "Gargron", + "acct": "Gargron", + "display_name": "Eugen 🎄", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2016-03-16T00:00:00.000Z", + "note": "Founder, CEO and lead developer @Mastodon, Germany.
", + "url": "https://mastodon.social/@Gargron", + "avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/ccb05a778962e171.png", + "avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/ccb05a778962e171.png", + "header": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", + "header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", + "followers_count": 98343, + "following_count": 271, + "statuses_count": 71288, + "last_status_at": "2022-01-31", + "emojis": [], + "fields": [ + { + "name": "Patreon", + "value": "https://www.patreon.com/mastodon", + "verified_at": null + }, + { + "name": "Homepage", + "value": "https://zeonfederated.com", + "verified_at": "2019-07-15T18:29:57.191+00:00" + } + ] + }, + "rules": [ + { + "id": "1", + "text": "Sexually explicit or violent media must be marked as sensitive when posting" + }, + { + "id": "2", + "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism" + }, + { + "id": "3", + "text": "No incitement of violence or promotion of violent ideologies" + }, + { + "id": "4", + "text": "No harassment, dogpiling or doxxing of other users" + }, + { + "id": "5", + "text": "No content illegal in Germany" + }, + { + "id": "6", + "text": "No spam, advertising or bot accounts" + } + ] +} diff --git a/app/soapbox/__fixtures__/pleroma-instance.json b/app/soapbox/__fixtures__/pleroma-instance.json new file mode 100644 index 000000000..b91376302 --- /dev/null +++ b/app/soapbox/__fixtures__/pleroma-instance.json @@ -0,0 +1,131 @@ +{ + "approval_required": true, + "avatar_upload_limit": 2000000, + "background_image": "https://gleasonator.com/images/city.jpg", + "background_upload_limit": 4000000, + "banner_upload_limit": 4000000, + "description": "Building the next generation of the Fediverse. Speak freely.", + "description_limit": 5000, + "email": "alex@alexgleason.me", + "languages": [ + "en" + ], + "max_toot_chars": 5000, + "pleroma": { + "metadata": { + "account_activation_required": false, + "birthday_min_age": 0, + "birthday_required": false, + "features": [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "v2_suggestions", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + "quote_posting", + "media_proxy", + "relay", + "pleroma_emoji_reactions", + "pleroma_chat_messages", + "email_list", + "profile_directory" + ], + "federation": { + "enabled": true, + "exclusions": false, + "mrf_hashtag": { + "federated_timeline_removal": [], + "reject": [], + "sensitive": [ + "nsfw" + ] + }, + "mrf_policies": [ + "TagPolicy", + "SimplePolicy", + "InlineQuotePolicy", + "HashtagPolicy" + ], + "mrf_simple": { + "accept": [], + "avatar_removal": [ + "pawoo.net", + "sinblr.com", + "dajiaweibo.com", + "baraag.net" + ], + "banner_removal": [ + "pawoo.net", + "sinblr.com", + "dajiaweibo.com", + "baraag.net" + ], + "federated_timeline_removal": [], + "followers_only": [], + "media_nsfw": [], + "media_removal": [ + "pawoo.net", + "sinblr.com", + "dajiaweibo.com", + "baraag.net" + ], + "reject": [ + "solagg.com" + ], + "reject_deletes": [], + "report_removal": [] + }, + "mrf_simple_info": {}, + "quarantined_instances": [], + "quarantined_instances_info": { + "quarantined_instances": {} + } + }, + "fields_limits": { + "max_fields": 15, + "max_remote_fields": 20, + "name_length": 512, + "value_length": 2048 + }, + "post_formats": [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode" + ], + "privileged_staff": true + }, + "stats": { + "mau": 71 + }, + "vapid_public_key": "BLElLQVJVmY_e4F5JoYxI5jXiVOYNsJ9p-amkykc9NcI-jwa9T1Y2GIbDqbY-HqC6ayPkfW4K4o9vgBFKYmkuS4" + }, + "poll_limits": { + "max_expiration": 31536000, + "max_option_chars": 200, + "max_options": 20, + "min_expiration": 0 + }, + "registrations": true, + "shout_limit": 5000, + "soapbox": { + "version": "1.1.1" + }, + "stats": { + "domain_count": 8140, + "status_count": 101956, + "user_count": 421 + }, + "thumbnail": "https://media.gleasonator.com/c0d38bde6ef0b3baa483f574797662ebd83ef9e1a1162e8e4fcd930bb4b3c068.png", + "title": "Gleasonator", + "upload_limit": 100000000, + "uri": "https://gleasonator.com", + "urls": { + "streaming_api": "wss://gleasonator.com" + }, + "version": "2.7.2 (compatible; Pleroma 2.4.51-1129-gf2cfef09-gleasonator)" +} diff --git a/app/soapbox/actions/accounts.js b/app/soapbox/actions/accounts.js index aacdcd106..23aad5601 100644 --- a/app/soapbox/actions/accounts.js +++ b/app/soapbox/actions/accounts.js @@ -109,6 +109,10 @@ export const NOTIFICATION_SETTINGS_REQUEST = 'NOTIFICATION_SETTINGS_REQUEST'; export const NOTIFICATION_SETTINGS_SUCCESS = 'NOTIFICATION_SETTINGS_SUCCESS'; export const NOTIFICATION_SETTINGS_FAIL = 'NOTIFICATION_SETTINGS_FAIL'; +export const BIRTHDAY_REMINDERS_FETCH_REQUEST = 'BIRTHDAY_REMINDERS_FETCH_REQUEST'; +export const BIRTHDAY_REMINDERS_FETCH_SUCCESS = 'BIRTHDAY_REMINDERS_FETCH_SUCCESS'; +export const BIRTHDAY_REMINDERS_FETCH_FAIL = 'BIRTHDAY_REMINDERS_FETCH_FAIL'; + export function createAccount(params) { return (dispatch, getState) => { dispatch({ type: ACCOUNT_CREATE_REQUEST, params }); @@ -156,14 +160,7 @@ export function fetchAccountByUsername(username) { const features = getFeatures(instance); const me = state.get('me'); - if (!me && features.accountLookup) { - dispatch(accountLookup(username)).then(account => { - dispatch(fetchAccountSuccess(account)); - }).catch(error => { - dispatch(fetchAccountFail(null, error)); - dispatch(importErrorWhileFetchingAccountByUsername(username)); - }); - } else if (features.accountByUsername) { + if (features.accountByUsername && (me || !features.accountLookup)) { api(getState).get(`/api/v1/accounts/${username}`).then(response => { dispatch(fetchRelationships([response.data.id])); dispatch(importFetchedAccount(response.data)); @@ -172,6 +169,13 @@ export function fetchAccountByUsername(username) { dispatch(fetchAccountFail(null, error)); dispatch(importErrorWhileFetchingAccountByUsername(username)); }); + } else if (features.accountLookup) { + dispatch(accountLookup(username)).then(account => { + dispatch(fetchAccountSuccess(account)); + }).catch(error => { + dispatch(fetchAccountFail(null, error)); + dispatch(importErrorWhileFetchingAccountByUsername(username)); + }); } else { dispatch(accountSearch({ q: username, @@ -217,7 +221,7 @@ export function fetchAccountFail(id, error) { }; } -export function followAccount(id, reblogs = true) { +export function followAccount(id, options = { reblogs: true }) { return (dispatch, getState) => { if (!isLoggedIn(getState)) return; @@ -226,7 +230,7 @@ export function followAccount(id, reblogs = true) { dispatch(followAccountRequest(id, locked)); - api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => { + api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => { dispatch(followAccountSuccess(response.data, alreadyFollowing)); }).catch(error => { dispatch(followAccountFail(error, locked)); @@ -1030,3 +1034,26 @@ export function accountLookup(acct, cancelToken) { }); }; } + +export function fetchBirthdayReminders(day, month) { + return (dispatch, getState) => { + if (!isLoggedIn(getState)) return; + + const me = getState().get('me'); + + dispatch({ type: BIRTHDAY_REMINDERS_FETCH_REQUEST, day, month, id: me }); + + api(getState).get('/api/v1/pleroma/birthdays', { params: { day, month } }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch({ + type: BIRTHDAY_REMINDERS_FETCH_SUCCESS, + accounts: response.data, + day, + month, + id: me, + }); + }).catch(error => { + dispatch({ type: BIRTHDAY_REMINDERS_FETCH_FAIL, day, month, id: me }); + }); + }; +} diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js index 4eca355d4..03139a465 100644 --- a/app/soapbox/actions/compose.js +++ b/app/soapbox/actions/compose.js @@ -81,6 +81,7 @@ const messages = defineMessages({ uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' }, success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' }, + view: { id: 'snackbar.view', defaultMessage: 'View' }, }); const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1); @@ -190,7 +191,7 @@ export function handleComposeSubmit(dispatch, getState, data, status) { dispatch(insertIntoTagHistory(data.tags || [], status)); dispatch(submitComposeSuccess({ ...data })); - dispatch(snackbar.success(messages.success)); + dispatch(snackbar.success(messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`)); } const needsDescriptions = state => { @@ -296,8 +297,7 @@ export function submitComposeFail(error) { export function uploadCompose(files) { return function(dispatch, getState) { if (!isLoggedIn(getState)) return; - const instance = getState().get('instance'); - const { attachmentLimit } = getFeatures(instance); + const attachmentLimit = getState().getIn(['instance', 'configuration', 'statuses', 'max_media_attachments']); const media = getState().getIn(['compose', 'media_attachments']); const progress = new Array(files.length).fill(0); diff --git a/app/soapbox/actions/interactions.js b/app/soapbox/actions/interactions.js index d7f27a96c..bb0461411 100644 --- a/app/soapbox/actions/interactions.js +++ b/app/soapbox/actions/interactions.js @@ -58,6 +58,7 @@ export const REMOTE_INTERACTION_FAIL = 'REMOTE_INTERACTION_FAIL'; const messages = defineMessages({ bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' }, bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' }, + view: { id: 'snackbar.view', defaultMessage: 'View' }, }); export function reblog(status) { @@ -220,28 +221,28 @@ export function unfavouriteFail(status, error) { }; } -export function bookmark(intl, status) { +export function bookmark(status) { return function(dispatch, getState) { dispatch(bookmarkRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function(response) { dispatch(importFetchedStatus(response.data)); dispatch(bookmarkSuccess(status, response.data)); - dispatch(snackbar.success(intl.formatMessage(messages.bookmarkAdded))); + dispatch(snackbar.success(messages.bookmarkAdded, messages.view, '/bookmarks')); }).catch(function(error) { dispatch(bookmarkFail(status, error)); }); }; } -export function unbookmark(intl, status) { +export function unbookmark(status) { return (dispatch, getState) => { dispatch(unbookmarkRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unbookmarkSuccess(status, response.data)); - dispatch(snackbar.success(intl.formatMessage(messages.bookmarkRemoved))); + dispatch(snackbar.success(messages.bookmarkRemoved)); }).catch(error => { dispatch(unbookmarkFail(status, error)); }); diff --git a/app/soapbox/actions/settings.js b/app/soapbox/actions/settings.js index 4a50909fa..418c41292 100644 --- a/app/soapbox/actions/settings.js +++ b/app/soapbox/actions/settings.js @@ -100,6 +100,10 @@ export const defaultSettings = ImmutableMap({ move: false, 'pleroma:emoji_reaction': false, }), + + birthdays: ImmutableMap({ + show: true, + }), }), community: ImmutableMap({ diff --git a/app/soapbox/actions/snackbar.js b/app/soapbox/actions/snackbar.js index c1be02757..47fd11137 100644 --- a/app/soapbox/actions/snackbar.js +++ b/app/soapbox/actions/snackbar.js @@ -1,21 +1,23 @@ import { ALERT_SHOW } from './alerts'; -export const show = (severity, message) => ({ +export const show = (severity, message, actionLabel, actionLink) => ({ type: ALERT_SHOW, message, + actionLabel, + actionLink, severity, }); -export function info(message) { - return show('info', message); +export function info(message, actionLabel, actionLink) { + return show('info', message, actionLabel, actionLink); } -export function success(message) { - return show('success', message); +export function success(message, actionLabel, actionLink) { + return show('success', message, actionLabel, actionLink); } -export function error(message) { - return show('error', message); +export function error(message, actionLabel, actionLink) { + return show('error', message, actionLabel, actionLink); } export default { diff --git a/app/soapbox/components/birthday_input.js b/app/soapbox/components/birthday_input.js new file mode 100644 index 000000000..12f42bb59 --- /dev/null +++ b/app/soapbox/components/birthday_input.js @@ -0,0 +1,130 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DatePicker from 'react-datepicker'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl } from 'react-intl'; +import { connect } from 'react-redux'; +import 'react-datepicker/dist/react-datepicker.css'; + +import IconButton from 'soapbox/components/icon_button'; +import { getFeatures } from 'soapbox/utils/features'; + +const messages = defineMessages({ + birthdayPlaceholder: { id: 'edit_profile.fields.birthday_placeholder', defaultMessage: 'Your birthday' }, + previousMonth: { id: 'datepicker.previous_month', defaultMessage: 'Previous month' }, + nextMonth: { id: 'datepicker.next_month', defaultMessage: 'Next month' }, + previousYear: { id: 'datepicker.previous_year', defaultMessage: 'Previous year' }, + nextYear: { id: 'datepicker.next_year', defaultMessage: 'Next year' }, +}); + +const mapStateToProps = state => { + const features = getFeatures(state.get('instance')); + + return { + supportsBirthdays: features.birthdays, + minAge: state.getIn(['instance', 'pleroma', 'metadata', 'birthday_min_age']), + }; +}; + +export default @connect(mapStateToProps) +@injectIntl +class EditProfile extends ImmutablePureComponent { + + static propTypes = { + hint: PropTypes.node, + required: PropTypes.bool, + supportsBirthdays: PropTypes.bool, + minAge: PropTypes.number, + onChange: PropTypes.func.isRequired, + value: PropTypes.instanceOf(Date), + }; + + renderHeader = ({ + decreaseMonth, + increaseMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, + decreaseYear, + increaseYear, + prevYearButtonDisabled, + nextYearButtonDisabled, + date, + }) => { + const { intl } = this.props; + + return ( +