diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bc876d1504..00f5c7c11e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -176,7 +176,10 @@ class ApplicationController < ActionController::Base return unless self_destruct? respond_to do |format| - format.any { render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html] } + format.any do + use_pack 'error' + render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html] + end format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: code } end end diff --git a/app/javascript/flavours/glitch/actions/account_notes.js b/app/javascript/flavours/glitch/actions/account_notes.js deleted file mode 100644 index 62a6b4cbb8..0000000000 --- a/app/javascript/flavours/glitch/actions/account_notes.js +++ /dev/null @@ -1,69 +0,0 @@ -import api from '../api'; - -export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; -export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; -export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; - -export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT'; -export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL'; - -export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT'; - -export function submitAccountNote() { - return (dispatch, getState) => { - dispatch(submitAccountNoteRequest()); - - const id = getState().getIn(['account_notes', 'edit', 'account_id']); - - api(getState).post(`/api/v1/accounts/${id}/note`, { - comment: getState().getIn(['account_notes', 'edit', 'comment']), - }).then(response => { - dispatch(submitAccountNoteSuccess(response.data)); - }).catch(error => dispatch(submitAccountNoteFail(error))); - }; -} - -export function submitAccountNoteRequest() { - return { - type: ACCOUNT_NOTE_SUBMIT_REQUEST, - }; -} - -export function submitAccountNoteSuccess(relationship) { - return { - type: ACCOUNT_NOTE_SUBMIT_SUCCESS, - relationship, - }; -} - -export function submitAccountNoteFail(error) { - return { - type: ACCOUNT_NOTE_SUBMIT_FAIL, - error, - }; -} - -export function initEditAccountNote(account) { - return (dispatch, getState) => { - const comment = getState().getIn(['relationships', account.get('id'), 'note']); - - dispatch({ - type: ACCOUNT_NOTE_INIT_EDIT, - account, - comment, - }); - }; -} - -export function cancelAccountNote() { - return { - type: ACCOUNT_NOTE_CANCEL, - }; -} - -export function changeAccountNoteComment(comment) { - return { - type: ACCOUNT_NOTE_CHANGE_COMMENT, - comment, - }; -} diff --git a/app/javascript/flavours/glitch/actions/account_notes.ts b/app/javascript/flavours/glitch/actions/account_notes.ts new file mode 100644 index 0000000000..dbe9ee2a9f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/account_notes.ts @@ -0,0 +1,18 @@ +import { createAppAsyncThunk } from 'flavours/glitch/store/typed_functions'; + +import api from '../api'; + +export const submitAccountNote = createAppAsyncThunk( + 'account_note/submit', + async (args: { id: string; value: string }, { getState }) => { + // TODO: replace `unknown` with `ApiRelationshipJSON` when it is merged + const response = await api(getState).post( + `/api/v1/accounts/${args.id}/note`, + { + comment: args.value, + }, + ); + + return { relationship: response.data }; + }, +); diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js index 0220b0af58..42834146bf 100644 --- a/app/javascript/flavours/glitch/actions/alerts.js +++ b/app/javascript/flavours/glitch/actions/alerts.js @@ -12,52 +12,48 @@ export const ALERT_DISMISS = 'ALERT_DISMISS'; export const ALERT_CLEAR = 'ALERT_CLEAR'; export const ALERT_NOOP = 'ALERT_NOOP'; -export function dismissAlert(alert) { - return { - type: ALERT_DISMISS, - alert, - }; -} +export const dismissAlert = alert => ({ + type: ALERT_DISMISS, + alert, +}); -export function clearAlert() { - return { - type: ALERT_CLEAR, - }; -} +export const clearAlert = () => ({ + type: ALERT_CLEAR, +}); -export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) { - return { - type: ALERT_SHOW, - title, - message, - message_values, - }; -} +export const showAlert = alert => ({ + type: ALERT_SHOW, + alert, +}); -export function showAlertForError(error, skipNotFound = false) { +export const showAlertForError = (error, skipNotFound = false) => { if (error.response) { const { data, status, statusText, headers } = error.response; + // Skip these errors as they are reflected in the UI if (skipNotFound && (status === 404 || status === 410)) { - // Skip these errors as they are reflected in the UI return { type: ALERT_NOOP }; } + // Rate limit errors if (status === 429 && headers['x-ratelimit-reset']) { - const reset_date = new Date(headers['x-ratelimit-reset']); - return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date }); + return showAlert({ + title: messages.rateLimitedTitle, + message: messages.rateLimitedMessage, + values: { 'retry_time': new Date(headers['x-ratelimit-reset']) }, + }); } - let message = statusText; - let title = `${status}`; - - if (data.error) { - message = data.error; - } - - return showAlert(title, message); - } else { - console.error(error); - return showAlert(); + return showAlert({ + title: `${status}`, + message: data.error || statusText, + }); } -} + + console.error(error); + + return showAlert({ + title: messages.unexpectedTitle, + message: messages.unexpectedMessage, + }); +}; diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index fa31d5102b..904057f5cc 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -85,9 +85,13 @@ export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTIO export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; +export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, + open: { id: 'compose.published.open', defaultMessage: 'Open' }, + published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, + saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, }); export const ensureComposeIsVisible = (getState, routerHistory) => { @@ -144,6 +148,15 @@ export function resetCompose() { }; } +export const focusCompose = (routerHistory, defaultText) => dispatch => { + dispatch({ + type: COMPOSE_FOCUS, + defaultText, + }); + + ensureComposeIsVisible(routerHistory); +}; + export function mentionCompose(account, routerHistory) { return (dispatch, getState) => { dispatch({ @@ -264,6 +277,13 @@ export function submitCompose(routerHistory) { } else if (statusId === null && response.data.visibility === 'direct') { insertIfOnline('direct'); } + + dispatch(showAlert({ + message: statusId === null ? messages.published : messages.saved, + action: messages.open, + dismissAfter: 10000, + onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), + })); }).catch(function (error) { dispatch(submitComposeFail(error)); }); @@ -307,13 +327,14 @@ export function tenorSet(options) { export function uploadCompose(files, alt = '') { return function (dispatch, getState) { const uploadLimit = 4; - const media = getState().getIn(['compose', 'media_attachments']); - const pending = getState().getIn(['compose', 'pending_media_attachments']); + const media = getState().getIn(['compose', 'media_attachments']); + const pending = getState().getIn(['compose', 'pending_media_attachments']); const progress = new Array(files.length).fill(0); + let total = Array.from(files).reduce((a, v) => a + v.size, 0); if (files.length + media.size + pending > uploadLimit) { - dispatch(showAlert(undefined, messages.uploadErrorLimit)); + dispatch(showAlert({ message: messages.uploadErrorLimit })); return; } diff --git a/app/javascript/flavours/glitch/actions/onboarding.js b/app/javascript/flavours/glitch/actions/onboarding.js index a4a525c427..a1dd3a731e 100644 --- a/app/javascript/flavours/glitch/actions/onboarding.js +++ b/app/javascript/flavours/glitch/actions/onboarding.js @@ -1,16 +1,8 @@ -import { openModal } from './modal'; import { changeSetting, saveSettings } from './settings'; -export function showOnboardingOnce() { - return (dispatch, getState) => { - const alreadySeen = getState().getIn(['settings', 'onboarded']); +export const INTRODUCTION_VERSION = 20181216044202; - if (!alreadySeen) { - dispatch(openModal({ - modalType: 'ONBOARDING', - })); - dispatch(changeSetting(['onboarded'], true)); - dispatch(saveSettings()); - } - }; -} +export const closeOnboarding = () => dispatch => { + dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); + dispatch(saveSettings()); +}; diff --git a/app/javascript/flavours/glitch/api.js b/app/javascript/flavours/glitch/api.js deleted file mode 100644 index 948ffbc95c..0000000000 --- a/app/javascript/flavours/glitch/api.js +++ /dev/null @@ -1,75 +0,0 @@ -// @ts-check - -import axios from 'axios'; -import LinkHeader from 'http-link-header'; - -import ready from './ready'; -/** - * @param {import('axios').AxiosResponse} response - * @returns {LinkHeader} - */ -export const getLinks = response => { - const value = response.headers.link; - - if (!value) { - return new LinkHeader(); - } - - return LinkHeader.parse(value); -}; - -/** @type {import('axios').RawAxiosRequestHeaders} */ -const csrfHeader = {}; - -/** - * @returns {void} - */ -const setCSRFHeader = () => { - /** @type {HTMLMetaElement | null} */ - const csrfToken = document.querySelector('meta[name=csrf-token]'); - - if (csrfToken) { - csrfHeader['X-CSRF-Token'] = csrfToken.content; - } -}; - -ready(setCSRFHeader); - -/** - * @param {() => import('immutable').Map} getState - * @returns {import('axios').RawAxiosRequestHeaders} - */ -const authorizationHeaderFromState = getState => { - const accessToken = getState && getState().getIn(['meta', 'access_token'], ''); - - if (!accessToken) { - return {}; - } - - return { - 'Authorization': `Bearer ${accessToken}`, - }; -}; - -/** - * @param {() => import('immutable').Map} getState - * @returns {import('axios').AxiosInstance} - */ -export default function api(getState) { - return axios.create({ - headers: { - ...csrfHeader, - ...authorizationHeaderFromState(getState), - }, - - transformResponse: [ - function (data) { - try { - return JSON.parse(data); - } catch { - return data; - } - }, - ], - }); -} diff --git a/app/javascript/flavours/glitch/api.ts b/app/javascript/flavours/glitch/api.ts new file mode 100644 index 0000000000..f262fd8570 --- /dev/null +++ b/app/javascript/flavours/glitch/api.ts @@ -0,0 +1,63 @@ +import type { AxiosResponse, RawAxiosRequestHeaders } from 'axios'; +import axios from 'axios'; +import LinkHeader from 'http-link-header'; + +import ready from './ready'; +import type { GetState } from './store'; + +export const getLinks = (response: AxiosResponse) => { + const value = response.headers.link as string | undefined; + + if (!value) { + return new LinkHeader(); + } + + return LinkHeader.parse(value); +}; + +const csrfHeader: RawAxiosRequestHeaders = {}; + +const setCSRFHeader = () => { + const csrfToken = document.querySelector( + 'meta[name=csrf-token]', + ); + + if (csrfToken) { + csrfHeader['X-CSRF-Token'] = csrfToken.content; + } +}; + +void ready(setCSRFHeader); + +const authorizationHeaderFromState = (getState?: GetState) => { + const accessToken = + getState && (getState().meta.get('access_token', '') as string); + + if (!accessToken) { + return {}; + } + + return { + Authorization: `Bearer ${accessToken}`, + } as RawAxiosRequestHeaders; +}; + +// eslint-disable-next-line import/no-default-export +export default function api(getState: GetState) { + return axios.create({ + headers: { + ...csrfHeader, + ...authorizationHeaderFromState(getState), + }, + + transformResponse: [ + function (data: unknown) { + try { + return JSON.parse(data as string) as unknown; + } catch { + return data; + } + }, + ], + }); +} diff --git a/app/javascript/flavours/glitch/components/account.jsx b/app/javascript/flavours/glitch/components/account.jsx index 78cf59e345..6342ef6f48 100644 --- a/app/javascript/flavours/glitch/components/account.jsx +++ b/app/javascript/flavours/glitch/components/account.jsx @@ -1,15 +1,21 @@ import PropTypes from 'prop-types'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { Skeleton } from 'flavours/glitch/components/skeleton'; +import { EmptyAccount } from 'flavours/glitch/components/empty_account'; +import { ShortNumber } from 'flavours/glitch/components/short_number'; +import { VerifiedBadge } from 'flavours/glitch/components/verified_badge'; import { me } from '../initial_state'; import { Avatar } from './avatar'; +import { Button } from './button'; +import { FollowersCounter } from './counters'; import { DisplayName } from './display_name'; import { IconButton } from './icon_button'; import Permalink from './permalink'; @@ -18,13 +24,13 @@ import { RelativeTimestamp } from './relative_timestamp'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, - unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, - unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, - mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' }, - unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' }, - mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, - block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' }, + unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, + unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, + mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' }, + unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' }, + mute: { id: 'account.mute_short', defaultMessage: 'Mute' }, + block: { id: 'account.block_short', defaultMessage: 'Block' }, }); class Account extends ImmutablePureComponent { @@ -38,14 +44,16 @@ class Account extends ImmutablePureComponent { onMuteNotifications: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, hidden: PropTypes.bool, + minimal: PropTypes.bool, actionIcon: PropTypes.string, actionTitle: PropTypes.string, defaultAction: PropTypes.string, onActionClick: PropTypes.func, + withBio: PropTypes.bool, }; static defaultProps = { - size: 36, + size: 46, }; handleFollow = () => { @@ -73,19 +81,10 @@ class Account extends ImmutablePureComponent { }; render () { - const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size } = this.props; + const { account, intl, hidden, withBio, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props; if (!account) { - return ( -
-
-
-
- -
-
-
- ); + return ; } if (hidden) { @@ -99,61 +98,91 @@ class Account extends ImmutablePureComponent { let buttons; - if (onActionClick) { - if (actionIcon) { - buttons = ; - } - } else if (account.get('id') !== me && account.get('relationship', null) !== null) { + if (actionIcon && onActionClick) { + buttons = ; + } else if (!actionIcon && account.get('id') !== me && account.get('relationship', null) !== null) { const following = account.getIn(['relationship', 'following']); const requested = account.getIn(['relationship', 'requested']); const blocking = account.getIn(['relationship', 'blocking']); const muting = account.getIn(['relationship', 'muting']); if (requested) { - buttons = ; + buttons = -
- -
- ); - } else { - action_buttons = ( -
- -
- ); - } - - let note_container = null; - if (isEditing) { - note_container = ( -