diff options
author | Anthony Bellew <anthonyreflected@gmail.com> | 2017-01-25 20:53:57 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-01-25 20:53:57 -0700 |
commit | 3d890c407356c8e0e7dd9b64e8e232ededcff8e8 (patch) | |
tree | a22df9a8737250f97a6024943af3445a163917b3 /app | |
parent | febe2449bb14f3d877fb934ceb6d52e320712bac (diff) | |
parent | 905c82917959a5afe24cb85c62c0b0ba13f0da8b (diff) |
Merge pull request #3 from tootsuite/master
Updating to current
Diffstat (limited to 'app')
197 files changed, 3223 insertions, 1082 deletions
diff --git a/app/assets/images/background-photo.jpeg b/app/assets/images/background-photo.jpeg index 4390fca66..b0a88ff35 100644 --- a/app/assets/images/background-photo.jpeg +++ b/app/assets/images/background-photo.jpeg Binary files differdiff --git a/app/assets/images/boost_sprite.png b/app/assets/images/boost_sprite.png new file mode 100644 index 000000000..564bf2646 --- /dev/null +++ b/app/assets/images/boost_sprite.png Binary files differdiff --git a/app/assets/javascripts/application_public.js b/app/assets/javascripts/application_public.js index f131a267a..9626c5dae 100644 --- a/app/assets/javascripts/application_public.js +++ b/app/assets/javascripts/application_public.js @@ -1,3 +1,8 @@ //= require jquery //= require jquery_ujs //= require extras +//= require best_in_place + +$(function () { + $(".best_in_place").best_in_place(); +}); diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx index 8d28b051f..0be05034e 100644 --- a/app/assets/javascripts/components/actions/accounts.jsx +++ b/app/assets/javascripts/components/actions/accounts.jsx @@ -1,8 +1,6 @@ import api, { getLinks } from '../api' import Immutable from 'immutable'; -export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF'; - export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; @@ -67,13 +65,6 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; -export function setAccountSelf(account) { - return { - type: ACCOUNT_SET_SELF, - account - }; -}; - export function fetchAccount(id) { return (dispatch, getState) => { dispatch(fetchAccountRequest(id)); @@ -89,32 +80,39 @@ export function fetchAccount(id) { export function fetchAccountTimeline(id, replace = false) { return (dispatch, getState) => { - dispatch(fetchAccountTimelineRequest(id)); - - const ids = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()); + const ids = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()); const newestId = ids.size > 0 ? ids.first() : null; let params = ''; + let skipLoading = false; if (newestId !== null && !replace) { - params = `?since_id=${newestId}`; + params = `?since_id=${newestId}`; + skipLoading = true; } + dispatch(fetchAccountTimelineRequest(id, skipLoading)); + api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => { - dispatch(fetchAccountTimelineSuccess(id, response.data, replace)); + dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading)); }).catch(error => { - dispatch(fetchAccountTimelineFail(id, error)); + dispatch(fetchAccountTimelineFail(id, error, skipLoading)); }); }; }; export function expandAccountTimeline(id) { return (dispatch, getState) => { - const lastId = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()).last(); + const lastId = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()).last(); dispatch(expandAccountTimelineRequest(id)); - api(getState).get(`/api/v1/accounts/${id}/statuses?max_id=${lastId}`).then(response => { + api(getState).get(`/api/v1/accounts/${id}/statuses`, { + params: { + limit: 10, + max_id: lastId + } + }).then(response => { dispatch(expandAccountTimelineSuccess(id, response.data)); }).catch(error => { dispatch(expandAccountTimelineFail(id, error)); @@ -210,27 +208,30 @@ export function unfollowAccountFail(error) { }; }; -export function fetchAccountTimelineRequest(id) { +export function fetchAccountTimelineRequest(id, skipLoading) { return { type: ACCOUNT_TIMELINE_FETCH_REQUEST, - id + id, + skipLoading }; }; -export function fetchAccountTimelineSuccess(id, statuses, replace) { +export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) { return { type: ACCOUNT_TIMELINE_FETCH_SUCCESS, id, statuses, - replace + replace, + skipLoading }; }; -export function fetchAccountTimelineFail(id, error) { +export function fetchAccountTimelineFail(id, error, skipLoading) { return { type: ACCOUNT_TIMELINE_FETCH_FAIL, id, - error + error, + skipLoading }; }; @@ -495,6 +496,10 @@ export function expandFollowingFail(id, error) { export function fetchRelationships(account_ids) { return (dispatch, getState) => { + if (account_ids.length === 0) { + return; + } + dispatch(fetchRelationshipsRequest(account_ids)); api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => { @@ -508,21 +513,24 @@ export function fetchRelationships(account_ids) { export function fetchRelationshipsRequest(ids) { return { type: RELATIONSHIPS_FETCH_REQUEST, - ids + ids, + skipLoading: true }; }; export function fetchRelationshipsSuccess(relationships) { return { type: RELATIONSHIPS_FETCH_SUCCESS, - relationships + relationships, + skipLoading: true }; }; export function fetchRelationshipsFail(error) { return { type: RELATIONSHIPS_FETCH_FAIL, - error + error, + skipLoading: true }; }; diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx new file mode 100644 index 000000000..503c2bfeb --- /dev/null +++ b/app/assets/javascripts/components/actions/cards.jsx @@ -0,0 +1,47 @@ +import api from '../api'; + +export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST'; +export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS'; +export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL'; + +export function fetchStatusCard(id) { + return (dispatch, getState) => { + dispatch(fetchStatusCardRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/card`).then(response => { + if (!response.data.url || !response.data.title || !response.data.description) { + return; + } + + dispatch(fetchStatusCardSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchStatusCardFail(id, error)); + }); + }; +}; + +export function fetchStatusCardRequest(id) { + return { + type: STATUS_CARD_FETCH_REQUEST, + id, + skipLoading: true + }; +}; + +export function fetchStatusCardSuccess(id, card) { + return { + type: STATUS_CARD_FETCH_SUCCESS, + id, + card, + skipLoading: true + }; +}; + +export function fetchStatusCardFail(id, error) { + return { + type: STATUS_CARD_FETCH_FAIL, + id, + error, + skipLoading: true + }; +}; diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index 05674ba89..6d0188166 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -23,6 +23,8 @@ export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; +export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; +export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; @@ -68,6 +70,7 @@ export function submitCompose() { in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')), sensitive: getState().getIn(['compose', 'sensitive']), + spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public') }).then(function (response) { dispatch(submitComposeSuccess({ ...response.data })); @@ -218,6 +221,20 @@ export function changeComposeSensitivity(checked) { }; }; +export function changeComposeSpoilerness(checked) { + return { + type: COMPOSE_SPOILERNESS_CHANGE, + checked + }; +}; + +export function changeComposeSpoilerText(text) { + return { + type: COMPOSE_SPOILER_TEXT_CHANGE, + text + }; +}; + export function changeComposeVisibility(checked) { return { type: COMPOSE_VISIBILITY_CHANGE, diff --git a/app/assets/javascripts/components/actions/favourites.jsx b/app/assets/javascripts/components/actions/favourites.jsx new file mode 100644 index 000000000..a25c1ae1c --- /dev/null +++ b/app/assets/javascripts/components/actions/favourites.jsx @@ -0,0 +1,83 @@ +import api, { getLinks } from '../api' + +export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; +export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; +export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL'; + +export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST'; +export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS'; +export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL'; + +export function fetchFavouritedStatuses() { + return (dispatch, getState) => { + dispatch(fetchFavouritedStatusesRequest()); + + api(getState).get('/api/v1/favourites').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchFavouritedStatusesFail(error)); + }); + }; +}; + +export function fetchFavouritedStatusesRequest() { + return { + type: FAVOURITED_STATUSES_FETCH_REQUEST + }; +}; + +export function fetchFavouritedStatusesSuccess(statuses, next) { + return { + type: FAVOURITED_STATUSES_FETCH_SUCCESS, + statuses, + next + }; +}; + +export function fetchFavouritedStatusesFail(error) { + return { + type: FAVOURITED_STATUSES_FETCH_FAIL, + error + }; +}; + +export function expandFavouritedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'favourites', 'next'], null); + + if (url === null) { + return; + } + + dispatch(expandFavouritedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandFavouritedStatusesFail(error)); + }); + }; +}; + +export function expandFavouritedStatusesRequest() { + return { + type: FAVOURITED_STATUSES_EXPAND_REQUEST + }; +}; + +export function expandFavouritedStatusesSuccess(statuses, next) { + return { + type: FAVOURITED_STATUSES_EXPAND_SUCCESS, + statuses, + next + }; +}; + +export function expandFavouritedStatusesFail(error) { + return { + type: FAVOURITED_STATUSES_EXPAND_FAIL, + error + }; +}; diff --git a/app/assets/javascripts/components/actions/meta.jsx b/app/assets/javascripts/components/actions/meta.jsx deleted file mode 100644 index d0adbce3f..000000000 --- a/app/assets/javascripts/components/actions/meta.jsx +++ /dev/null @@ -1,8 +0,0 @@ -export const ACCESS_TOKEN_SET = 'ACCESS_TOKEN_SET'; - -export function setAccessToken(token) { - return { - type: ACCESS_TOKEN_SET, - token: token - }; -}; diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx index 8bd835406..1731c1857 100644 --- a/app/assets/javascripts/components/actions/notifications.jsx +++ b/app/assets/javascripts/components/actions/notifications.jsx @@ -14,8 +14,6 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; -export const NOTIFICATIONS_SETTING_CHANGE = 'NOTIFICATIONS_SETTING_CHANGE'; - const fetchRelatedRelationships = (dispatch, notifications) => { const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); @@ -26,21 +24,25 @@ const fetchRelatedRelationships = (dispatch, notifications) => { export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { + const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); + const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); + dispatch({ type: NOTIFICATIONS_UPDATE, notification, account: notification.account, - status: notification.status + status: notification.status, + meta: playSound ? { sound: 'boop' } : undefined }); fetchRelatedRelationships(dispatch, [notification]); // Desktop notifications - if (typeof window.Notification !== 'undefined' && getState().getIn(['notifications', 'settings', 'alerts', notification.type], false)) { + if (typeof window.Notification !== 'undefined' && showAlert) { const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); const body = $('<p>').html(notification.status ? notification.status.content : '').text(); - new Notification(title, { body, icon: notification.account.avatar }); + new Notification(title, { body, icon: notification.account.avatar, tag: notification.id }); } }; }; @@ -94,13 +96,17 @@ export function expandNotifications() { return (dispatch, getState) => { const url = getState().getIn(['notifications', 'next'], null); - if (url === null) { + if (url === null || getState().getIn(['notifications', 'isLoading'])) { return; } dispatch(expandNotificationsRequest()); - api(getState).get(url).then(response => { + api(getState).get(url, { + params: { + limit: 5 + } + }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); @@ -133,11 +139,3 @@ export function expandNotificationsFail(error) { error }; }; - -export function changeNotificationsSetting(key, checked) { - return { - type: NOTIFICATIONS_SETTING_CHANGE, - key, - checked - }; -}; diff --git a/app/assets/javascripts/components/actions/settings.jsx b/app/assets/javascripts/components/actions/settings.jsx new file mode 100644 index 000000000..c754b30ca --- /dev/null +++ b/app/assets/javascripts/components/actions/settings.jsx @@ -0,0 +1,19 @@ +import axios from 'axios'; + +export const SETTING_CHANGE = 'SETTING_CHANGE'; + +export function changeSetting(key, value) { + return { + type: SETTING_CHANGE, + key, + value + }; +}; + +export function saveSettings() { + return (_, getState) => { + axios.put('/api/web/settings', { + data: getState().get('settings').toJS() + }); + }; +}; diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx index cbee94bca..9ac215727 100644 --- a/app/assets/javascripts/components/actions/statuses.jsx +++ b/app/assets/javascripts/components/actions/statuses.jsx @@ -1,6 +1,7 @@ import api from '../api'; import { deleteFromTimelines } from './timelines'; +import { fetchStatusCard } from './cards'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; @@ -14,39 +15,44 @@ export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; -export function fetchStatusRequest(id) { +export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, - id: id + id, + skipLoading }; }; export function fetchStatus(id) { return (dispatch, getState) => { - dispatch(fetchStatusRequest(id)); + const skipLoading = getState().getIn(['statuses', id], null) !== null; + + dispatch(fetchStatusRequest(id, skipLoading)); api(getState).get(`/api/v1/statuses/${id}`).then(response => { - dispatch(fetchStatusSuccess(response.data)); + dispatch(fetchStatusSuccess(response.data, skipLoading)); dispatch(fetchContext(id)); + dispatch(fetchStatusCard(id)); }).catch(error => { - dispatch(fetchStatusFail(id, error)); + dispatch(fetchStatusFail(id, error, skipLoading)); }); }; }; -export function fetchStatusSuccess(status, context) { +export function fetchStatusSuccess(status, skipLoading) { return { type: STATUS_FETCH_SUCCESS, - status: status, - context: context + status, + skipLoading }; }; -export function fetchStatusFail(id, error) { +export function fetchStatusFail(id, error, skipLoading) { return { type: STATUS_FETCH_FAIL, - id: id, - error: error + id, + error, + skipLoading }; }; diff --git a/app/assets/javascripts/components/actions/store.jsx b/app/assets/javascripts/components/actions/store.jsx new file mode 100644 index 000000000..3bba99549 --- /dev/null +++ b/app/assets/javascripts/components/actions/store.jsx @@ -0,0 +1,17 @@ +import Immutable from 'immutable'; + +export const STORE_HYDRATE = 'STORE_HYDRATE'; + +const convertState = rawState => + Immutable.fromJS(rawState, (k, v) => + Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x => + Number.isNaN(x * 1) ? x : x * 1)); + +export function hydrateStore(rawState) { + const state = convertState(rawState); + + return { + type: STORE_HYDRATE, + state + }; +}; diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx index 0e6f09190..29a060e87 100644 --- a/app/assets/javascripts/components/actions/timelines.jsx +++ b/app/assets/javascripts/components/actions/timelines.jsx @@ -14,11 +14,12 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; -export function refreshTimelineSuccess(timeline, statuses) { +export function refreshTimelineSuccess(timeline, statuses, skipLoading) { return { type: TIMELINE_REFRESH_SUCCESS, - timeline: timeline, - statuses: statuses + timeline, + statuses, + skipLoading }; }; @@ -39,55 +40,65 @@ export function deleteFromTimelines(id) { return (dispatch, getState) => { const accountId = getState().getIn(['statuses', id, 'account']); const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); + const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); dispatch({ type: TIMELINE_DELETE, id, accountId, - references + references, + reblogOf }); }; }; -export function refreshTimelineRequest(timeline, id) { +export function refreshTimelineRequest(timeline, id, skipLoading) { return { type: TIMELINE_REFRESH_REQUEST, timeline, - id + id, + skipLoading }; }; export function refreshTimeline(timeline, id = null) { return function (dispatch, getState) { - dispatch(refreshTimelineRequest(timeline, id)); + if (getState().getIn(['timelines', timeline, 'isLoading'])) { + return; + } const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List()); const newestId = ids.size > 0 ? ids.first() : null; - let params = ''; - let path = timeline; + let params = ''; + let path = timeline; + let skipLoading = false; if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded'])) { - params = `?since_id=${newestId}`; + params = `?since_id=${newestId}`; + skipLoading = true; } if (id) { path = `${path}/${id}` } + dispatch(refreshTimelineRequest(timeline, id, skipLoading)); + api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) { - dispatch(refreshTimelineSuccess(timeline, response.data)); + dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading)); }).catch(function (error) { - dispatch(refreshTimelineFail(timeline, error)); + dispatch(refreshTimelineFail(timeline, error, skipLoading)); }); }; }; -export function refreshTimelineFail(timeline, error) { +export function refreshTimelineFail(timeline, error, skipLoading) { return { type: TIMELINE_REFRESH_FAIL, timeline, - error + error, + skipLoading }; }; @@ -95,6 +106,12 @@ export function expandTimeline(timeline, id = null) { return (dispatch, getState) => { const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last(); + if (!lastId || getState().getIn(['timelines', timeline, 'isLoading'])) { + // If timeline is empty, don't try to load older posts since there are none + // Also if already loading + return; + } + dispatch(expandTimelineRequest(timeline)); let path = timeline; @@ -103,7 +120,12 @@ export function expandTimeline(timeline, id = null) { path = `${path}/${id}` } - api(getState).get(`/api/v1/timelines/${path}?max_id=${lastId}`).then(response => { + api(getState).get(`/api/v1/timelines/${path}`, { + params: { + limit: 10, + max_id: lastId + } + }).then(response => { dispatch(expandTimelineSuccess(timeline, response.data)); }).catch(error => { dispatch(expandTimelineFail(timeline, error)); diff --git a/app/assets/javascripts/components/components/account.jsx b/app/assets/javascripts/components/components/account.jsx index 814d8a9c8..108401b2f 100644 --- a/app/assets/javascripts/components/components/account.jsx +++ b/app/assets/javascripts/components/components/account.jsx @@ -8,7 +8,9 @@ import { defineMessages, injectIntl } from 'react-intl'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' } + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock' } }); const outerStyle = { @@ -42,7 +44,9 @@ const Account = React.createClass({ account: ImmutablePropTypes.map.isRequired, me: React.PropTypes.number.isRequired, onFollow: React.PropTypes.func.isRequired, - withNote: React.PropTypes.bool + onBlock: React.PropTypes.func.isRequired, + withNote: React.PropTypes.bool, + intl: React.PropTypes.object.isRequired }, getDefaultProps () { @@ -57,6 +61,10 @@ const Account = React.createClass({ this.props.onFollow(this.props.account); }, + handleBlock () { + this.props.onBlock(this.props.account); + }, + render () { const { account, me, withNote, intl } = this.props; @@ -70,10 +78,18 @@ const Account = React.createClass({ note = <div style={noteStyle}>{account.get('note')}</div>; } - if (account.get('id') !== me && account.get('relationship', null) != null) { + if (account.get('id') !== me && account.get('relationship', null) !== null) { const following = account.getIn(['relationship', 'following']); - - buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + + if (requested) { + buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} /> + } else if (blocking) { + buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />; + } else { + buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + } } return ( diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx index 39ccbcaf9..81ec7a236 100644 --- a/app/assets/javascripts/components/components/autosuggest_textarea.jsx +++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx @@ -38,7 +38,8 @@ const AutosuggestTextarea = React.createClass({ onSuggestionsClearRequested: React.PropTypes.func.isRequired, onSuggestionsFetchRequested: React.PropTypes.func.isRequired, onChange: React.PropTypes.func.isRequired, - onKeyUp: React.PropTypes.func + onKeyUp: React.PropTypes.func, + onKeyDown: React.PropTypes.func }, getInitialState () { @@ -108,15 +109,28 @@ const AutosuggestTextarea = React.createClass({ break; } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + this.props.onKeyDown(e); }, onBlur () { - this.setState({ suggestionsHidden: true }); + // If we hide the suggestions immediately, then this will prevent the + // onClick for the suggestions themselves from firing. + // Setting a short window for that to take place before hiding the + // suggestions ensures that can't happen. + setTimeout(() => { + this.setState({ suggestionsHidden: true }); + }, 100); }, onSuggestionClick (suggestion, e) { e.preventDefault(); this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.textarea.focus(); }, componentWillReceiveProps (nextProps) { diff --git a/app/assets/javascripts/components/components/avatar.jsx b/app/assets/javascripts/components/components/avatar.jsx index 687aa7bb9..b8420014b 100644 --- a/app/assets/javascripts/components/components/avatar.jsx +++ b/app/assets/javascripts/components/components/avatar.jsx @@ -8,12 +8,41 @@ const Avatar = React.createClass({ style: React.PropTypes.object }, + getInitialState () { + return { + hovering: false + }; + }, + mixins: [PureRenderMixin], + handleMouseEnter () { + this.setState({ hovering: true }); + }, + + handleMouseLeave () { + this.setState({ hovering: false }); + }, + + handleLoad () { + this.canvas.getContext('2d').drawImage(this.image, 0, 0, this.props.size, this.props.size); + }, + + setImageRef (c) { + this.image = c; + }, + + setCanvasRef (c) { + this.canvas = c; + }, + render () { + const { hovering } = this.state; + return ( - <div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}> - <img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} /> + <div onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px`, position: 'relative' }}> + <img ref={this.setImageRef} onLoad={this.handleLoad} src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ position: 'absolute', top: '0', left: '0', visibility: hovering ? 'visible' : 'hidden', borderRadius: '4px' }} /> + <canvas ref={this.setCanvasRef} width={this.props.size} height={this.props.size} style={{ borderRadius: '4px' }} /> </div> ); } diff --git a/app/assets/javascripts/components/components/button.jsx b/app/assets/javascripts/components/components/button.jsx index d63129013..19c52550a 100644 --- a/app/assets/javascripts/components/components/button.jsx +++ b/app/assets/javascripts/components/components/button.jsx @@ -27,7 +27,7 @@ const Button = React.createClass({ render () { const style = { - fontFamily: 'Roboto', + fontFamily: 'inherit', display: this.props.block ? 'block' : 'inline-block', width: this.props.block ? '100%' : 'auto', position: 'relative', diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx new file mode 100644 index 000000000..203dc5e0c --- /dev/null +++ b/app/assets/javascripts/components/components/column_collapsable.jsx @@ -0,0 +1,60 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { Motion, spring } from 'react-motion'; + +const iconStyle = { + fontSize: '16px', + padding: '15px', + position: 'absolute', + right: '0', + top: '-48px', + cursor: 'pointer' +}; + +const ColumnCollapsable = React.createClass({ + + propTypes: { + icon: React.PropTypes.string.isRequired, + fullHeight: React.PropTypes.number.isRequired, + children: React.PropTypes.node, + onCollapse: React.PropTypes.func + }, + + getInitialState () { + return { + collapsed: true + }; + }, + + mixins: [PureRenderMixin], + + handleToggleCollapsed () { + const currentState = this.state.collapsed; + + this.setState({ collapsed: !currentState }); + + if (!currentState && this.props.onCollapse) { + this.props.onCollapse(); + } + }, + + render () { + const { icon, fullHeight, children } = this.props; + const { collapsed } = this.state; + + return ( + <div style={{ position: 'relative' }}> + <div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div> + + <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}> + {({ opacity, height }) => + <div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}> + {children} + </div> + } + </Motion> + </div> + ); + } +}); + +export default ColumnCollapsable; diff --git a/app/assets/javascripts/components/components/dropdown_menu.jsx b/app/assets/javascripts/components/components/dropdown_menu.jsx index 450550d55..ffef29c00 100644 --- a/app/assets/javascripts/components/components/dropdown_menu.jsx +++ b/app/assets/javascripts/components/components/dropdown_menu.jsx @@ -1,13 +1,15 @@ import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; -const DropdownMenu = ({ icon, items, size }) => { +const DropdownMenu = ({ icon, items, size, direction }) => { + const directionClass = (direction == "left") ? "dropdown__left" : "dropdown__right"; + return ( <Dropdown> <DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }}> <i className={`fa fa-fw fa-${icon}`} style={{ verticalAlign: 'middle' }} /> </DropdownTrigger> - <DropdownContent style={{ lineHeight: '18px', textAlign: 'left' }}> + <DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}> <ul> {items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => { if (typeof action === 'function') { diff --git a/app/assets/javascripts/components/components/icon_button.jsx b/app/assets/javascripts/components/components/icon_button.jsx index e9a7228e4..f9b6192c0 100644 --- a/app/assets/javascripts/components/components/icon_button.jsx +++ b/app/assets/javascripts/components/components/icon_button.jsx @@ -1,4 +1,5 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { Motion, spring } from 'react-motion'; const IconButton = React.createClass({ @@ -10,14 +11,16 @@ const IconButton = React.createClass({ active: React.PropTypes.bool, style: React.PropTypes.object, activeStyle: React.PropTypes.object, - disabled: React.PropTypes.bool + disabled: React.PropTypes.bool, + animate: React.PropTypes.bool }, getDefaultProps () { return { size: 18, active: false, - disabled: false + disabled: false, + animate: false }; }, @@ -49,9 +52,18 @@ const IconButton = React.createClass({ } return ( - <button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`} onClick={this.handleClick} style={style}> - <i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> - </button> + <Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> + {({ rotate }) => + <button + aria-label={this.props.title} + title={this.props.title} + className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`} + onClick={this.handleClick} + style={style}> + <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> + </button> + } + </Motion> ); } diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx index b5c2a69d8..1e3a88955 100644 --- a/app/assets/javascripts/components/components/lightbox.jsx +++ b/app/assets/javascripts/components/components/lightbox.jsx @@ -35,7 +35,9 @@ const Lightbox = React.createClass({ propTypes: { isVisible: React.PropTypes.bool, onOverlayClicked: React.PropTypes.func, - onCloseClicked: React.PropTypes.func + onCloseClicked: React.PropTypes.func, + intl: React.PropTypes.object.isRequired, + children: React.PropTypes.node }, mixins: [PureRenderMixin], @@ -57,19 +59,17 @@ const Lightbox = React.createClass({ render () { const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props; - const content = isVisible ? children : <div />; - return ( - <div className='lightbox' style={{...overlayStyle, display: isVisible ? 'flex' : 'none'}} onClick={onOverlayClicked}> - <Motion defaultStyle={{ y: -200 }} style={{ y: spring(isVisible ? 0 : -200) }}> - {({ y }) => - <div style={{...dialogStyle, transform: `translateY(${y}px)`}}> + <Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}> + {({ backgroundOpacity, opacity, y }) => + <div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex'}} onClick={onOverlayClicked}> + <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }}> <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} /> - {content} + {children} </div> - } - </Motion> - </div> + </div> + } + </Motion> ); } diff --git a/app/assets/javascripts/components/components/loading_indicator.jsx b/app/assets/javascripts/components/components/loading_indicator.jsx index fd5acae84..c8a263924 100644 --- a/app/assets/javascripts/components/components/loading_indicator.jsx +++ b/app/assets/javascripts/components/components/loading_indicator.jsx @@ -1,15 +1,17 @@ import { FormattedMessage } from 'react-intl'; -const LoadingIndicator = () => { - const style = { - textAlign: 'center', - fontSize: '16px', - fontWeight: '500', - color: '#616b86', - paddingTop: '120px' - }; - - return <div style={style}><FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /></div>; +const style = { + textAlign: 'center', + fontSize: '16px', + fontWeight: '500', + color: '#616b86', + paddingTop: '120px' }; +const LoadingIndicator = () => ( + <div style={style}> + <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /> + </div> +); + export default LoadingIndicator; diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx index 9aafd8181..7e92abe2d 100644 --- a/app/assets/javascripts/components/components/media_gallery.jsx +++ b/app/assets/javascripts/components/components/media_gallery.jsx @@ -1,12 +1,18 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; -import { FormattedMessage } from 'react-intl'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' } +}); const outerStyle = { marginTop: '8px', overflow: 'hidden', width: '100%', - boxSizing: 'border-box' + boxSizing: 'border-box', + position: 'relative' }; const spoilerStyle = { @@ -32,11 +38,18 @@ const spoilerSubSpanStyle = { fontWeight: '500' }; +const spoilerButtonStyle = { + position: 'absolute', + top: '6px', + left: '8px', + zIndex: '100' +}; + const MediaGallery = React.createClass({ getInitialState () { return { - visible: false + visible: !this.props.sensitive }; }, @@ -59,21 +72,30 @@ const MediaGallery = React.createClass({ }, handleOpen () { - this.setState({ visible: true }); + this.setState({ visible: !this.state.visible }); }, render () { - const { media, sensitive } = this.props; + const { media, intl, sensitive } = this.props; let children; - if (sensitive && !this.state.visible) { - children = ( - <div style={spoilerStyle} onClick={this.handleOpen}> - <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> - <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> - </div> - ); + if (!this.state.visible) { + if (sensitive) { + children = ( + <div style={spoilerStyle} onClick={this.handleOpen}> + <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } else { + children = ( + <div style={spoilerStyle} onClick={this.handleOpen}> + <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } } else { const size = media.take(4).size; @@ -134,9 +156,12 @@ const MediaGallery = React.createClass({ ); }); } - + return ( <div style={{ ...outerStyle, height: `${this.props.height}px` }}> + <div style={spoilerButtonStyle} > + <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} /> + </div> {children} </div> ); @@ -144,4 +169,4 @@ const MediaGallery = React.createClass({ }); -export default MediaGallery; +export default injectIntl(MediaGallery); diff --git a/app/assets/javascripts/components/components/missing_indicator.jsx b/app/assets/javascripts/components/components/missing_indicator.jsx new file mode 100644 index 000000000..ed8b4fe24 --- /dev/null +++ b/app/assets/javascripts/components/components/missing_indicator.jsx @@ -0,0 +1,17 @@ +import { FormattedMessage } from 'react-intl'; + +const style = { + textAlign: 'center', + fontSize: '16px', + fontWeight: '500', + color: '#616b86', + paddingTop: '120px' +}; + +const MissingIndicator = () => ( + <div style={style}> + <FormattedMessage id='missing_indicator.label' defaultMessage='Not found' /> + </div> +); + +export default MissingIndicator; diff --git a/app/assets/javascripts/components/components/relative_timestamp.jsx b/app/assets/javascripts/components/components/relative_timestamp.jsx index 3a5b88523..3b012b184 100644 --- a/app/assets/javascripts/components/components/relative_timestamp.jsx +++ b/app/assets/javascripts/components/components/relative_timestamp.jsx @@ -1,15 +1,18 @@ -import { - FormattedMessage, - FormattedDate, - FormattedRelative -} from 'react-intl'; - -const RelativeTimestamp = ({ timestamp }) => { - return <FormattedRelative value={new Date(timestamp)} />; +import { injectIntl, FormattedRelative } from 'react-intl'; + +const RelativeTimestamp = ({ intl, timestamp }) => { + const date = new Date(timestamp); + + return ( + <time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}> + <FormattedRelative value={date} /> + </time> + ); }; RelativeTimestamp.propTypes = { + intl: React.PropTypes.object.isRequired, timestamp: React.PropTypes.string.isRequired }; -export default RelativeTimestamp; +export default injectIntl(RelativeTimestamp); diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx index afaf82561..f2cc1fb12 100644 --- a/app/assets/javascripts/components/components/status_action_bar.jsx +++ b/app/assets/javascripts/components/components/status_action_bar.jsx @@ -49,7 +49,7 @@ const StatusActionBar = React.createClass({ }, handleMentionClick () { - this.props.onMention(this.props.status.get('account')); + this.props.onMention(this.props.status.get('account'), this.context.router); }, handleBlockClick () { @@ -77,10 +77,10 @@ const StatusActionBar = React.createClass({ <div style={{ marginTop: '10px', overflow: 'hidden' }}> <div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div> <div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div> - <div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> + <div style={{ float: 'left', marginRight: '18px'}}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> <div style={{ width: '18px', height: '18px', float: 'left' }}> - <DropdownMenu items={menu} icon='ellipsis-h' size={18} /> + <DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" /> </div> </div> ); diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx index f2c88cee0..521b557f0 100644 --- a/app/assets/javascripts/components/components/status_content.jsx +++ b/app/assets/javascripts/components/components/status_content.jsx @@ -1,6 +1,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import emojify from '../emoji'; +import { FormattedMessage } from 'react-intl'; const StatusContent = React.createClass({ @@ -13,6 +14,12 @@ const StatusContent = React.createClass({ onClick: React.PropTypes.func }, + getInitialState () { + return { + hidden: true + }; + }, + mixins: [PureRenderMixin], componentDidMount () { @@ -31,8 +38,6 @@ const StatusContent = React.createClass({ link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener'); } - - link.addEventListener('click', this.onNormalClick, false); } }, @@ -52,16 +57,59 @@ const StatusContent = React.createClass({ } }, - onNormalClick (e) { - e.stopPropagation(); + handleMouseDown (e) { + this.startXY = [e.clientX, e.clientY]; + }, + + handleMouseUp (e) { + const [ startX, startY ] = this.startXY; + const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; + + if (e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) { + return; + } + + if (deltaX + deltaY < 5 && e.button === 0) { + this.props.onClick(); + } + + this.startXY = null; + }, + + handleSpoilerClick () { + this.setState({ hidden: !this.state.hidden }); }, render () { - const { status, onClick } = this.props; + const { status } = this.props; + const { hidden } = this.state; const content = { __html: emojify(status.get('content')) }; - - return <div className='status__content' style={{ cursor: 'pointer' }} dangerouslySetInnerHTML={content} onClick={onClick} />; + const spoilerContent = { __html: emojify(status.get('spoiler_text', '')) }; + + if (status.get('spoiler_text').length > 0) { + const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />; + + return ( + <div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> + <p style={{ marginBottom: hidden ? '0px' : '' }} > + <span dangerouslySetInnerHTML={spoilerContent} /> <a onClick={this.handleSpoilerClick}>{toggleText}</a> + </p> + + <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} /> + </div> + ); + } else { + return ( + <div + className='status__content' + style={{ cursor: 'pointer' }} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + dangerouslySetInnerHTML={content} + /> + ); + } }, }); diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx index e0a73435f..69cc013f2 100644 --- a/app/assets/javascripts/components/components/status_list.jsx +++ b/app/assets/javascripts/components/components/status_list.jsx @@ -11,7 +11,8 @@ const StatusList = React.createClass({ onScrollToBottom: React.PropTypes.func, onScrollToTop: React.PropTypes.func, onScroll: React.PropTypes.func, - trackScroll: React.PropTypes.bool + trackScroll: React.PropTypes.bool, + isLoading: React.PropTypes.bool }, getDefaultProps () { @@ -24,10 +25,10 @@ const StatusList = React.createClass({ handleScroll (e) { const { scrollTop, scrollHeight, clientHeight } = e.target; - + const offset = scrollHeight - scrollTop - clientHeight; this._oldScrollPosition = scrollHeight - scrollTop; - if (scrollTop === scrollHeight - clientHeight && this.props.onScrollToBottom) { + if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) { this.props.onScrollToBottom(); } else if (scrollTop < 100 && this.props.onScrollToTop) { this.props.onScrollToTop(); @@ -36,21 +37,37 @@ const StatusList = React.createClass({ } }, - componentDidUpdate (prevProps) { - if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) { - const node = ReactDOM.findDOMNode(this); + componentDidMount () { + this.attachScrollListener(); + }, - if (node.scrollTop > 0) { - node.scrollTop = node.scrollHeight - this._oldScrollPosition; - } + componentDidUpdate (prevProps) { + if (this.node.scrollTop > 0 && (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition)) { + this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition; } }, + componentWillUnmount () { + this.detachScrollListener(); + }, + + attachScrollListener () { + this.node.addEventListener('scroll', this.handleScroll); + }, + + detachScrollListener () { + this.node.removeEventListener('scroll', this.handleScroll); + }, + + setRef (c) { + this.node = c; + }, + render () { const { statusIds, onScrollToBottom, trackScroll } = this.props; const scrollableArea = ( - <div className='scrollable' onScroll={this.handleScroll}> + <div className='scrollable' ref={this.setRef}> <div> {statusIds.map((statusId) => { return <StatusContainer key={statusId} id={statusId} />; diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx index 8f64ad3cd..3edc8f672 100644 --- a/app/assets/javascripts/components/components/video_player.jsx +++ b/app/assets/javascripts/components/components/video_player.jsx @@ -4,7 +4,8 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; const messages = defineMessages({ - toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' } + toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, + toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' } }); const videoStyle = { @@ -20,7 +21,7 @@ const videoStyle = { const muteStyle = { position: 'absolute', top: '10px', - left: '10px', + right: '10px', opacity: '0.8', zIndex: '5' }; @@ -35,7 +36,8 @@ const spoilerStyle = { display: 'flex', alignItems: 'center', justifyContent: 'center', - flexDirection: 'column' + flexDirection: 'column', + position: 'relative' }; const spoilerSpanStyle = { @@ -49,6 +51,13 @@ const spoilerSubSpanStyle = { fontWeight: '500' }; +const spoilerButtonStyle = { + position: 'absolute', + top: '6px', + left: '8px', + zIndex: '100' +}; + const VideoPlayer = React.createClass({ propTypes: { media: ImmutablePropTypes.map.isRequired, @@ -66,7 +75,8 @@ const VideoPlayer = React.createClass({ getInitialState () { return { - visible: false, + visible: !this.props.sensitive, + preview: true, muted: true }; }, @@ -90,22 +100,49 @@ const VideoPlayer = React.createClass({ }, handleOpen () { - this.setState({ visible: true }); + this.setState({ preview: !this.state.preview }); + }, + + handleVisibility () { + this.setState({ + visible: !this.state.visible, + preview: true + }); }, render () { const { media, intl, width, height, sensitive } = this.props; - if (sensitive && !this.state.visible) { - return ( - <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}> - <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> - <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> - </div> - ); - } else if (!sensitive && !this.state.visible) { + let spoilerButton = ( + <div style={spoilerButtonStyle} > + <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} /> + </div> + ); + + if (!this.state.visible) { + if (sensitive) { + return ( + <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleVisibility}> + {spoilerButton} + <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } else { + return ( + <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}> + {spoilerButton} + <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } + } + + if (this.state.preview) { return ( <div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}> + {spoilerButton} <div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div> </div> ); @@ -113,7 +150,8 @@ const VideoPlayer = React.createClass({ return ( <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}> - <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div> + {spoilerButton} + <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div> <video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} /> </div> ); diff --git a/app/assets/javascripts/components/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx index 1f49f9819..889c0ac4c 100644 --- a/app/assets/javascripts/components/containers/account_container.jsx +++ b/app/assets/javascripts/components/containers/account_container.jsx @@ -3,7 +3,9 @@ import { makeGetAccount } from '../selectors'; import Account from '../components/account'; import { followAccount, - unfollowAccount + unfollowAccount, + blockAccount, + unblockAccount } from '../actions/accounts'; const makeMapStateToProps = () => { @@ -24,6 +26,14 @@ const mapDispatchToProps = (dispatch) => ({ } else { dispatch(followAccount(account.get('id'))); } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(blockAccount(account.get('id'))); + } } }); diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index 670455376..5f4b2cf79 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -7,15 +7,13 @@ import { refreshTimeline } from '../actions/timelines'; import { updateNotifications } from '../actions/notifications'; -import { setAccessToken } from '../actions/meta'; -import { setAccountSelf } from '../actions/accounts'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; import createBrowserHistory from 'history/lib/createBrowserHistory'; import { applyRouterMiddleware, useRouterHistory, Router, Route, + IndexRedirect, IndexRoute } from 'react-router'; import { useScroll } from 'react-router-scroll'; @@ -35,6 +33,8 @@ import Favourites from '../features/favourites'; import HashtagTimeline from '../features/hashtag_timeline'; import Notifications from '../features/notifications'; import FollowRequests from '../features/follow_requests'; +import GenericNotFound from '../features/generic_not_found'; +import FavouritedStatuses from '../features/favourited_statuses'; import { IntlProvider, addLocaleData } from 'react-intl'; import en from 'react-intl/locale-data/en'; import de from 'react-intl/locale-data/de'; @@ -44,9 +44,12 @@ import pt from 'react-intl/locale-data/pt'; import hu from 'react-intl/locale-data/hu'; import uk from 'react-intl/locale-data/uk'; import getMessagesForLocale from '../locales'; +import { hydrateStore } from '../actions/store'; const store = configureStore(); +store.dispatch(hydrateStore(window.INITIAL_STATE)); + const browserHistory = useRouterHistory(createBrowserHistory)({ basename: '/web' }); @@ -56,31 +59,26 @@ addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]); const Mastodon = React.createClass({ propTypes: { - token: React.PropTypes.string.isRequired, - timelines: React.PropTypes.object, - account: React.PropTypes.string, locale: React.PropTypes.string.isRequired }, - mixins: [PureRenderMixin], - componentWillMount() { - const { token, account, locale } = this.props; - - store.dispatch(setAccessToken(token)); - store.dispatch(setAccountSelf(JSON.parse(account))); + const { locale } = this.props; if (typeof App !== 'undefined') { this.subscription = App.cable.subscriptions.create('TimelineChannel', { received (data) { switch(data.type) { - case 'update': - return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message))); - case 'delete': - return store.dispatch(deleteFromTimelines(data.id)); - case 'notification': - return store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale)); + case 'update': + store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message))); + break; + case 'delete': + store.dispatch(deleteFromTimelines(data.id)); + break; + case 'notification': + store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale)); + break; } } @@ -107,14 +105,16 @@ const Mastodon = React.createClass({ <Provider store={store}> <Router history={browserHistory} render={applyRouterMiddleware(useScroll())}> <Route path='/' component={UI}> - <IndexRoute component={GettingStarted} /> + <IndexRedirect to="/getting-started" /> + <Route path='getting-started' component={GettingStarted} /> <Route path='timelines/home' component={HomeTimeline} /> <Route path='timelines/mentions' component={MentionsTimeline} /> <Route path='timelines/public' component={PublicTimeline} /> <Route path='timelines/tag/:id' component={HashtagTimeline} /> <Route path='notifications' component={Notifications} /> + <Route path='favourites' component={FavouritedStatuses} /> <Route path='statuses/new' component={Compose} /> <Route path='statuses/:statusId' component={Status} /> @@ -128,6 +128,7 @@ const Mastodon = React.createClass({ </Route> <Route path='follow_requests' component={FollowRequests} /> + <Route path='*' component={GenericNotFound} /> </Route> </Router> </Provider> diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx index 6a882eab4..ad2be03d1 100644 --- a/app/assets/javascripts/components/containers/status_container.jsx +++ b/app/assets/javascripts/components/containers/status_container.jsx @@ -15,6 +15,7 @@ import { blockAccount } from '../actions/accounts'; import { deleteStatus } from '../actions/statuses'; import { openMedia } from '../actions/modal'; import { createSelector } from 'reselect' +import { isMobile } from '../is_mobile' const mapStateToProps = (state, props) => ({ statusBase: state.getIn(['statuses', props.id]), @@ -86,8 +87,11 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(deleteStatus(status.get('id'))); }, - onMention (account) { + onMention (account, router) { dispatch(mentionCompose(account)); + if (isMobile(window.innerWidth)) { + router.push('/statuses/new'); + } }, onOpenMedia (url) { diff --git a/app/assets/javascripts/components/emoji.jsx b/app/assets/javascripts/components/emoji.jsx index a06c75953..c93c07c74 100644 --- a/app/assets/javascripts/components/emoji.jsx +++ b/app/assets/javascripts/components/emoji.jsx @@ -5,5 +5,5 @@ emojione.sprites = false; emojione.imagePathPNG = '/emoji/'; export default function emojify(text) { - return emojione.unicodeToImage(text); + return emojione.toImage(text); }; diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx index 45de75d97..ab7b08dc7 100644 --- a/app/assets/javascripts/components/features/account/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx @@ -66,7 +66,7 @@ const ActionBar = React.createClass({ return ( <div style={outerStyle}> <div style={outerDropdownStyle}> - <DropdownMenu items={menu} icon='bars' size={24} /> + <DropdownMenu items={menu} icon='bars' size={24} direction="right" /> </div> <div style={outerLinksStyle}> diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx index 6ae5ac002..dead11265 100644 --- a/app/assets/javascripts/components/features/account/components/header.jsx +++ b/app/assets/javascripts/components/features/account/components/header.jsx @@ -71,8 +71,8 @@ const Header = React.createClass({ <span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> </a> - <span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span> - <div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> + <span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#489fde', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span> + <div style={{ color: '#d9e1e8', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> {info} {actionBtn} diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx index c2cc58bb2..3a9b48f21 100644 --- a/app/assets/javascripts/components/features/account/index.jsx +++ b/app/assets/javascripts/components/features/account/index.jsx @@ -20,6 +20,7 @@ import LoadingIndicator from '../../components/loading_indicator'; import ActionBar from './components/action_bar'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; +import { isMobile } from '../../is_mobile' const makeMapStateToProps = () => { const getAccount = makeGetAccount(); @@ -34,11 +35,16 @@ const makeMapStateToProps = () => { const Account = React.createClass({ + contextTypes: { + router: React.PropTypes.object + }, + propTypes: { params: React.PropTypes.object.isRequired, dispatch: React.PropTypes.func.isRequired, account: ImmutablePropTypes.map, - me: React.PropTypes.number.isRequired + me: React.PropTypes.number.isRequired, + children: React.PropTypes.node }, mixins: [PureRenderMixin], @@ -71,6 +77,9 @@ const Account = React.createClass({ handleMention () { this.props.dispatch(mentionCompose(this.props.account)); + if (isMobile(window.innerWidth)) { + this.context.router.push('/statuses/new'); + } }, render () { diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx index 7a3dbe160..5c09839f7 100644 --- a/app/assets/javascripts/components/features/account_timeline/index.jsx +++ b/app/assets/javascripts/components/features/account_timeline/index.jsx @@ -9,7 +9,8 @@ import StatusList from '../../components/status_list'; import LoadingIndicator from '../../components/loading_indicator'; const mapStateToProps = (state, props) => ({ - statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]), + statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items']), + isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']), me: state.getIn(['meta', 'me']) }); @@ -18,7 +19,9 @@ const AccountTimeline = React.createClass({ propTypes: { params: React.PropTypes.object.isRequired, dispatch: React.PropTypes.func.isRequired, - statusIds: ImmutablePropTypes.list + statusIds: ImmutablePropTypes.list, + isLoading: React.PropTypes.bool, + me: React.PropTypes.number.isRequired }, mixins: [PureRenderMixin], @@ -38,13 +41,13 @@ const AccountTimeline = React.createClass({ }, render () { - const { statusIds, me } = this.props; + const { statusIds, isLoading, me } = this.props; if (!statusIds) { return <LoadingIndicator />; } - return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} /> + return <StatusList statusIds={statusIds} isLoading={isLoading} me={me} onScrollToBottom={this.handleScrollToBottom} /> } }); diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index 55f361b0b..48363a968 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -14,6 +14,7 @@ import { Motion, spring } from 'react-motion'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' }, publish: { id: 'compose_form.publish', defaultMessage: 'Publish' } }); @@ -25,6 +26,8 @@ const ComposeForm = React.createClass({ suggestion_token: React.PropTypes.string, suggestions: ImmutablePropTypes.list, sensitive: React.PropTypes.bool, + spoiler: React.PropTypes.bool, + spoiler_text: React.PropTypes.string, unlisted: React.PropTypes.bool, private: React.PropTypes.bool, fileDropDate: React.PropTypes.instanceOf(Date), @@ -32,6 +35,7 @@ const ComposeForm = React.createClass({ is_uploading: React.PropTypes.bool, in_reply_to: ImmutablePropTypes.map, media_count: React.PropTypes.number, + me: React.PropTypes.number, onChange: React.PropTypes.func.isRequired, onSubmit: React.PropTypes.func.isRequired, onCancelReply: React.PropTypes.func.isRequired, @@ -39,6 +43,8 @@ const ComposeForm = React.createClass({ onFetchSuggestions: React.PropTypes.func.isRequired, onSuggestionSelected: React.PropTypes.func.isRequired, onChangeSensitivity: React.PropTypes.func.isRequired, + onChangeSpoilerness: React.PropTypes.func.isRequired, + onChangeSpoilerText: React.PropTypes.func.isRequired, onChangeVisibility: React.PropTypes.func.isRequired, onChangeListability: React.PropTypes.func.isRequired, }, @@ -49,7 +55,7 @@ const ComposeForm = React.createClass({ this.props.onChange(e.target.value); }, - handleKeyUp (e) { + handleKeyDown (e) { if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { this.props.onSubmit(); } @@ -76,6 +82,15 @@ const ComposeForm = React.createClass({ this.props.onChangeSensitivity(e.target.checked); }, + handleChangeSpoilerness (e) { + this.props.onChangeSpoilerness(e.target.checked); + this.props.onChangeSpoilerText(''); + }, + + handleChangeSpoilerText (e) { + this.props.onChangeSpoilerText(e.target.value); + }, + handleChangeVisibility (e) { this.props.onChangeVisibility(e.target.checked); }, @@ -85,7 +100,14 @@ const ComposeForm = React.createClass({ }, componentDidUpdate (prevProps) { - if (prevProps.in_reply_to !== this.props.in_reply_to) { + if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) { + // If replying to zero or one users, places the cursor at the end of the textbox. + // If replying to more than one user, selects any usernames past the first; + // this provides a convenient shortcut to drop everyone else from the conversation. + const selectionStart = this.props.text.search(/\s/) + 1; + const selectionEnd = this.props.text.length; + + this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); this.autosuggestTextarea.textarea.focus(); } }, @@ -103,8 +125,18 @@ const ComposeForm = React.createClass({ replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />; } + let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me); + return ( <div style={{ padding: '10px' }}> + <Motion defaultStyle={{ opacity: !this.props.spoiler ? 0 : 100, height: !this.props.spoiler ? 50 : 0 }} style={{ opacity: spring(!this.props.spoiler ? 0 : 100), height: spring(!this.props.spoiler ? 0 : 50) }}> + {({ opacity, height }) => + <div className="spoiler-input" style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}> + <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} type="text" className="spoiler-input__input" /> + </div> + } + </Motion> + {replyArea} <AutosuggestTextarea @@ -115,7 +147,7 @@ const ComposeForm = React.createClass({ value={this.props.text} onChange={this.handleChange} suggestions={this.props.suggestions} - onKeyUp={this.handleKeyUp} + onKeyDown={this.handleKeyDown} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionSelected={this.onSuggestionSelected} @@ -123,7 +155,7 @@ const ComposeForm = React.createClass({ <div style={{ marginTop: '10px', overflow: 'hidden' }}> <div style={{ float: 'right' }}><Button text={intl.formatMessage(messages.publish)} onClick={this.handleSubmit} disabled={disabled} /></div> - <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div> + <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div> <UploadButtonContainer style={{ paddingTop: '4px' }} /> </div> @@ -132,7 +164,12 @@ const ComposeForm = React.createClass({ <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span> </label> - <Motion defaultStyle={{ opacity: this.props.private ? 0 : 100, height: this.props.private ? 39.5 : 0 }} style={{ opacity: spring(this.props.private ? 0 : 100), height: spring(this.props.private ? 0 : 39.5) }}> + <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle' }}> + <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} /> + <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide behind content warning' /></span> + </label> + + <Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}> {({ opacity, height }) => <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}> <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} /> diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx index d31d0e453..d0e865d29 100644 --- a/app/assets/javascripts/components/features/compose/components/drawer.jsx +++ b/app/assets/javascripts/components/features/compose/components/drawer.jsx @@ -1,26 +1,75 @@ -import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { Link } from 'react-router'; +import { injectIntl, defineMessages } from 'react-intl'; -const style = { +const messages = defineMessages({ + start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } +}); + +const outerStyle = { + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + overflowY: 'hidden' +}; + +const innerStyle = { boxSizing: 'border-box', - background: '#454b5e', padding: '0', display: 'flex', flexDirection: 'column', - overflowY: 'auto' + overflowY: 'auto', + flexGrow: '1' +}; + +const tabStyle = { + display: 'block', + flex: '1 1 auto', + padding: '15px', + paddingBottom: '13px', + color: '#9baec8', + textDecoration: 'none', + textAlign: 'center', + fontSize: '16px', + borderBottom: '2px solid transparent' }; -const Drawer = React.createClass({ +const tabActiveStyle = { + color: '#2b90d9', + borderBottom: '2px solid #2b90d9' +}; - mixins: [PureRenderMixin], +const Drawer = ({ children, withHeader, intl }) => { + let header = ''; - render () { - return ( - <div className='drawer' style={style}> - {this.props.children} + if (withHeader) { + header = ( + <div className='drawer__header'> + <Link title={intl.formatMessage(messages.start)} style={tabStyle} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> + <Link title={intl.formatMessage(messages.public)} style={tabStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link> + <a title={intl.formatMessage(messages.preferences)} style={tabStyle} href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a> + <a title={intl.formatMessage(messages.logout)} style={tabStyle} href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a> </div> ); } -}); + return ( + <div className='drawer' style={outerStyle}> + {header} + + <div className='drawer__inner' style={innerStyle}> + {children} + </div> + </div> + ); +}; + +Drawer.propTypes = { + withHeader: React.PropTypes.bool, + children: React.PropTypes.node, + intl: React.PropTypes.object +}; -export default Drawer; +export default injectIntl(Drawer); diff --git a/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx index df94c30d2..289e2dddf 100644 --- a/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx +++ b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx @@ -16,12 +16,12 @@ const NavigationBar = React.createClass({ render () { return ( - <div style={{ padding: '10px', display: 'flex', cursor: 'default' }}> + <div style={{ padding: '10px', display: 'flex', flexShrink: '0', cursor: 'default' }}> <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Permalink> <div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}> <strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong> - <a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.settings' defaultMessage='Settings' /></a> · <Link to='/timelines/public' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.public_timeline' defaultMessage='Public timeline' /></Link> · <a href='/auth/sign_out' data-method='delete' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a> + <a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> </div> </div> ); diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx index b4e618820..e4672216b 100644 --- a/app/assets/javascripts/components/features/compose/components/search.jsx +++ b/app/assets/javascripts/components/features/compose/components/search.jsx @@ -38,7 +38,7 @@ const inputStyle = { border: 'none', padding: '10px', paddingRight: '30px', - fontFamily: 'Roboto', + fontFamily: 'inherit', background: '#282c37', color: '#9baec8', fontSize: '14px', diff --git a/app/assets/javascripts/components/features/compose/components/upload_button.jsx b/app/assets/javascripts/components/features/compose/components/upload_button.jsx index 5250ff748..4c8181aa1 100644 --- a/app/assets/javascripts/components/features/compose/components/upload_button.jsx +++ b/app/assets/javascripts/components/features/compose/components/upload_button.jsx @@ -11,7 +11,9 @@ const UploadButton = React.createClass({ propTypes: { disabled: React.PropTypes.bool, onSelectFile: React.PropTypes.func.isRequired, - style: React.PropTypes.object + style: React.PropTypes.object, + resetFileKey: React.PropTypes.number, + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], @@ -31,12 +33,12 @@ const UploadButton = React.createClass({ }, render () { - const { intl } = this.props; + const { intl, resetFileKey, disabled } = this.props; return ( <div style={this.props.style}> - <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={this.props.disabled} onClick={this.handleClick} size={24} /> - <input ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} /> + <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} /> + <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} /> </div> ); } diff --git a/app/assets/javascripts/components/features/compose/components/upload_form.jsx b/app/assets/javascripts/components/features/compose/components/upload_form.jsx index ac548033c..94c94b4b7 100644 --- a/app/assets/javascripts/components/features/compose/components/upload_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/upload_form.jsx @@ -12,15 +12,20 @@ const UploadForm = React.createClass({ propTypes: { media: ImmutablePropTypes.list.isRequired, is_uploading: React.PropTypes.bool, - onRemoveFile: React.PropTypes.func.isRequired + onRemoveFile: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], render () { - const { intl } = this.props; + const { intl, media } = this.props; - const uploads = this.props.media.map(attachment => ( + if (!media.size) { + return null; + } + + const uploads = media.map(attachment => ( <div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'> <div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}> <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} /> @@ -29,7 +34,7 @@ const UploadForm = React.createClass({ )); return ( - <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden' }}> + <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}> {uploads} </div> ); diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx index 2b6ee1ae7..8ccfce059 100644 --- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx @@ -8,6 +8,8 @@ import { fetchComposeSuggestions, selectComposeSuggestion, changeComposeSensitivity, + changeComposeSpoilerness, + changeComposeSpoilerText, changeComposeVisibility, changeComposeListability } from '../../../actions/compose'; @@ -22,13 +24,16 @@ const makeMapStateToProps = () => { suggestion_token: state.getIn(['compose', 'suggestion_token']), suggestions: state.getIn(['compose', 'suggestions']), sensitive: state.getIn(['compose', 'sensitive']), + spoiler: state.getIn(['compose', 'spoiler']), + spoiler_text: state.getIn(['compose', 'spoiler_text']), unlisted: state.getIn(['compose', 'unlisted']), private: state.getIn(['compose', 'private']), fileDropDate: state.getIn(['compose', 'fileDropDate']), is_submitting: state.getIn(['compose', 'is_submitting']), is_uploading: state.getIn(['compose', 'is_uploading']), in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])), - media_count: state.getIn(['compose', 'media_attachments']).size + media_count: state.getIn(['compose', 'media_attachments']).size, + me: state.getIn(['compose', 'me']) }; }; @@ -65,6 +70,14 @@ const mapDispatchToProps = function (dispatch) { dispatch(changeComposeSensitivity(checked)); }, + onChangeSpoilerness (checked) { + dispatch(changeComposeSpoilerness(checked)); + }, + + onChangeSpoilerText (checked) { + dispatch(changeComposeSpoilerText(checked)); + }, + onChangeVisibility (checked) { dispatch(changeComposeVisibility(checked)); }, diff --git a/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx b/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx index 51e2513d8..0006608da 100644 --- a/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx @@ -1,8 +1,10 @@ import { connect } from 'react-redux'; import NavigationBar from '../components/navigation_bar'; -const mapStateToProps = (state, props) => ({ - account: state.getIn(['accounts', state.getIn(['meta', 'me'])]) -}); +const mapStateToProps = (state, props) => { + return { + account: state.getIn(['accounts', state.getIn(['meta', 'me'])]) + }; +}; export default connect(mapStateToProps)(NavigationBar); diff --git a/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx index 4154b0737..78e5312f5 100644 --- a/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx @@ -4,6 +4,7 @@ import { uploadCompose } from '../../../actions/compose'; const mapStateToProps = state => ({ disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), + resetFileKey: state.getIn(['compose', 'resetFileKey']) }); const mapDispatchToProps = dispatch => ({ diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx index 4017c8949..f6095c0c6 100644 --- a/app/assets/javascripts/components/features/compose/index.jsx +++ b/app/assets/javascripts/components/features/compose/index.jsx @@ -10,7 +10,8 @@ import { mountCompose, unmountCompose } from '../../actions/compose'; const Compose = React.createClass({ propTypes: { - dispatch: React.PropTypes.func.isRequired + dispatch: React.PropTypes.func.isRequired, + withHeader: React.PropTypes.bool }, mixins: [PureRenderMixin], @@ -25,7 +26,7 @@ const Compose = React.createClass({ render () { return ( - <Drawer> + <Drawer withHeader={this.props.withHeader}> <SearchContainer /> <NavigationContainer /> <ComposeFormContainer /> diff --git a/app/assets/javascripts/components/features/favourited_statuses/index.jsx b/app/assets/javascripts/components/features/favourited_statuses/index.jsx new file mode 100644 index 000000000..a2d521736 --- /dev/null +++ b/app/assets/javascripts/components/features/favourited_statuses/index.jsx @@ -0,0 +1,63 @@ +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites'; +import Column from '../ui/components/column'; +import StatusList from '../../components/status_list'; +import ColumnBackButton from '../public_timeline/components/column_back_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + heading: { id: 'column.favourites', defaultMessage: 'Favourites' } +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'favourites', 'items']), + loaded: state.getIn(['status_lists', 'favourites', 'loaded']), + me: state.getIn(['meta', 'me']) +}); + +const Favourites = React.createClass({ + + propTypes: { + params: React.PropTypes.object.isRequired, + dispatch: React.PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + loaded: React.PropTypes.bool, + intl: React.PropTypes.object.isRequired, + me: React.PropTypes.number.isRequired + }, + + mixins: [PureRenderMixin], + + componentWillMount () { + this.props.dispatch(fetchFavouritedStatuses()); + }, + + handleScrollToBottom () { + this.props.dispatch(expandFavouritedStatuses()); + }, + + render () { + const { statusIds, loaded, intl, me } = this.props; + + if (!loaded) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='star' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButton /> + <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} /> + </Column> + ); + } + +}); + +export default connect(mapStateToProps)(injectIntl(Favourites)); diff --git a/app/assets/javascripts/components/features/generic_not_found/index.jsx b/app/assets/javascripts/components/features/generic_not_found/index.jsx new file mode 100644 index 000000000..a7afe29b0 --- /dev/null +++ b/app/assets/javascripts/components/features/generic_not_found/index.jsx @@ -0,0 +1,10 @@ +import Column from '../ui/components/column'; +import MissingIndicator from '../../components/missing_indicator'; + +const GenericNotFound = () => ( + <Column> + <MissingIndicator /> + </Column> +); + +export default GenericNotFound; diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index 157bdf8f2..42e0a9e24 100644 --- a/app/assets/javascripts/components/features/getting_started/index.jsx +++ b/app/assets/javascripts/components/features/getting_started/index.jsx @@ -8,25 +8,16 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; const messages = defineMessages({ heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' }, - settings: { id: 'navigation_bar.settings', defaultMessage: 'Settings' }, - follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' } + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, + sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' } }); const mapStateToProps = state => ({ me: state.getIn(['accounts', state.getIn(['meta', 'me'])]) }); -const hamburgerStyle = { - background: '#373b4a', - color: '#fff', - fontSize: '16px', - padding: '15px', - position: 'absolute', - right: '0', - top: '-48px', - cursor: 'default' -}; - const GettingStarted = ({ intl, me }) => { let followRequests = ''; @@ -37,19 +28,21 @@ const GettingStarted = ({ intl, me }) => { return ( <Column icon='asterisk' heading={intl.formatMessage(messages.heading)}> <div style={{ position: 'relative' }}> - <div style={hamburgerStyle}><i className='fa fa-bars' /></div> <ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' /> - <ColumnLink icon='cog' text={intl.formatMessage(messages.settings)} href='/settings/profile' /> + <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> + <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> {followRequests} + <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> </div> - <div className='static-content'> - <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p> - <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p> - <p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p> + <div className='scrollable optionally-scrollable'> + <div className='static-content getting-started'> + <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p> + <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p> + <p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p> + <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a style={{ color: '#616b86'}} href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></p> + </div> </div> - - <div className='getting-started__illustration' /> </Column> ); }; diff --git a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx new file mode 100644 index 000000000..714be309b --- /dev/null +++ b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx @@ -0,0 +1,68 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnCollapsable from '../../../components/column_collapsable'; +import SettingToggle from '../../notifications/components/setting_toggle'; +import SettingText from './setting_text'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' } +}); + +const outerStyle = { + background: '#373b4a', + padding: '15px' +}; + +const sectionStyle = { + cursor: 'default', + display: 'block', + fontWeight: '500', + color: '#9baec8', + marginBottom: '10px' +}; + +const rowStyle = { + +}; + +const ColumnSettings = React.createClass({ + + propTypes: { + settings: ImmutablePropTypes.map.isRequired, + onChange: React.PropTypes.func.isRequired, + onSave: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired + }, + + mixins: [PureRenderMixin], + + render () { + const { settings, onChange, onSave, intl } = this.props; + + return ( + <ColumnCollapsable icon='sliders' fullHeight={209} onCollapse={onSave}> + <div style={outerStyle}> + <span style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} /> + </div> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> + </div> + + <span style={sectionStyle}><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div style={rowStyle}> + <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + </ColumnCollapsable> + ); + } + +}); + +export default injectIntl(ColumnSettings); diff --git a/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx b/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx new file mode 100644 index 000000000..79697e869 --- /dev/null +++ b/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx @@ -0,0 +1,41 @@ +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const style = { + display: 'block', + fontFamily: 'inherit', + marginBottom: '10px', + padding: '7px 0', + boxSizing: 'border-box', + width: '100%' +}; + +const SettingText = React.createClass({ + + propTypes: { + settings: ImmutablePropTypes.map.isRequired, + settingKey: React.PropTypes.array.isRequired, + label: React.PropTypes.string.isRequired, + onChange: React.PropTypes.func.isRequired + }, + + handleChange (e) { + this.props.onChange(this.props.settingKey, e.target.value) + }, + + render () { + const { settings, settingKey, label } = this.props; + + return ( + <input + style={style} + className='setting-text' + value={settings.getIn(settingKey)} + onChange={this.handleChange} + placeholder={label} + /> + ); + } + +}); + +export default SettingText; diff --git a/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx b/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx new file mode 100644 index 000000000..3b3ce19bc --- /dev/null +++ b/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting, saveSettings } from '../../../actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'home']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['home', ...key], checked)); + }, + + onSave () { + dispatch(saveSettings()); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/assets/javascripts/components/features/home_timeline/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx index e4f4fa7c7..5d2263f15 100644 --- a/app/assets/javascripts/components/features/home_timeline/index.jsx +++ b/app/assets/javascripts/components/features/home_timeline/index.jsx @@ -1,9 +1,8 @@ -import { connect } from 'react-redux'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../ui/components/column'; -import { refreshTimeline } from '../../actions/timelines'; import { defineMessages, injectIntl } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' } @@ -12,20 +11,17 @@ const messages = defineMessages({ const HomeTimeline = React.createClass({ propTypes: { - dispatch: React.PropTypes.func.isRequired + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], - componentWillMount () { - this.props.dispatch(refreshTimeline('home')); - }, - render () { const { intl } = this.props; return ( <Column icon='home' heading={intl.formatMessage(messages.title)}> + <ColumnSettingsContainer /> <StatusListContainer {...this.props} type='home' /> </Column> ); @@ -33,4 +29,4 @@ const HomeTimeline = React.createClass({ }); -export default connect()(injectIntl(HomeTimeline)); +export default injectIntl(HomeTimeline); diff --git a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx index b4035c20d..b63c1881a 100644 --- a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx +++ b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx @@ -1,37 +1,14 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Toggle from 'react-toggle'; -import { Motion, spring } from 'react-motion'; import { FormattedMessage } from 'react-intl'; +import ColumnCollapsable from '../../../components/column_collapsable'; +import SettingToggle from './setting_toggle'; const outerStyle = { background: '#373b4a', padding: '15px' }; -const iconStyle = { - fontSize: '16px', - padding: '15px', - position: 'absolute', - right: '0', - top: '-48px', - cursor: 'pointer' -}; - -const labelStyle = { - display: 'block', - lineHeight: '24px', - verticalAlign: 'middle' -}; - -const labelSpanStyle = { - display: 'inline-block', - verticalAlign: 'middle', - marginBottom: '14px', - marginLeft: '8px', - color: '#9baec8' -}; - const sectionStyle = { cursor: 'default', display: 'block', @@ -48,100 +25,55 @@ const ColumnSettings = React.createClass({ propTypes: { settings: ImmutablePropTypes.map.isRequired, - onChange: React.PropTypes.func.isRequired - }, - - getInitialState () { - return { - collapsed: true - }; + onChange: React.PropTypes.func.isRequired, + onSave: React.PropTypes.func.isRequired }, mixins: [PureRenderMixin], - handleToggleCollapsed () { - this.setState({ collapsed: !this.state.collapsed }); - }, - - handleChange (key, e) { - this.props.onChange(key, e.target.checked); - }, - render () { - const { settings } = this.props; - const { collapsed } = this.state; + const { settings, onChange, onSave } = this.props; const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />; + const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; return ( - <div style={{ position: 'relative' }}> - <div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className='fa fa-sliders' /></div> - - <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : 458) }}> - {({ opacity, height }) => - <div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}> - <div style={outerStyle}> - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> - - <div style={rowStyle}> - <label style={labelStyle}> - <Toggle checked={settings.getIn(['alerts', 'follow'])} onChange={this.handleChange.bind(this, ['alerts', 'follow'])} /> - <span style={labelSpanStyle}>{alertStr}</span> - </label> - - <label style={labelStyle}> - <Toggle checked={settings.getIn(['shows', 'follow'])} onChange={this.handleChange.bind(this, ['shows', 'follow'])} /> - <span style={labelSpanStyle}>{showStr}</span> - </label> - </div> - - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> - - <div style={rowStyle}> - <label style={labelStyle}> - <Toggle checked={settings.getIn(['alerts', 'favourite'])} onChange={this.handleChange.bind(this, ['alerts', 'favourite'])} /> - <span style={labelSpanStyle}>{alertStr}</span> - </label> - - <label style={labelStyle}> - <Toggle checked={settings.getIn(['shows', 'favourite'])} onChange={this.handleChange.bind(this, ['shows', 'favourite'])} /> - <span style={labelSpanStyle}>{showStr}</span> - </label> - </div> - - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> - - <div style={rowStyle}> - <label style={labelStyle}> - <Toggle checked={settings.getIn(['alerts', 'mention'])} onChange={this.handleChange.bind(this, ['alerts', 'mention'])} /> - <span style={labelSpanStyle}>{alertStr}</span> - </label> - - <label style={labelStyle}> - <Toggle checked={settings.getIn(['shows', 'mention'])} onChange={this.handleChange.bind(this, ['shows', 'mention'])} /> - <span style={labelSpanStyle}>{showStr}</span> - </label> - </div> - - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> - - <div style={rowStyle}> - <label style={labelStyle}> - <Toggle checked={settings.getIn(['alerts', 'reblog'])} onChange={this.handleChange.bind(this, ['alerts', 'reblog'])} /> - <span style={labelSpanStyle}>{alertStr}</span> - </label> - - <label style={labelStyle}> - <Toggle checked={settings.getIn(['shows', 'reblog'])} onChange={this.handleChange.bind(this, ['shows', 'reblog'])} /> - <span style={labelSpanStyle}>{showStr}</span> - </label> - </div> - </div> - </div> - } - </Motion> - </div> + <ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}> + <div style={outerStyle}> + <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> + </div> + + <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> + </div> + + <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> + </div> + + <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> + </div> + </div> + </ColumnCollapsable> ); } diff --git a/app/assets/javascripts/components/features/notifications/components/notification.jsx b/app/assets/javascripts/components/features/notifications/components/notification.jsx index 9f4cf9e4d..140ba9134 100644 --- a/app/assets/javascripts/components/features/notifications/components/notification.jsx +++ b/app/assets/javascripts/components/features/notifications/components/notification.jsx @@ -4,6 +4,8 @@ import StatusContainer from '../../../containers/status_container'; import AccountContainer from '../../../containers/account_container'; import { FormattedMessage } from 'react-intl'; import Permalink from '../../../components/permalink'; +import emojify from '../../../emoji'; +import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; const messageStyle = { marginLeft: '68px', @@ -71,7 +73,7 @@ const Notification = React.createClass({ <i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} /> </div> - <FormattedMessage id='notification.reblog' defaultMessage='{name} reblogged your status' values={{ name: link }} /> + <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> </div> <StatusContainer id={notification.get('status')} muted={true} /> @@ -83,7 +85,8 @@ const Notification = React.createClass({ const { notification } = this.props; const account = notification.get('account'); const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username'); - const link = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`}>{displayName}</Permalink>; + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + const link = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />; switch(notification.get('type')) { case 'follow': diff --git a/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx new file mode 100644 index 000000000..c2438f716 --- /dev/null +++ b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx @@ -0,0 +1,32 @@ +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; + +const labelStyle = { + display: 'block', + lineHeight: '24px', + verticalAlign: 'middle' +}; + +const labelSpanStyle = { + display: 'inline-block', + verticalAlign: 'middle', + marginBottom: '14px', + marginLeft: '8px', + color: '#9baec8' +}; + +const SettingToggle = ({ settings, settingKey, label, onChange }) => ( + <label style={labelStyle}> + <Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} /> + <span style={labelSpanStyle}>{label}</span> + </label> +); + +SettingToggle.propTypes = { + settings: ImmutablePropTypes.map.isRequired, + settingKey: React.PropTypes.array.isRequired, + label: React.PropTypes.node.isRequired, + onChange: React.PropTypes.func.isRequired +}; + +export default SettingToggle; diff --git a/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx b/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx index 6907fd351..bc24c75e0 100644 --- a/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx +++ b/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx @@ -1,15 +1,19 @@ import { connect } from 'react-redux'; import ColumnSettings from '../components/column_settings'; -import { changeNotificationsSetting } from '../../../actions/notifications'; +import { changeSetting, saveSettings } from '../../../actions/settings'; const mapStateToProps = state => ({ - settings: state.getIn(['notifications', 'settings']) + settings: state.getIn(['settings', 'notifications']) }); const mapDispatchToProps = dispatch => ({ onChange (key, checked) { - dispatch(changeNotificationsSetting(key, checked)); + dispatch(changeSetting(['notifications', ...key], checked)); + }, + + onSave () { + dispatch(saveSettings()); } }); diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx index 7e706ad6a..b4593aaff 100644 --- a/app/assets/javascripts/components/features/notifications/index.jsx +++ b/app/assets/javascripts/components/features/notifications/index.jsx @@ -2,10 +2,7 @@ import { connect } from 'react-redux'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Column from '../ui/components/column'; -import { - refreshNotifications, - expandNotifications -} from '../../actions/notifications'; +import { expandNotifications } from '../../actions/notifications'; import NotificationContainer from './containers/notification_container'; import { ScrollContainer } from 'react-router-scroll'; import { defineMessages, injectIntl } from 'react-intl'; @@ -18,12 +15,13 @@ const messages = defineMessages({ }); const getNotifications = createSelector([ - state => Immutable.List(state.getIn(['notifications', 'settings', 'shows']).filter(item => !item).keys()), + state => Immutable.List(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), state => state.getIn(['notifications', 'items']) ], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type')))); const mapStateToProps = state => ({ - notifications: getNotifications(state) + notifications: getNotifications(state), + isLoading: state.getIn(['notifications', 'isLoading'], true) }); const Notifications = React.createClass({ @@ -32,7 +30,8 @@ const Notifications = React.createClass({ notifications: ImmutablePropTypes.list.isRequired, dispatch: React.PropTypes.func.isRequired, trackScroll: React.PropTypes.bool, - intl: React.PropTypes.object.isRequired + intl: React.PropTypes.object.isRequired, + isLoading: React.PropTypes.bool }, getDefaultProps () { @@ -43,15 +42,11 @@ const Notifications = React.createClass({ mixins: [PureRenderMixin], - componentWillMount () { - const { dispatch } = this.props; - dispatch(refreshNotifications()); - }, - handleScroll (e) { const { scrollTop, scrollHeight, clientHeight } = e.target; + const offset = scrollHeight - scrollTop - clientHeight; - if (scrollTop === scrollHeight - clientHeight) { + if (250 > offset && !this.props.isLoading) { this.props.dispatch(expandNotifications()); } }, @@ -70,6 +65,7 @@ const Notifications = React.createClass({ if (trackScroll) { return ( <Column icon='bell' heading={intl.formatMessage(messages.title)}> + <ColumnSettingsContainer /> <ScrollContainer scrollKey='notifications'> {scrollableArea} </ScrollContainer> diff --git a/app/assets/javascripts/components/features/status/components/action_bar.jsx b/app/assets/javascripts/components/features/status/components/action_bar.jsx index 030428440..3f8a0457d 100644 --- a/app/assets/javascripts/components/features/status/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx @@ -61,8 +61,8 @@ const ActionBar = React.createClass({ <div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div> - <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> - <div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div> + <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> + <div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div> </div> ); } diff --git a/app/assets/javascripts/components/features/status/components/card.jsx b/app/assets/javascripts/components/features/status/components/card.jsx new file mode 100644 index 000000000..ccb06dfd5 --- /dev/null +++ b/app/assets/javascripts/components/features/status/components/card.jsx @@ -0,0 +1,100 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const outerStyle = { + display: 'flex', + cursor: 'pointer', + fontSize: '14px', + border: '1px solid #363c4b', + borderRadius: '4px', + color: '#616b86', + marginTop: '14px', + textDecoration: 'none', + overflow: 'hidden' +}; + +const contentStyle = { + flex: '1 1 auto', + padding: '8px', + paddingLeft: '14px', + overflow: 'hidden' +}; + +const titleStyle = { + display: 'block', + fontWeight: '500', + marginBottom: '5px', + color: '#d9e1e8', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' +}; + +const descriptionStyle = { + color: '#d9e1e8' +}; + +const imageOuterStyle = { + flex: '0 0 100px', + background: '#373b4a' +}; + +const imageStyle = { + display: 'block', + width: '100%', + height: 'auto', + margin: '0', + borderRadius: '4px 0 0 4px' +}; + +const hostStyle = { + display: 'block', + marginTop: '5px', + fontSize: '13px' +}; + +const getHostname = url => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +const Card = React.createClass({ + propTypes: { + card: ImmutablePropTypes.map + }, + + mixins: [PureRenderMixin], + + render () { + const { card } = this.props; + + if (card === null) { + return null; + } + + let image = ''; + + if (card.get('image')) { + image = ( + <div style={imageOuterStyle}> + <img src={card.get('image')} alt={card.get('title')} style={imageStyle} /> + </div> + ); + } + + return ( + <a style={outerStyle} href={card.get('url')} className='status-card'> + {image} + + <div style={contentStyle}> + <strong style={titleStyle} title={card.get('title')}>{card.get('title')}</strong> + <p style={descriptionStyle}>{card.get('description').substring(0, 50)}</p> + <span style={hostStyle}>{getHostname(card.get('url'))}</span> + </div> + </a> + ); + } +}); + +export default Card; diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx index b967d966f..f2d6ae48a 100644 --- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx +++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx @@ -7,6 +7,7 @@ import MediaGallery from '../../../components/media_gallery'; import VideoPlayer from '../../../components/video_player'; import { Link } from 'react-router'; import { FormattedDate, FormattedNumber } from 'react-intl'; +import CardContainer from '../containers/card_container'; const DetailedStatus = React.createClass({ @@ -32,7 +33,9 @@ const DetailedStatus = React.createClass({ render () { const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; - let media = ''; + + let media = ''; + let applicationLink = ''; if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'video') { @@ -40,6 +43,12 @@ const DetailedStatus = React.createClass({ } else { media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />; } + } else { + media = <CardContainer statusId={status.get('id')} />; + } + + if (status.get('application')) { + applicationLink = <span> · <a className='detailed-status__application' style={{ color: 'inherit' }} href={status.getIn(['application', 'website'])} target='_blank' rel='nooopener'>{status.getIn(['application', 'name'])}</a></span>; } return ( @@ -54,7 +63,7 @@ const DetailedStatus = React.createClass({ {media} <div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}> - <a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a> · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link> + <a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link> </div> </div> ); diff --git a/app/assets/javascripts/components/features/status/containers/card_container.jsx b/app/assets/javascripts/components/features/status/containers/card_container.jsx new file mode 100644 index 000000000..5c8bfeec2 --- /dev/null +++ b/app/assets/javascripts/components/features/status/containers/card_container.jsx @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Card from '../components/card'; + +const mapStateToProps = (state, { statusId }) => ({ + card: state.getIn(['cards', statusId], null) +}); + +export default connect(mapStateToProps)(Card); diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx index 0a1528fe9..389549849 100644 --- a/app/assets/javascripts/components/features/status/index.jsx +++ b/app/assets/javascripts/components/features/status/index.jsx @@ -23,6 +23,7 @@ import { ScrollContainer } from 'react-router-scroll'; import ColumnBackButton from '../../components/column_back_button'; import StatusContainer from '../../containers/status_container'; import { openMedia } from '../../actions/modal'; +import { isMobile } from '../../is_mobile' const makeMapStateToProps = () => { const getStatus = makeGetStatus(); @@ -47,7 +48,8 @@ const Status = React.createClass({ dispatch: React.PropTypes.func.isRequired, status: ImmutablePropTypes.map, ancestorsIds: ImmutablePropTypes.list, - descendantsIds: ImmutablePropTypes.list + descendantsIds: ImmutablePropTypes.list, + me: React.PropTypes.number }, mixins: [PureRenderMixin], @@ -80,6 +82,10 @@ const Status = React.createClass({ handleMentionClick (account) { this.props.dispatch(mentionCompose(account)); + + if (isMobile(window.innerWidth)) { + this.context.router.push('/statuses/new'); + } }, handleOpenMedia (url) { diff --git a/app/assets/javascripts/components/features/ui/components/column_link.jsx b/app/assets/javascripts/components/features/ui/components/column_link.jsx index a2f7c13a6..901a29f5c 100644 --- a/app/assets/javascripts/components/features/ui/components/column_link.jsx +++ b/app/assets/javascripts/components/features/ui/components/column_link.jsx @@ -13,10 +13,10 @@ const iconStyle = { marginRight: '5px' }; -const ColumnLink = ({ icon, text, to, href }) => { +const ColumnLink = ({ icon, text, to, href, method }) => { if (href) { return ( - <a href={href} style={outerStyle} className='column-link'> + <a href={href} style={outerStyle} className='column-link' data-method={method}> <i className={`fa fa-fw fa-${icon}`} style={iconStyle} /> {text} </a> diff --git a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx index 219979522..2f8a28fad 100644 --- a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx +++ b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx @@ -3,15 +3,14 @@ import { FormattedMessage } from 'react-intl'; const outerStyle = { background: '#373b4a', - margin: '10px', flex: '0 0 auto', - marginBottom: '0' + overflowY: 'auto' }; const tabStyle = { display: 'block', flex: '1 1 auto', - padding: '10px', + padding: '10px 5px', color: '#fff', textDecoration: 'none', textAlign: 'center', @@ -31,7 +30,7 @@ const TabsBar = () => { <Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link> <Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link> <Link style={tabStyle} activeStyle={tabActiveStyle} to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link> - <Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /> <FormattedMessage id='tabs_bar.public' defaultMessage='Public' /></Link> + <Link style={{ ...tabStyle, flexGrow: '0', flexBasis: '30px' }} activeStyle={tabActiveStyle} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link> </div> ); }; diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx index cd7d63a4a..53d162462 100644 --- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx @@ -1,6 +1,9 @@ -import { connect } from 'react-redux'; -import { closeModal } from '../../../actions/modal'; -import Lightbox from '../../../components/lightbox'; +import { connect } from 'react-redux'; +import { closeModal } from '../../../actions/modal'; +import Lightbox from '../../../components/lightbox'; +import ImageLoader from 'react-imageloader'; +import LoadingIndicator from '../../../components/loading_indicator'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; const mapStateToProps = state => ({ url: state.getIn(['modal', 'url']), @@ -23,6 +26,18 @@ const imageStyle = { maxHeight: '80vh' }; +const loadingStyle = { + background: '#373b4a', + width: '400px', + paddingBottom: '120px' +}; + +const preloader = () => ( + <div style={loadingStyle}> + <LoadingIndicator /> + </div> +); + const Modal = React.createClass({ propTypes: { @@ -32,12 +47,18 @@ const Modal = React.createClass({ onOverlayClicked: React.PropTypes.func }, + mixins: [PureRenderMixin], + render () { const { url, ...other } = this.props; return ( <Lightbox {...other}> - <img src={url} style={imageStyle} /> + <ImageLoader + src={url} + preloader={preloader} + imgProps={{ style: imageStyle }} + /> </Lightbox> ); } diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx index 1621cec7b..8af7b0c3c 100644 --- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx @@ -2,26 +2,56 @@ import { connect } from 'react-redux'; import StatusList from '../../../components/status_list'; import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines'; import Immutable from 'immutable'; +import { createSelector } from 'reselect'; + +const getStatusIds = createSelector([ + (state, { type }) => state.getIn(['settings', type], Immutable.Map()), + (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()), + (state) => state.get('statuses') +], (columnSettings, statusIds, statuses) => statusIds.filter(id => { + const statusForId = statuses.get(id); + let showStatus = true; + + if (columnSettings.getIn(['shows', 'reblog']) === false) { + showStatus = showStatus && statusForId.get('reblog') === null; + } + + if (columnSettings.getIn(['shows', 'reply']) === false) { + showStatus = showStatus && statusForId.get('in_reply_to_id') === null; + } + + if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) { + try { + const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i'); + showStatus = showStatus && !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'content']) : statusForId.get('content')); + } catch(e) { + // Bad regex, don't affect filters + } + } + + return showStatus; +})); const mapStateToProps = (state, props) => ({ - statusIds: state.getIn(['timelines', props.type, 'items'], Immutable.List()) + statusIds: getStatusIds(state, props), + isLoading: state.getIn(['timelines', props.type, 'isLoading'], true) }); -const mapDispatchToProps = function (dispatch, props) { - return { - onScrollToBottom () { - dispatch(scrollTopTimeline(props.type, false)); - dispatch(expandTimeline(props.type, props.id)); - }, +const mapDispatchToProps = (dispatch, { type, id }) => ({ - onScrollToTop () { - dispatch(scrollTopTimeline(props.type, true)); - }, + onScrollToBottom () { + dispatch(scrollTopTimeline(type, false)); + dispatch(expandTimeline(type, id)); + }, - onScroll () { - dispatch(scrollTopTimeline(props.type, false)); - } - }; -}; + onScrollToTop () { + dispatch(scrollTopTimeline(type, true)); + }, + + onScroll () { + dispatch(scrollTopTimeline(type, false)); + } + +}); export default connect(mapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx index 76e3dd940..003d061ad 100644 --- a/app/assets/javascripts/components/features/ui/index.jsx +++ b/app/assets/javascripts/components/features/ui/index.jsx @@ -8,12 +8,20 @@ import Compose from '../compose'; import TabsBar from './components/tabs_bar'; import ModalContainer from './containers/modal_container'; import Notifications from '../notifications'; +import { connect } from 'react-redux'; +import { isMobile } from '../../is_mobile'; import { debounce } from 'react-decoration'; import { uploadCompose } from '../../actions/compose'; -import { connect } from 'react-redux'; +import { refreshTimeline } from '../../actions/timelines'; +import { refreshNotifications } from '../../actions/notifications'; const UI = React.createClass({ + propTypes: { + dispatch: React.PropTypes.func.isRequired, + children: React.PropTypes.node + }, + getInitialState () { return { width: window.innerWidth @@ -41,7 +49,7 @@ const UI = React.createClass({ handleDrop (e) { e.preventDefault(); - if (e.dataTransfer) { + if (e.dataTransfer && e.dataTransfer.files.length === 1) { this.props.dispatch(uploadCompose(e.dataTransfer.files)); } }, @@ -50,6 +58,9 @@ const UI = React.createClass({ window.addEventListener('resize', this.handleResize, { passive: true }); window.addEventListener('dragover', this.handleDragOver); window.addEventListener('drop', this.handleDrop); + + this.props.dispatch(refreshTimeline('home')); + this.props.dispatch(refreshNotifications()); }, componentWillUnmount () { @@ -59,11 +70,9 @@ const UI = React.createClass({ }, render () { - const layoutBreakpoint = 1024; - let mountedColumns; - if (this.state.width <= layoutBreakpoint) { + if (isMobile(this.state.width)) { mountedColumns = ( <ColumnsArea> {this.props.children} @@ -72,7 +81,7 @@ const UI = React.createClass({ } else { mountedColumns = ( <ColumnsArea> - <Compose /> + <Compose withHeader={true} /> <HomeTimeline trackScroll={false} /> <Notifications trackScroll={false} /> {this.props.children} diff --git a/app/assets/javascripts/components/is_mobile.jsx b/app/assets/javascripts/components/is_mobile.jsx new file mode 100644 index 000000000..eaa6221e4 --- /dev/null +++ b/app/assets/javascripts/components/is_mobile.jsx @@ -0,0 +1,5 @@ +const LAYOUT_BREAKPOINT = 1024; + +export function isMobile(width) { + return width <= LAYOUT_BREAKPOINT; +}; diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx index 17b74e15d..7d32824f1 100644 --- a/app/assets/javascripts/components/locales/de.jsx +++ b/app/assets/javascripts/components/locales/de.jsx @@ -8,6 +8,9 @@ const en = { "status.reblog": "Teilen", "status.favourite": "Favorisieren", "status.reblogged_by": "{name} teilte", + "status.sensitive_warning": "Sensible Inhalte", + "status.sensitive_toggle": "Klicken um zu zeigen", + "status.open": "Öffnen", "video_player.toggle_sound": "Ton umschalten", "account.mention": "Erwähnen", "account.edit_profile": "Profil bearbeiten", @@ -19,14 +22,17 @@ const en = { "account.follows": "Folgt", "account.followers": "Folger", "account.follows_you": "Folgt dir", + "account.requested": "Warte auf Erlaubnis", "getting_started.heading": "Erste Schritte", "getting_started.about_addressing": "Du kannst Leuten folgen, falls du ihren Nutzernamen und ihre Domain kennst, in dem du eine e-mail-artige Addresse in das Suchfeld oben an der Seite eingibst.", "getting_started.about_shortcuts": "Falls der Zielnutzer an derselben Domain ist wie du, funktioniert der Nutzername auch alleine. Das gilt auch für Erwähnungen in Beiträgen.", "getting_started.about_developer": "Der Entwickler des Projekts kann unter Gargron@mastodon.social gefunden werden", + "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", "column.home": "Home", "column.mentions": "Erwähnungen", "column.public": "Gesamtes Bekanntes Netz", "column.notifications": "Mitteilungen", + "column.follow_requests": "Folgeanfragen", "tabs_bar.compose": "Schreiben", "tabs_bar.home": "Home", "tabs_bar.mentions": "Erwähnungen", @@ -36,9 +42,12 @@ const en = { "compose_form.publish": "Veröffentlichen", "compose_form.sensitive": "Medien als sensitiv markieren", "compose_form.unlisted": "Öffentlich nicht auflisten", - "navigation_bar.settings": "Einstellungen", + "compose_form.private": "Als privat markieren", + "navigation_bar.edit_profile": "Profil bearbeiten", + "navigation_bar.preferences": "Einstellungen", "navigation_bar.public_timeline": "Öffentlich", "navigation_bar.logout": "Abmelden", + "navigation_bar.follow_requests": "Folgeanfragen", "reply_indicator.cancel": "Abbrechen", "search.placeholder": "Suche", "search.account": "Konto", @@ -48,7 +57,21 @@ const en = { "notification.follow": "{name} folgt dir", "notification.favourite": "{name} favorisierte deinen Status", "notification.reblog": "{name} teilte deinen Status", - "notification.mention": "{name} erwähnte dich" + "notification.mention": "{name} erwähnte dich", + "notifications.column_settings.alert": "Desktop-Benachrichtigunen", + "notifications.column_settings.show": "In der Spalte anzeigen", + "notifications.column_settings.follow": "Neue Folger:", + "notifications.column_settings.favourite": "Favorisierungen:", + "notifications.column_settings.mention": "Erwähnungen:", + "notifications.column_settings.reblog": "Geteilte Beiträge:", + "follow_request.authorize": "Erlauben", + "follow_request.reject": "Ablehnen", + "home.column_settings.basic": "Einfach", + "home.column_settings.advanced": "Fortgeschritten", + "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen", + "home.column_settings.show_replies": "Antworten anzeigen", + "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke", + "missing_indicator.label": "Nicht gefunden" }; export default en; diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx index 3d4a38919..92dcbaeb9 100644 --- a/app/assets/javascripts/components/locales/en.jsx +++ b/app/assets/javascripts/components/locales/en.jsx @@ -17,7 +17,6 @@ const en = { "account.unfollow": "Unfollow", "account.block": "Block", "account.follow": "Follow", - "account.block": "Block", "account.posts": "Posts", "account.follows": "Follows", "account.followers": "Followers", @@ -27,6 +26,7 @@ const en = { "getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.", "getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.", "getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social", + "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.", "column.home": "Home", "column.mentions": "Mentions", "column.public": "Public", @@ -40,7 +40,9 @@ const en = { "compose_form.publish": "Toot", "compose_form.sensitive": "Mark media as sensitive", "compose_form.private": "Mark as private", - "navigation_bar.settings": "Settings", + "compose_form.unlisted": "Do not display in public timeline", + "navigation_bar.edit_profile": "Edit profile", + "navigation_bar.preferences": "Preferences", "navigation_bar.public_timeline": "Public timeline", "navigation_bar.logout": "Logout", "reply_indicator.cancel": "Cancel", diff --git a/app/assets/javascripts/components/locales/es.jsx b/app/assets/javascripts/components/locales/es.jsx index 6bd9b18ed..b75fb57d9 100644 --- a/app/assets/javascripts/components/locales/es.jsx +++ b/app/assets/javascripts/components/locales/es.jsx @@ -37,7 +37,8 @@ const es = { "compose_form.publish": "Publicar", "compose_form.sensitive": "Marcar el contenido como sensible", "compose_form.unlisted": "Privado", - "navigation_bar.settings": "Ajustes", + "navigation_bar.edit_profile": "Editar perfil", + "navigation_bar.preferences": "Preferencias", "navigation_bar.public_timeline": "Público", "navigation_bar.logout": "Cerrar sesión", "reply_indicator.cancel": "Cancelar", diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx index 968c3f8c3..183e5d5b5 100644 --- a/app/assets/javascripts/components/locales/fr.jsx +++ b/app/assets/javascripts/components/locales/fr.jsx @@ -38,7 +38,8 @@ const fr = { "compose_form.publish": "Pouet", "compose_form.sensitive": "Marquer le contenu comme délicat", "compose_form.unlisted": "Ne pas apparaître dans le fil public", - "navigation_bar.settings": "Paramètres", + "navigation_bar.edit_profile": "Modifier le profil", + "navigation_bar.preferences": "Préférences", "navigation_bar.public_timeline": "Public", "navigation_bar.logout": "Déconnexion", "reply_indicator.cancel": "Annuler", diff --git a/app/assets/javascripts/components/locales/hu.jsx b/app/assets/javascripts/components/locales/hu.jsx index 606fc830f..9a2d14d87 100644 --- a/app/assets/javascripts/components/locales/hu.jsx +++ b/app/assets/javascripts/components/locales/hu.jsx @@ -38,7 +38,8 @@ const hu = { "compose_form.publish": "Tülk!", "compose_form.sensitive": "Tartalom érzékenynek jelölése", "compose_form.unlisted": "Listázatlan mód", - "navigation_bar.settings": "Beállítások", + "navigation_bar.edit_profile": "Profil szerkesztése", + "navigation_bar.preferences": "Beállítások", "navigation_bar.public_timeline": "Nyilvános időfolyam", "navigation_bar.logout": "Kijelentkezés", "reply_indicator.cancel": "Mégsem", diff --git a/app/assets/javascripts/components/locales/pt.jsx b/app/assets/javascripts/components/locales/pt.jsx index 57cbcd31b..d68724b13 100644 --- a/app/assets/javascripts/components/locales/pt.jsx +++ b/app/assets/javascripts/components/locales/pt.jsx @@ -36,7 +36,8 @@ const pt = { "compose_form.publish": "Publicar", "compose_form.sensitive": "Marcar conteúdo como sensível", "compose_form.unlisted": "Modo não-listado", - "navigation_bar.settings": "Configurações", + "navigation_bar.edit_profile": "Editar perfil", + "navigation_bar.preferences": "Preferências", "navigation_bar.public_timeline": "Timeline Pública", "navigation_bar.logout": "Logout", "reply_indicator.cancel": "Cancelar", diff --git a/app/assets/javascripts/components/locales/uk.jsx b/app/assets/javascripts/components/locales/uk.jsx index 53535c25a..84a348c21 100644 --- a/app/assets/javascripts/components/locales/uk.jsx +++ b/app/assets/javascripts/components/locales/uk.jsx @@ -38,7 +38,8 @@ const uk = { "compose_form.publish": "Дмухнути", "compose_form.sensitive": "Непристойний зміст", "compose_form.unlisted": "Таємний режим", - "navigation_bar.settings": "Налаштування", + "navigation_bar.edit_profile": "Редагувати профіль", + "navigation_bar.preferences": "Налаштування", "navigation_bar.public_timeline": "Публічна стіна", "navigation_bar.logout": "Вийти", "reply_indicator.cancel": "Відмінити", diff --git a/app/assets/javascripts/components/middleware/errors.jsx b/app/assets/javascripts/components/middleware/errors.jsx index 3a1473bc1..74d77f0f9 100644 --- a/app/assets/javascripts/components/middleware/errors.jsx +++ b/app/assets/javascripts/components/middleware/errors.jsx @@ -23,7 +23,7 @@ export default function errorsMiddleware() { dispatch(showAlert(title, message)); } else { console.error(action.error); - dispatch(showAlert('Oops!', 'An unexpected error occurred. Inspect the console for more details')); + dispatch(showAlert('Oops!', 'An unexpected error occurred.')); } } } diff --git a/app/assets/javascripts/components/middleware/loading_bar.jsx b/app/assets/javascripts/components/middleware/loading_bar.jsx new file mode 100644 index 000000000..a98f1bb2b --- /dev/null +++ b/app/assets/javascripts/components/middleware/loading_bar.jsx @@ -0,0 +1,25 @@ +import { showLoading, hideLoading } from 'react-redux-loading-bar'; + +const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED']; + +export default function loadingBarMiddleware(config = {}) { + const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes; + + return ({ dispatch }) => next => (action) => { + if (action.type && !action.skipLoading) { + const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes; + + const isPending = new RegExp(`${PENDING}$`, 'g'); + const isFulfilled = new RegExp(`${FULFILLED}$`, 'g'); + const isRejected = new RegExp(`${REJECTED}$`, 'g'); + + if (action.type.match(isPending)) { + dispatch(showLoading()); + } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) { + dispatch(hideLoading()); + } + } + + return next(action); + }; +}; diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index 7f2f89d0a..409dfd663 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -1,5 +1,4 @@ import { - ACCOUNT_SET_SELF, ACCOUNT_FETCH_SUCCESS, FOLLOWERS_FETCH_SUCCESS, FOLLOWERS_EXPAND_SUCCESS, @@ -7,7 +6,9 @@ import { FOLLOWING_EXPAND_SUCCESS, ACCOUNT_TIMELINE_FETCH_SUCCESS, ACCOUNT_TIMELINE_EXPAND_SUCCESS, - FOLLOW_REQUESTS_FETCH_SUCCESS + FOLLOW_REQUESTS_FETCH_SUCCESS, + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS } from '../actions/accounts'; import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; import { @@ -33,6 +34,11 @@ import { NOTIFICATIONS_REFRESH_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS } from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import { STORE_HYDRATE } from '../actions/store'; import Immutable from 'immutable'; const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account)); @@ -67,38 +73,45 @@ const initialState = Immutable.Map(); export default function accounts(state = initialState, action) { switch(action.type) { - case ACCOUNT_SET_SELF: - case ACCOUNT_FETCH_SUCCESS: - case NOTIFICATIONS_UPDATE: - return normalizeAccount(state, action.account); - case FOLLOWERS_FETCH_SUCCESS: - case FOLLOWERS_EXPAND_SUCCESS: - case FOLLOWING_FETCH_SUCCESS: - case FOLLOWING_EXPAND_SUCCESS: - case REBLOGS_FETCH_SUCCESS: - case FAVOURITES_FETCH_SUCCESS: - case COMPOSE_SUGGESTIONS_READY: - case SEARCH_SUGGESTIONS_READY: - case FOLLOW_REQUESTS_FETCH_SUCCESS: - return normalizeAccounts(state, action.accounts); - case NOTIFICATIONS_REFRESH_SUCCESS: - case NOTIFICATIONS_EXPAND_SUCCESS: - return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); - case TIMELINE_REFRESH_SUCCESS: - case TIMELINE_EXPAND_SUCCESS: - case ACCOUNT_TIMELINE_FETCH_SUCCESS: - case ACCOUNT_TIMELINE_EXPAND_SUCCESS: - case CONTEXT_FETCH_SUCCESS: - return normalizeAccountsFromStatuses(state, action.statuses); - case REBLOG_SUCCESS: - case FAVOURITE_SUCCESS: - case UNREBLOG_SUCCESS: - case UNFAVOURITE_SUCCESS: - return normalizeAccountFromStatus(state, action.response); - case TIMELINE_UPDATE: - case STATUS_FETCH_SUCCESS: - return normalizeAccountFromStatus(state, action.status); - default: - return state; + case STORE_HYDRATE: + return state.merge(action.state.get('accounts')); + case ACCOUNT_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeAccount(state, action.account); + case FOLLOWERS_FETCH_SUCCESS: + case FOLLOWERS_EXPAND_SUCCESS: + case FOLLOWING_FETCH_SUCCESS: + case FOLLOWING_EXPAND_SUCCESS: + case REBLOGS_FETCH_SUCCESS: + case FAVOURITES_FETCH_SUCCESS: + case COMPOSE_SUGGESTIONS_READY: + case SEARCH_SUGGESTIONS_READY: + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return normalizeAccounts(state, action.accounts); + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(state, action.statuses); + case REBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNREBLOG_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeAccountFromStatus(state, action.response); + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + return normalizeAccountFromStatus(state, action.status); + case ACCOUNT_FOLLOW_SUCCESS: + return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); + case ACCOUNT_UNFOLLOW_SUCCESS: + return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1)); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/cards.jsx b/app/assets/javascripts/components/reducers/cards.jsx new file mode 100644 index 000000000..3c9395011 --- /dev/null +++ b/app/assets/javascripts/components/reducers/cards.jsx @@ -0,0 +1,14 @@ +import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards'; + +import Immutable from 'immutable'; + +const initialState = Immutable.Map(); + +export default function cards(state = initialState, action) { + switch(action.type) { + case STATUS_CARD_FETCH_SUCCESS: + return state.set(action.id, Immutable.fromJS(action.card)); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index 16215684e..d3a84842f 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -17,16 +17,20 @@ import { COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTION_SELECT, COMPOSE_SENSITIVITY_CHANGE, + COMPOSE_SPOILERNESS_CHANGE, + COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_VISIBILITY_CHANGE, COMPOSE_LISTABILITY_CHANGE } from '../actions/compose'; import { TIMELINE_DELETE } from '../actions/timelines'; -import { ACCOUNT_SET_SELF } from '../actions/accounts'; +import { STORE_HYDRATE } from '../actions/store'; import Immutable from 'immutable'; const initialState = Immutable.Map({ mounted: false, sensitive: false, + spoiler: false, + spoiler_text: '', unlisted: false, private: false, text: '', @@ -38,7 +42,8 @@ const initialState = Immutable.Map({ media_attachments: Immutable.List(), suggestion_token: null, suggestions: Immutable.List(), - me: null + me: null, + resetFileKey: Math.floor((Math.random() * 0x10000)) }); function statusToTextMentions(state, status) { @@ -55,6 +60,8 @@ function statusToTextMentions(state, status) { function clearAll(state) { return state.withMutations(map => { map.set('text', ''); + map.set('spoiler', false); + map.set('spoiler_text', ''); map.set('is_submitting', false); map.set('in_reply_to', null); map.update('media_attachments', list => list.clear()); @@ -65,6 +72,7 @@ function appendMedia(state, media) { return state.withMutations(map => { map.update('media_attachments', list => list.push(media)); map.set('is_uploading', false); + map.set('resetFileKey', Math.floor((Math.random() * 0x10000))); map.update('text', oldText => `${oldText} ${media.get('text_url')}`.trim()); }); }; @@ -80,7 +88,7 @@ function removeMedia(state, mediaId) { const insertSuggestion = (state, position, token, completion) => { return state.withMutations(map => { - map.update('text', oldText => `${oldText.slice(0, position)}${completion}${oldText.slice(position + token.length)}`); + map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`); map.set('suggestion_token', null); map.update('suggestions', Immutable.List(), list => list.clear()); }); @@ -88,64 +96,68 @@ const insertSuggestion = (state, position, token, completion) => { export default function compose(state = initialState, action) { switch(action.type) { - case COMPOSE_MOUNT: - return state.set('mounted', true); - case COMPOSE_UNMOUNT: - return state.set('mounted', false); - case COMPOSE_SENSITIVITY_CHANGE: - return state.set('sensitive', action.checked); - case COMPOSE_VISIBILITY_CHANGE: - return state.set('private', action.checked); - case COMPOSE_LISTABILITY_CHANGE: - return state.set('unlisted', action.checked); - case COMPOSE_CHANGE: - return state.set('text', action.text); - case COMPOSE_REPLY: - return state.withMutations(map => { - map.set('in_reply_to', action.status.get('id')); - map.set('text', statusToTextMentions(state, action.status)); - }); - case COMPOSE_REPLY_CANCEL: - return state.withMutations(map => { - map.set('in_reply_to', null); - map.set('text', ''); - }); - case COMPOSE_SUBMIT_REQUEST: - return state.set('is_submitting', true); - case COMPOSE_SUBMIT_SUCCESS: - return clearAll(state); - case COMPOSE_SUBMIT_FAIL: - return state.set('is_submitting', false); - case COMPOSE_UPLOAD_REQUEST: - return state.withMutations(map => { - map.set('is_uploading', true); - map.set('fileDropDate', new Date()); - }); - case COMPOSE_UPLOAD_SUCCESS: - return appendMedia(state, Immutable.fromJS(action.media)); - case COMPOSE_UPLOAD_FAIL: - return state.set('is_uploading', false); - case COMPOSE_UPLOAD_UNDO: - return removeMedia(state, action.media_id); - case COMPOSE_UPLOAD_PROGRESS: - return state.set('progress', Math.round((action.loaded / action.total) * 100)); - case COMPOSE_MENTION: - return state.update('text', text => `${text}@${action.account.get('acct')} `); - case COMPOSE_SUGGESTIONS_CLEAR: - return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); - case COMPOSE_SUGGESTIONS_READY: - return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token); - case COMPOSE_SUGGESTION_SELECT: - return insertSuggestion(state, action.position, action.token, action.completion); - case TIMELINE_DELETE: - if (action.id === state.get('in_reply_to')) { - return state.set('in_reply_to', null); - } else { - return state; - } - case ACCOUNT_SET_SELF: - return state.set('me', action.account.id).set('private', action.account.locked); - default: + case STORE_HYDRATE: + return state.merge(action.state.get('compose')); + case COMPOSE_MOUNT: + return state.set('mounted', true); + case COMPOSE_UNMOUNT: + return state.set('mounted', false); + case COMPOSE_SENSITIVITY_CHANGE: + return state.set('sensitive', action.checked); + case COMPOSE_SPOILERNESS_CHANGE: + return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked); + case COMPOSE_SPOILER_TEXT_CHANGE: + return state.set('spoiler_text', action.text); + case COMPOSE_VISIBILITY_CHANGE: + return state.set('private', action.checked); + case COMPOSE_LISTABILITY_CHANGE: + return state.set('unlisted', action.checked); + case COMPOSE_CHANGE: + return state.set('text', action.text); + case COMPOSE_REPLY: + return state.withMutations(map => { + map.set('in_reply_to', action.status.get('id')); + map.set('text', statusToTextMentions(state, action.status)); + }); + case COMPOSE_REPLY_CANCEL: + return state.withMutations(map => { + map.set('in_reply_to', null); + map.set('text', ''); + }); + case COMPOSE_SUBMIT_REQUEST: + return state.set('is_submitting', true); + case COMPOSE_SUBMIT_SUCCESS: + return clearAll(state); + case COMPOSE_SUBMIT_FAIL: + return state.set('is_submitting', false); + case COMPOSE_UPLOAD_REQUEST: + return state.withMutations(map => { + map.set('is_uploading', true); + map.set('fileDropDate', new Date()); + }); + case COMPOSE_UPLOAD_SUCCESS: + return appendMedia(state, Immutable.fromJS(action.media)); + case COMPOSE_UPLOAD_FAIL: + return state.set('is_uploading', false); + case COMPOSE_UPLOAD_UNDO: + return removeMedia(state, action.media_id); + case COMPOSE_UPLOAD_PROGRESS: + return state.set('progress', Math.round((action.loaded / action.total) * 100)); + case COMPOSE_MENTION: + return state.update('text', text => `${text}@${action.account.get('acct')} `); + case COMPOSE_SUGGESTIONS_CLEAR: + return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); + case COMPOSE_SUGGESTIONS_READY: + return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token); + case COMPOSE_SUGGESTION_SELECT: + return insertSuggestion(state, action.position, action.token, action.completion); + case TIMELINE_DELETE: + if (action.id === state.get('in_reply_to')) { + return state.set('in_reply_to', null); + } else { return state; + } + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx index aea9239f8..0798116c4 100644 --- a/app/assets/javascripts/components/reducers/index.jsx +++ b/app/assets/javascripts/components/reducers/index.jsx @@ -11,6 +11,9 @@ import statuses from './statuses'; import relationships from './relationships'; import search from './search'; import notifications from './notifications'; +import settings from './settings'; +import status_lists from './status_lists'; +import cards from './cards'; export default combineReducers({ timelines, @@ -20,9 +23,12 @@ export default combineReducers({ loadingBar: loadingBarReducer, modal, user_lists, + status_lists, accounts, statuses, relationships, search, - notifications + notifications, + settings, + cards }); diff --git a/app/assets/javascripts/components/reducers/meta.jsx b/app/assets/javascripts/components/reducers/meta.jsx index c7222c60b..cd4b313d5 100644 --- a/app/assets/javascripts/components/reducers/meta.jsx +++ b/app/assets/javascripts/components/reducers/meta.jsx @@ -1,16 +1,16 @@ -import { ACCESS_TOKEN_SET } from '../actions/meta'; -import { ACCOUNT_SET_SELF } from '../actions/accounts'; +import { STORE_HYDRATE } from '../actions/store'; import Immutable from 'immutable'; -const initialState = Immutable.Map(); +const initialState = Immutable.Map({ + access_token: null, + me: null +}); export default function meta(state = initialState, action) { switch(action.type) { - case ACCESS_TOKEN_SET: - return state.set('access_token', action.token); - case ACCOUNT_SET_SELF: - return state.set('me', action.account.id); - default: - return state; + case STORE_HYDRATE: + return state.merge(action.state.get('meta')); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx index b529b6aa8..ac53ea210 100644 --- a/app/assets/javascripts/components/reducers/modal.jsx +++ b/app/assets/javascripts/components/reducers/modal.jsx @@ -8,14 +8,14 @@ const initialState = Immutable.Map({ export default function modal(state = initialState, action) { switch(action.type) { - case MEDIA_OPEN: - return state.withMutations(map => { - map.set('url', action.url); - map.set('open', true); - }); - case MODAL_CLOSE: - return state.set('open', false); - default: - return state; + case MEDIA_OPEN: + return state.withMutations(map => { + map.set('url', action.url); + map.set('open', true); + }); + case MODAL_CLOSE: + return state.set('open', false); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx index e0d1ccf83..482093c33 100644 --- a/app/assets/javascripts/components/reducers/notifications.jsx +++ b/app/assets/javascripts/components/reducers/notifications.jsx @@ -2,7 +2,10 @@ import { NOTIFICATIONS_UPDATE, NOTIFICATIONS_REFRESH_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS, - NOTIFICATIONS_SETTING_CHANGE + NOTIFICATIONS_REFRESH_REQUEST, + NOTIFICATIONS_EXPAND_REQUEST, + NOTIFICATIONS_REFRESH_FAIL, + NOTIFICATIONS_EXPAND_FAIL } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import Immutable from 'immutable'; @@ -11,22 +14,7 @@ const initialState = Immutable.Map({ items: Immutable.List(), next: null, loaded: false, - - settings: Immutable.Map({ - alerts: Immutable.Map({ - follow: true, - favourite: true, - reblog: true, - mention: true - }), - - shows: Immutable.Map({ - follow: true, - favourite: true, - reblog: true, - mention: true - }) - }) + isLoading: true }); const notificationToMap = notification => Immutable.Map({ @@ -48,7 +36,11 @@ const normalizeNotifications = (state, notifications, next) => { items = items.set(i, notificationToMap(n)); }); - return state.update('items', list => loaded ? list.unshift(...items) : list.push(...items)).set('next', next).set('loaded', true); + return state + .update('items', list => loaded ? list.unshift(...items) : list.push(...items)) + .set('next', next) + .set('loaded', true) + .set('isLoading', false); }; const appendNormalizedNotifications = (state, notifications, next) => { @@ -58,7 +50,10 @@ const appendNormalizedNotifications = (state, notifications, next) => { items = items.set(i, notificationToMap(n)); }); - return state.update('items', list => list.push(...items)).set('next', next); + return state + .update('items', list => list.push(...items)) + .set('next', next) + .set('isLoading', false); }; const filterNotifications = (state, relationship) => { @@ -67,17 +62,20 @@ const filterNotifications = (state, relationship) => { export default function notifications(state = initialState, action) { switch(action.type) { - case NOTIFICATIONS_UPDATE: - return normalizeNotification(state, action.notification); - case NOTIFICATIONS_REFRESH_SUCCESS: - return normalizeNotifications(state, action.notifications, action.next); - case NOTIFICATIONS_EXPAND_SUCCESS: - return appendNormalizedNotifications(state, action.notifications, action.next); - case ACCOUNT_BLOCK_SUCCESS: - return filterNotifications(state, action.relationship); - case NOTIFICATIONS_SETTING_CHANGE: - return state.setIn(['settings', ...action.key], action.checked); - default: - return state; + case NOTIFICATIONS_REFRESH_REQUEST: + case NOTIFICATIONS_EXPAND_REQUEST: + case NOTIFICATIONS_REFRESH_FAIL: + case NOTIFICATIONS_EXPAND_FAIL: + return state.set('isLoading', true); + case NOTIFICATIONS_UPDATE: + return normalizeNotification(state, action.notification); + case NOTIFICATIONS_REFRESH_SUCCESS: + return normalizeNotifications(state, action.notifications, action.next); + case NOTIFICATIONS_EXPAND_SUCCESS: + return appendNormalizedNotifications(state, action.notifications, action.next); + case ACCOUNT_BLOCK_SUCCESS: + return filterNotifications(state, action.relationship); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx index 9c2041863..d835ef268 100644 --- a/app/assets/javascripts/components/reducers/search.jsx +++ b/app/assets/javascripts/components/reducers/search.jsx @@ -23,7 +23,7 @@ const normalizeSuggestions = (state, value, accounts) => { } ]; - if (value.indexOf('@') === -1) { + if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) { newSuggestions.push({ title: 'hashtag', items: [ diff --git a/app/assets/javascripts/components/reducers/settings.jsx b/app/assets/javascripts/components/reducers/settings.jsx new file mode 100644 index 000000000..8acc3faca --- /dev/null +++ b/app/assets/javascripts/components/reducers/settings.jsx @@ -0,0 +1,46 @@ +import { SETTING_CHANGE } from '../actions/settings'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + home: Immutable.Map({ + shows: Immutable.Map({ + reblog: true, + reply: true + }) + }), + + notifications: Immutable.Map({ + alerts: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }), + + shows: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }), + + sounds: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }) + }) +}); + +export default function settings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.mergeDeep(action.state.get('settings')); + case SETTING_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/reducers/status_lists.jsx b/app/assets/javascripts/components/reducers/status_lists.jsx new file mode 100644 index 000000000..b883b1c58 --- /dev/null +++ b/app/assets/javascripts/components/reducers/status_lists.jsx @@ -0,0 +1,39 @@ +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + favourites: Immutable.Map({ + next: null, + loaded: false, + items: Immutable.List() + }) +}); + +const normalizeList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('items', Immutable.List(statuses.map(item => item.id))); + })); +}; + +const appendToList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('items', map.get('items').push(...statuses.map(item => item.id))); + })); +}; + +export default function statusLists(state = initialState, action) { + switch(action.type) { + case FAVOURITED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'favourites', action.statuses, action.next); + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'favourites', action.statuses, action.next); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx index c740b6d64..084b6304c 100644 --- a/app/assets/javascripts/components/reducers/statuses.jsx +++ b/app/assets/javascripts/components/reducers/statuses.jsx @@ -28,6 +28,10 @@ import { NOTIFICATIONS_REFRESH_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS } from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; import Immutable from 'immutable'; const normalizeStatus = (state, status) => { @@ -77,36 +81,38 @@ const initialState = Immutable.Map(); export default function statuses(state = initialState, action) { switch(action.type) { - case TIMELINE_UPDATE: - case STATUS_FETCH_SUCCESS: - case NOTIFICATIONS_UPDATE: - return normalizeStatus(state, action.status); - case REBLOG_SUCCESS: - case UNREBLOG_SUCCESS: - case FAVOURITE_SUCCESS: - case UNFAVOURITE_SUCCESS: - return normalizeStatus(state, action.response); - case FAVOURITE_REQUEST: - return state.setIn([action.status.get('id'), 'favourited'], true); - case FAVOURITE_FAIL: - return state.setIn([action.status.get('id'), 'favourited'], false); - case REBLOG_REQUEST: - return state.setIn([action.status.get('id'), 'reblogged'], true); - case REBLOG_FAIL: - return state.setIn([action.status.get('id'), 'reblogged'], false); - case TIMELINE_REFRESH_SUCCESS: - case TIMELINE_EXPAND_SUCCESS: - case ACCOUNT_TIMELINE_FETCH_SUCCESS: - case ACCOUNT_TIMELINE_EXPAND_SUCCESS: - case CONTEXT_FETCH_SUCCESS: - case NOTIFICATIONS_REFRESH_SUCCESS: - case NOTIFICATIONS_EXPAND_SUCCESS: - return normalizeStatuses(state, action.statuses); - case TIMELINE_DELETE: - return deleteStatus(state, action.id, action.references); - case ACCOUNT_BLOCK_SUCCESS: - return filterStatuses(state, action.relationship); - default: - return state; + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeStatus(state, action.status); + case REBLOG_SUCCESS: + case UNREBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeStatus(state, action.response); + case FAVOURITE_REQUEST: + return state.setIn([action.status.get('id'), 'favourited'], true); + case FAVOURITE_FAIL: + return state.setIn([action.status.get('id'), 'favourited'], false); + case REBLOG_REQUEST: + return state.setIn([action.status.get('id'), 'reblogged'], true); + case REBLOG_FAIL: + return state.setIn([action.status.get('id'), 'reblogged'], false); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return normalizeStatuses(state, action.statuses); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); + case ACCOUNT_BLOCK_SUCCESS: + return filterStatuses(state, action.relationship); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index b73c83e0f..6f2d26dcb 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -1,9 +1,12 @@ import { TIMELINE_REFRESH_REQUEST, TIMELINE_REFRESH_SUCCESS, + TIMELINE_REFRESH_FAIL, TIMELINE_UPDATE, TIMELINE_DELETE, TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_FAIL, TIMELINE_SCROLL_TOP } from '../actions/timelines'; import { @@ -13,37 +16,43 @@ import { UNFAVOURITE_SUCCESS } from '../actions/interactions'; import { - ACCOUNT_FETCH_SUCCESS, + ACCOUNT_TIMELINE_FETCH_REQUEST, ACCOUNT_TIMELINE_FETCH_SUCCESS, + ACCOUNT_TIMELINE_FETCH_FAIL, + ACCOUNT_TIMELINE_EXPAND_REQUEST, ACCOUNT_TIMELINE_EXPAND_SUCCESS, + ACCOUNT_TIMELINE_EXPAND_FAIL, ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import { - STATUS_FETCH_SUCCESS, CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; import Immutable from 'immutable'; const initialState = Immutable.Map({ home: Immutable.Map({ + isLoading: false, loaded: false, top: true, items: Immutable.List() }), mentions: Immutable.Map({ + isLoading: false, loaded: false, top: true, items: Immutable.List() }), public: Immutable.Map({ + isLoading: false, loaded: false, top: true, items: Immutable.List() }), tag: Immutable.Map({ + isLoading: false, id: null, loaded: false, top: true, @@ -82,6 +91,7 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => { }); state = state.setIn([timeline, 'loaded'], true); + state = state.setIn([timeline, 'isLoading'], false); return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids)); }; @@ -94,6 +104,8 @@ const appendNormalizedTimeline = (state, timeline, statuses) => { moreIds = moreIds.set(i, status.get('id')); }); + state = state.setIn([timeline, 'isLoading'], false); + return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds)); }; @@ -105,7 +117,10 @@ const normalizeAccountTimeline = (state, accountId, statuses, replace = false) = ids = ids.set(i, status.get('id')); }); - return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => (replace ? ids : list.unshift(...ids))); + return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map + .set('isLoading', false) + .set('loaded', true) + .update('items', Immutable.List(), list => (replace ? ids : list.unshift(...ids)))); }; const appendNormalizedAccountTimeline = (state, accountId, statuses) => { @@ -116,7 +131,9 @@ const appendNormalizedAccountTimeline = (state, accountId, statuses) => { moreIds = moreIds.set(i, status.get('id')); }); - return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds)); + return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map + .set('isLoading', false) + .update('items', list => list.push(...moreIds))); }; const updateTimeline = (state, timeline, status, references) => { @@ -145,14 +162,19 @@ const updateTimeline = (state, timeline, status, references) => { return state; }; -const deleteStatus = (state, id, accountId, references) => { +const deleteStatus = (state, id, accountId, references, reblogOf) => { + if (reblogOf) { + // If we are deleting a reblog, just replace reblog with its original + return state.updateIn(['home', 'items'], list => list.map(item => item === id ? reblogOf : item)); + } + // Remove references from timelines ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) { state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); }); // Remove references from account timelines - state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.filterNot(item => item === id)); + state = state.updateIn(['accounts_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id)); // Remove references from context state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => { @@ -202,8 +224,11 @@ const resetTimeline = (state, timeline, id) => { if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) { state = state.update(timeline, map => map .set('id', id) + .set('isLoading', true) .set('loaded', false) .update('items', list => list.clear())); + } else { + state = state.setIn([timeline, 'isLoading'], true); } return state; @@ -211,27 +236,37 @@ const resetTimeline = (state, timeline, id) => { export default function timelines(state = initialState, action) { switch(action.type) { - case TIMELINE_REFRESH_REQUEST: - return resetTimeline(state, action.timeline, action.id); - case TIMELINE_REFRESH_SUCCESS: - return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); - case TIMELINE_EXPAND_SUCCESS: - return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); - case TIMELINE_UPDATE: - return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references); - case TIMELINE_DELETE: - return deleteStatus(state, action.id, action.accountId, action.references); - case CONTEXT_FETCH_SUCCESS: - return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants)); - case ACCOUNT_TIMELINE_FETCH_SUCCESS: - return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace); - case ACCOUNT_TIMELINE_EXPAND_SUCCESS: - return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); - case ACCOUNT_BLOCK_SUCCESS: - return filterTimelines(state, action.relationship, action.statuses); - case TIMELINE_SCROLL_TOP: - return state.setIn([action.timeline, 'top'], action.top); - default: - return state; + case TIMELINE_REFRESH_REQUEST: + case TIMELINE_EXPAND_REQUEST: + return resetTimeline(state, action.timeline, action.id); + case TIMELINE_REFRESH_FAIL: + case TIMELINE_EXPAND_FAIL: + return state.setIn([action.timeline, 'isLoading'], false); + case TIMELINE_REFRESH_SUCCESS: + return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); + case TIMELINE_EXPAND_SUCCESS: + return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); + case TIMELINE_UPDATE: + return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); + case CONTEXT_FETCH_SUCCESS: + return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants)); + case ACCOUNT_TIMELINE_FETCH_REQUEST: + case ACCOUNT_TIMELINE_EXPAND_REQUEST: + return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true)); + case ACCOUNT_TIMELINE_FETCH_FAIL: + case ACCOUNT_TIMELINE_EXPAND_FAIL: + return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false)); + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace); + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); + case ACCOUNT_BLOCK_SUCCESS: + return filterTimelines(state, action.relationship, action.statuses); + case TIMELINE_SCROLL_TOP: + return state.setIn([action.timeline, 'top'], action.top); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx index 36093663f..72922f509 100644 --- a/app/assets/javascripts/components/reducers/user_lists.jsx +++ b/app/assets/javascripts/components/reducers/user_lists.jsx @@ -36,24 +36,24 @@ const appendToList = (state, type, id, accounts, next) => { export default function userLists(state = initialState, action) { switch(action.type) { - case FOLLOWERS_FETCH_SUCCESS: - return normalizeList(state, 'followers', action.id, action.accounts, action.next); - case FOLLOWERS_EXPAND_SUCCESS: - return appendToList(state, 'followers', action.id, action.accounts, action.next); - case FOLLOWING_FETCH_SUCCESS: - return normalizeList(state, 'following', action.id, action.accounts, action.next); - case FOLLOWING_EXPAND_SUCCESS: - return appendToList(state, 'following', action.id, action.accounts, action.next); - case REBLOGS_FETCH_SUCCESS: - return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id))); - case FAVOURITES_FETCH_SUCCESS: - return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id))); - case FOLLOW_REQUESTS_FETCH_SUCCESS: - return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); - case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: - case FOLLOW_REQUEST_REJECT_SUCCESS: - return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); - default: - return state; + case FOLLOWERS_FETCH_SUCCESS: + return normalizeList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWERS_EXPAND_SUCCESS: + return appendToList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWING_FETCH_SUCCESS: + return normalizeList(state, 'following', action.id, action.accounts, action.next); + case FOLLOWING_EXPAND_SUCCESS: + return appendToList(state, 'following', action.id, action.accounts, action.next); + case REBLOGS_FETCH_SUCCESS: + return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id))); + case FAVOURITES_FETCH_SUCCESS: + return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id))); + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: + case FOLLOW_REQUEST_REJECT_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); + default: + return state; } }; diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx index 3d03d4c19..ad0427b52 100644 --- a/app/assets/javascripts/components/store/configureStore.jsx +++ b/app/assets/javascripts/components/store/configureStore.jsx @@ -1,11 +1,23 @@ import { createStore, applyMiddleware, compose } from 'redux'; -import thunk from 'redux-thunk'; -import appReducer from '../reducers'; -import { loadingBarMiddleware } from 'react-redux-loading-bar'; -import errorsMiddleware from '../middleware/errors'; +import thunk from 'redux-thunk'; +import appReducer from '../reducers'; +import loadingBarMiddleware from '../middleware/loading_bar'; +import errorsMiddleware from '../middleware/errors'; +import soundsMiddleware from 'redux-sounds'; +import Howler from 'howler'; +import Immutable from 'immutable'; -export default function configureStore(initialState) { - return createStore(appReducer, initialState, compose(applyMiddleware(thunk, loadingBarMiddleware({ - promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'], - }), errorsMiddleware()), window.devToolsExtension ? window.devToolsExtension() : f => f)); +Howler.mobileAutoEnable = false; + +const soundsData = { + boop: '/sounds/boop.mp3' +}; + +export default function configureStore() { + return createStore(appReducer, compose(applyMiddleware( + thunk, + loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), + errorsMiddleware(), + soundsMiddleware(soundsData) + ), window.devToolsExtension ? window.devToolsExtension() : f => f)); }; diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx index b9f8e6842..5738863dd 100644 --- a/app/assets/javascripts/extras.jsx +++ b/app/assets/javascripts/extras.jsx @@ -1,7 +1,7 @@ import emojify from './components/emoji' $(() => { - $.each($('.entry .content, .entry .status__content, .status__display-name, .display-name, .name, .account__header__content'), (_, content) => { + $.each($('.emojify'), (_, content) => { const $content = $(content); $content.html(emojify($content.html())); }); @@ -19,8 +19,6 @@ $(() => { }); $('.webapp-btn').on('click', e => { - console.log(e); - if (e.button === 0) { e.preventDefault(); window.location.href = $(e.target).attr('href'); diff --git a/app/assets/stylesheets/about.scss b/app/assets/stylesheets/about.scss index 3681672d8..b7d903ddf 100644 --- a/app/assets/stylesheets/about.scss +++ b/app/assets/stylesheets/about.scss @@ -1,20 +1,21 @@ -@import url(https://fonts.googleapis.com/css?family=Montserrat); -@import url(https://fonts.googleapis.com/css?family=Judson); - .about-body { .wrapper { max-width: 600px; margin: 0 auto; - color: #9baec8; + color: $color3; padding-top: 50px; padding-bottom: 50px; + + &.thicc { + max-width: 700px; + } } h1 { - font: 46px/52px 'Roboto', sans-serif; + font: 46px/52px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-weight: 600; margin-bottom: 20px; - color: #2b90d9; + color: $color4; padding: 20px 0; img { @@ -26,17 +27,21 @@ } h2 { - font: 24px/28px 'Judson', sans-serif; - font-weight: 300; + font-family: 'Montserrat', sans-serif; + font-size: 24px; + line-height: 28px; + font-weight: 400; margin-bottom: 20px; - color: #fff; + color: $color5; } h3 { - font: 20px/28px 'Judson', sans-serif; - font-weight: 300; + font-family: 'Montserrat', sans-serif; + font-size: 20px; + line-height: 28px; + font-weight: 400; margin-bottom: 20px; - color: #d9e1e8; + color: $color2; } ul, ol { @@ -57,12 +62,12 @@ } p, li { - font: 20px/28px 'Judson', sans-serif; - font-weight: 300; + font: 16px/28px 'Montserrat', sans-serif; + font-weight: 400; margin-bottom: 26px; a { - color: #2b90d9; + color: $color4; text-decoration: underline; } } @@ -70,14 +75,15 @@ em { display: inline-block; padding: 7px 7px 5px 7px; - background: #9baec8; - color: #282c37; + margin: 0 2px; + background: $color3; + color: $color1; font: 16px/16px 'Montserrat', sans-serif; font-weight: 300; } .screenshot { - box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); + box-shadow: 0 0 15px rgba($color8, 0.4); margin-bottom: 26px; img { @@ -96,7 +102,7 @@ line-height: 36px; a { - color: #9baec8; + color: $color3; text-decoration: underline; } } @@ -108,3 +114,162 @@ } } } + +.information-board { + margin: 20px 0; + display: flex; + justify-content: space-between; + border-top: 1px solid lighten($color1, 10%); + border-bottom: 1px solid lighten($color1, 10%); + padding-right: 14px; + + .section { + flex: 1 0 0; + padding: 14px; + text-align: right; + font: 16px/28px 'Montserrat', sans-serif; + + span, strong { + display: block; + } + + span { + font-size: 16px; + + &:last-child { + color: $color2; + font-size: 14px; + } + } + + strong { + font-weight: 500; + font-size: 32px; + line-height: 48px; + color: $color5; + } + } +} + +.owner { + text-align: center; + + .avatar { + width: 80px; + height: 80px; + margin: 0 auto; + margin-bottom: 15px; + + img { + display: block; + width: 80px; + height: 80px; + border-radius: 48px; + } + } + + .name { + font-size: 14px; + + a { + display: block; + color: $color5; + text-decoration: none; + + &:hover { + .display_name { + text-decoration: underline; + } + } + } + + .username { + display: block; + color: $color3; + } + } +} + +.contact-email { + text-align: center; + margin: 40px 0; + + strong { + display: block; + color: $color5; + } +} + +.sidebar-layout { + display: flex; + + .main { + flex: 1 1 auto; + padding: 14px 0; + + .panel { + padding-right: 14px; + } + } + + .sidebar { + border-left: 1px solid lighten($color1, 10%); + width: 180px; + flex: 0 0 auto; + } + + .panel { + .panel-header { + background: lighten($color1, 10%); + padding: 7px 14px; + text-transform: uppercase; + font-size: 12px; + font-weight: 500; + } + + .panel-body { + padding: 14px; + } + + .panel-list { + ul { + list-style: none; + margin: 0; + + li { + margin: 0; + font-family: inherit; + font-size: 13px; + line-height: 18px; + + a { + display: block; + padding: 7px 14px; + color: rgba($color5, 0.7); + text-decoration: none; + transition: all 200ms linear; + + i.fa { + margin-right: 5px; + } + + &:hover { + color: $color5; + background-color: darken($color1, 5%); + transition: all 100ms linear; + } + + &.selected { + color: $color5; + background-color: $color4; + + &:hover { + background-color: lighten($color4, 5%); + } + } + } + } + } + } + } +} diff --git a/app/assets/stylesheets/accounts.scss b/app/assets/stylesheets/accounts.scss index 748bb8224..7c48c91f3 100644 --- a/app/assets/stylesheets/accounts.scss +++ b/app/assets/stylesheets/accounts.scss @@ -1,10 +1,10 @@ .card { - background: #282c37; + background: $color1; background-size: cover; padding: 60px 0; padding-bottom: 0; border-radius: 4px 4px 0 0; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 15px rgba($color8, 0.2); overflow: hidden; position: relative; @@ -14,7 +14,7 @@ } &:after { - background: rgba(0, 0, 0, 0.5); + background: rgba($color8, 0.5); display: block; content: ""; position: absolute; @@ -29,7 +29,7 @@ display: block; font-size: 20px; line-height: 18px * 1.5; - color: #fff; + color: $color5; font-weight: 500; text-align: center; position: relative; @@ -38,7 +38,7 @@ small { display: block; font-size: 14px; - color: #2b90d9; + color: $color4; font-weight: 400; } } @@ -81,10 +81,10 @@ .counter { width: 80px; - color: #9baec8; + color: $color3; padding: 0 10px; margin-bottom: 10px; - border-right: 1px solid #9baec8; + border-right: 1px solid $color3; cursor: default; position: relative; @@ -99,14 +99,14 @@ bottom: -10px; left: 0; width: 100%; - border-bottom: 4px solid #9baec8; + border-bottom: 4px solid $color3; opacity: 0.5; transition: all 0.8s ease; } &.active { &:after { - border-bottom: 4px solid #2b90d9; + border-bottom: 4px solid $color4; opacity: 1; } } @@ -133,7 +133,7 @@ .counter-number { font-weight: 500; font-size: 18px; - color: #fff; + color: $color5; } } @@ -142,7 +142,7 @@ font-size: 14px; line-height: 18px; padding: 5px 10px; - color: #d9e1e8; + color: $color2; order: 1; } @@ -173,7 +173,7 @@ a, .current, .next_page, .previous_page, .gap { font-size: 14px; - color: #fff; + color: $color5; font-weight: 500; display: inline-block; padding: 6px 10px; @@ -181,9 +181,9 @@ } .current { - background: #fff; + background: $color5; border-radius: 100px; - color: #282c37; + color: $color1; cursor: default; } @@ -193,7 +193,7 @@ .previous_page, .next_page { text-transform: uppercase; - color: #d9e1e8; + color: $color2; } .previous_page { @@ -218,7 +218,7 @@ .disabled { cursor: default; - color: lighten(#282c37, 10%); + color: lighten($color1, 10%); } @media screen and (max-width: 360px) { @@ -236,8 +236,8 @@ .accounts-grid { clear: both; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); - background: #fff; + box-shadow: 0 0 15px rgba($color8, 0.2); + background: $color5; border-radius: 0 0 4px 4px; padding: 20px 10px; padding-bottom: 10px; @@ -252,9 +252,9 @@ box-sizing: border-box; width: 335px; float: left; - border: 1px solid #d9e1e8; + border: 1px solid $color2; border-radius: 4px; - color: #282c37; + color: $color1; height: 160px; margin-bottom: 10px; @@ -265,7 +265,7 @@ .account-grid-card__header { overflow: hidden; padding: 10px; - border-bottom: 1px solid #d9e1e8; + border-bottom: 1px solid $color2; } .avatar { @@ -287,7 +287,7 @@ a { display: block; - color: #282c37; + color: $color1; text-decoration: none; &:hover { @@ -304,19 +304,19 @@ } .username { - color: #2b90d9; + color: $color4; } .note { padding: 10px; padding-top: 15px; - color: #9baec8; + color: $color3; } } } .nothing-here { - color: #9baec8; + color: $color3; font-size: 14px; font-weight: 500; text-align: center; @@ -327,10 +327,10 @@ .account-card { padding: 14px 10px; - background: #fff; + background: $color5; border-radius: 4px; text-align: left; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 15px rgba($color8, 0.2); .detailed-status__display-name { display: block; @@ -363,12 +363,12 @@ strong { font-weight: 500; - color: #282c37; + color: $color1; } span { font-size: 14px; - color: #9baec8; + color: $color3; } } @@ -383,6 +383,6 @@ .account__header__content { font-size: 14px; - color: #282c37; + color: $color1; } } diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss index 6e4234d13..8d01ac4c4 100644 --- a/app/assets/stylesheets/admin.scss +++ b/app/assets/stylesheets/admin.scss @@ -2,7 +2,7 @@ width: 100%; height: 100%; position: fixed; - background: #1a1c23; + background: darken($color1, 2%); overflow-y: scroll; .sidebar { @@ -10,7 +10,7 @@ position: fixed; left: 0; height: 100%; - background: #282c37; + background: $color1; .logo { display: block; @@ -25,7 +25,7 @@ a { display: block; padding: 15px 25px; - color: rgba(255, 255, 255, 0.7); + color: rgba($color5, 0.7); text-decoration: none; transition: all 200ms linear; @@ -34,17 +34,17 @@ } &:hover { - color: #fff; - background-color: darken(#282c37, 5%); + color: $color5; + background-color: darken($color1, 5%); transition: all 100ms linear; } &.selected { - color: #fff; - background-color: #2b90d9; + color: $color5; + background-color: $color4; &:hover { - background-color: lighten(#2b90d9, 5%); + background-color: lighten($color4, 5%); } } } @@ -84,21 +84,21 @@ a { display: inline-block; - color: rgba(255, 255, 255, 0.7); + color: rgba($color5, 0.7); text-decoration: none; text-transform: uppercase; font-size: 12px; font-weight: 500; - border-bottom: 2px solid #282c37; + border-bottom: 2px solid $color1; &:hover { - color: #fff; - border-bottom: 2px solid lighten(#282c37, 5%); + color: $color5; + border-bottom: 2px solid lighten($color1, 5%); } &.selected { - color: #2b90d9; - border-bottom: 2px solid #2b90d9; + color: $color4; + border-bottom: 2px solid $color4; } } } diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e4c550b81..649a0148b 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -1,6 +1,8 @@ +@import 'variables'; @import url(https://fonts.googleapis.com/css?family=Roboto:400,500,400italic); @import url(https://fonts.googleapis.com/css?family=Roboto+Mono:400,500); -@import "font-awesome"; +@import url(https://fonts.googleapis.com/css?family=Montserrat); +@import 'font-awesome'; /* http://meyerweb.com/eric/tools/css/reset/ v2.0 | 20110126 @@ -63,31 +65,31 @@ table { } ::-webkit-scrollbar-thumb { - background: #42495b; - border: 0px none #ffffff; + background: lighten($color1, 4%); + border: 0px none $color5; border-radius: 50px; } ::-webkit-scrollbar-thumb:hover { - background: #525a70; + background: lighten($color1, 6%); } ::-webkit-scrollbar-thumb:active { - background: #42495b; + background: lighten($color1, 4%); } ::-webkit-scrollbar-track { - border: 0px none #ffffff; + border: 0px none $color5; border-radius: 0; - background: rgba(0, 0, 0, 0.1); + background: rgba($color8, 0.1); } ::-webkit-scrollbar-track:hover { - background: #282c37; + background: $color1; } ::-webkit-scrollbar-track:active { - background: #282c37; + background: $color1; } ::-webkit-scrollbar-corner { @@ -96,13 +98,13 @@ table { body { font-family: 'Roboto', sans-serif; - background: #282c37 image-url('background-photo.jpeg'); + background: $color1 image-url('background-photo.jpeg'); background-size: cover; background-attachment: fixed; font-size: 13px; line-height: 18px; font-weight: 400; - color: #fff; + color: $color5; padding-bottom: 140px; text-rendering: optimizelegibility; font-feature-settings: "kern"; @@ -164,7 +166,7 @@ body { h1 { display: block; text-align: center; - color: #fff; + color: $color5; font-size: 48px; font-weight: 500; @@ -215,12 +217,10 @@ body { text-align: center; margin-top: 30px; font-size: 12px; - color: darken(#d9e1e8, 25%); + color: darken($color2, 25%); .domain { - //font-size: 12px; font-weight: 500; - //font-family: 'Roboto Mono', monospace; a { color: inherit; diff --git a/app/assets/stylesheets/boost.scss b/app/assets/stylesheets/boost.scss new file mode 100644 index 000000000..a2e6421f8 --- /dev/null +++ b/app/assets/stylesheets/boost.scss @@ -0,0 +1,7 @@ +@function url-friendly-colour($colour) { + @return '%23' + str-slice('#{$colour}', 2, -1) +} + +button i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{url-friendly-colour(lighten($color1, 26%))}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{url-friendly-colour($color4)}' stroke-width='0'/></svg>"); +} diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index acfa85c6b..6014da5b6 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -1,12 +1,12 @@ .button { - background-color: #2b90d9; - font-family: 'Roboto'; + background-color: darken($color4, 3%); + font-family: inherit; display: inline-block; position: relative; box-sizing: border-box; text-align: center; border: 10px none; - color: #fff; + color: $color5; font-size: 14px; font-weight: 500; letter-spacing: 0; @@ -19,56 +19,69 @@ text-decoration: none; &:hover { - background-color: #489fde; + background-color: lighten($color4, 7%); } &:disabled { - background-color: #9baec8; + background-color: $color3; cursor: default; } &.button-secondary { - background-color: #282c37; + background-color: $color1; &:hover { - background-color: #282c37; + background-color: $color1; } &:disabled { - background-color: #9baec8; + background-color: $color3; } } } .icon-button { - color: #616b86; + color: lighten($color1, 26%); border: none; background: transparent; cursor: pointer; &:hover { - color: #717b98; + color: lighten($color1, 33%); } &.disabled { - color: #454b5e; + color: lighten($color1, 13%); cursor: default; } &.active { - color: #2b90d9; + color: $color4; + } +} + +.invisible { + font-size: 0; + line-height: 0; + display: inline-block; + width: 0; +} + +.ellipsis { + &:after { + content: "…"; } } .lightbox .icon-button { - color: #282c37; + color: $color1; } .compose-form__textarea, .follow-form__input { - background: #fff; + background: $color5; &:disabled { - background: #d9e1e8; + background: $color2; } } @@ -107,7 +120,7 @@ } a { - color: #d9e1e8; + color: $color2; text-decoration: none; &:hover { @@ -139,11 +152,11 @@ } .reply-indicator__content { - color: #282c37; + color: $color1; font-size: 14px; a { - color: #535b72; + color: lighten($color1, 20%); } } @@ -183,13 +196,13 @@ } } -.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .account__display-name { +.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .detailed-status__application, .account__display-name { text-decoration: none; } .status__display-name, .account__display-name { strong { - color: #fff; + color: $color5; } &.muted { @@ -214,7 +227,7 @@ } .detailed-status__display-name { - color: #d9e1e8; + color: $color2; line-height: 24px; strong, span { @@ -223,17 +236,17 @@ strong { font-size: 16px; - color: #fff; + color: $color5; } } .muted { .status__content p, .status__content a { - color: #616b86; + color: lighten($color1, 26%); } .status__display-name strong { - color: #616b86; + color: lighten($color1, 26%); } .status__avatar { @@ -246,7 +259,7 @@ text-decoration: none; &:hover { - color: #fff; + color: $color5; text-decoration: underline; } } @@ -282,17 +295,17 @@ height: 0; border-style: solid; border-width: 0 4.5px 7.8px 4.5px; - border-color: transparent transparent #d9e1e8 transparent; + border-color: transparent transparent $color2 transparent; top: -7px; left: 8px; } ul { list-style: none; - background: #d9e1e8; + background: $color2; padding: 4px 0; border-radius: 4px; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); + box-shadow: 0 0 15px rgba($color8, 0.4); min-width: 100px; } @@ -302,12 +315,12 @@ padding: 6px 16px; width: 100px; text-decoration: none; - background: #d9e1e8; - color: #282c37; + background: $color2; + color: $color1; &:hover { - background: #2b90d9; - color: #d9e1e8; + background: $color4; + color: $color2; } } } @@ -315,7 +328,7 @@ .static-content { padding: 10px; padding-top: 20px; - color: #616b86; + color: lighten($color1, 26%); h1 { font-size: 16px; @@ -331,11 +344,15 @@ } .columns-area { - margin: 10px; - margin-left: 0; flex-direction: row; } +@media screen and (min-width: 360px) { + .columns-area { + margin: 10px; + } +} + .column { width: 330px; position: relative; @@ -345,12 +362,43 @@ width: 280px; } +.drawer__inner { + background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65)); +} + +.drawer__header { + flex: 0 0 auto; + font-size: 16px; + background: lighten($color1, 8%); + margin-bottom: 10px; + display: flex; + flex-direction: row; + + a { + transition: all 100ms ease-in; + + &:hover { + background: lighten($color1, 3%); + transition: all 200ms ease-out; + } + } +} + .column, .drawer { - margin-left: 10px; + margin-left: 5px; + margin-right: 5px; flex: 0 0 auto; overflow: hidden; } +.column:first-child, .drawer:first-child { + margin-left: 0; +} + +.column:last-child, .drawer:last-child { + margin-right: 0; +} + @media screen and (max-width: 1024px) { .column, .drawer { width: 100%; @@ -359,7 +407,6 @@ } .columns-area { - margin: 10px; flex-direction: column; } } @@ -368,6 +415,13 @@ display: flex; } +@media screen and (min-width: 360px) { + .tabs-bar { + margin: 10px; + margin-bottom: 0; + } +} + @media screen and (min-width: 1025px) { .tabs-bar { display: none; @@ -383,22 +437,22 @@ top: 100%; width: 100%; z-index: 99; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); + box-shadow: 0 0 15px rgba($color8, 0.4); } .react-autosuggest__section-title { - background: #9baec8; + background: $color3; padding: 4px 10px; font-weight: 500; cursor: default; - color: #282c37; + color: $color1; text-transform: uppercase; font-size: 11px; } .react-autosuggest__suggestions-list { - background: #d9e1e8; - color: #282c37; + background: $color2; + color: $color1; font-size: 14px; } @@ -408,8 +462,8 @@ } .react-autosuggest__suggestion--focused { - background: #2b90d9; - color: #fff; + background: $color4; + color: $color5; } .scrollable { @@ -417,6 +471,10 @@ overflow-x: hidden; flex: 1 1 auto; -webkit-overflow-scrolling: touch; + + &.optionally-scrollable { + overflow-y: auto; + } } .column-back-button { @@ -433,7 +491,7 @@ border: 0; padding: 0; user-select: none; - -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-tap-highlight-color: rgba($color8, 0); -webkit-tap-highlight-color: transparent; } @@ -459,20 +517,20 @@ height: 24px; padding: 0; border-radius: 30px; - background-color: #282c37; + background-color: $color1; transition: all 0.2s ease; } .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { - background-color: darken(#282c37, 10%); + background-color: darken($color1, 10%); } .react-toggle--checked .react-toggle-track { - background-color: #2b90d9; + background-color: $color4; } .react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { - background-color: lighten(#2b90d9, 10%); + background-color: lighten($color4, 10%); } .react-toggle-track-check { @@ -519,59 +577,62 @@ left: 1px; width: 22px; height: 22px; - border: 1px solid #282c37; + border: 1px solid $color1; border-radius: 50%; - background-color: #FAFAFA; + background-color: darken($color5, 2%); box-sizing: border-box; transition: all 0.25s ease; } .react-toggle--checked .react-toggle-thumb { left: 27px; - border-color: #2b90d9; + border-color: $color4; } .column-link { - background: #373b4a; + background: lighten($color1, 6%); &:hover { - background: lighten(#373b4a, 5%); + background: lighten($color1, 11%); } } -.autosuggest-textarea { +.autosuggest-textarea, .spoiler-input { position: relative; } -.autosuggest-textarea__textarea { +.autosuggest-textarea__textarea, .spoiler-input__input { display: block; box-sizing: border-box; width: 100%; - height: 100px; resize: none; - color: #282c37; + margin: 0; + color: $color1; padding: 7px; - font-family: 'Roboto'; + font-family: inherit; font-size: 14px; - margin: 0; resize: vertical; border: 3px dashed transparent; transition: border-color 0.3s ease; &.file-drop { - border-color: #aaa; + border-color: darken($color5, 33%); } } +.autosuggest-textarea__textarea { + height: 100px; +} + .autosuggest-textarea__suggestions { position: absolute; top: 100%; width: 100%; z-index: 99; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); - background: #d9e1e8; - color: #282c37; + box-shadow: 0 0 15px rgba($color8, 0.4); + background: $color2; + color: $color1; font-size: 14px; } @@ -580,21 +641,69 @@ cursor: pointer; &:hover { - background: darken(#d9e1e8, 10%); + background: darken($color2, 10%); } &.selected { - background: #2b90d9; - color: #fff; + background: $color4; + color: $color5; } } -.getting-started__illustration { - width: 330px; - height: 235px; - background: image-url('mastodon-getting-started.png') no-repeat 0 0; - position: absolute; - pointer-events: none; - bottom: 0; - left: 0; +.getting-started { + box-sizing: border-box; + overflow-y: auto; + padding-bottom: 235px; + background: image-url('mastodon-getting-started.png') no-repeat 0 100% local; + height: 100%; + + p { + color: $color2; + } +} + +.dropdown__content.dropdown__left { + transform: translateX(-108px); + + &::before { + right: 8px !important; + left: initial !important; + } +} + +.setting-text { + color: $color3; + background: transparent; + border: none; + border-bottom: 2px solid $color3; + + &:focus, &:active { + color: $color5; + border-bottom-color: $color4; + } +} + +@import 'boost'; + +button i.fa-retweet { + height: 19px; + width: 22px; + background-position: 0 0; + transition: background-position 0.9s steps(10); + transition-duration: 0s; + + &::before { + display: none !important; + } +} + +button.active i.fa-retweet { + transition-duration: 0.9s; + background-position: 0 100%; +} + +.status-card { + &:hover { + background: lighten($color1, 6%); + } } diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index e6d2e85a2..365396511 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -16,7 +16,7 @@ code { .hint { display: block; - color: rgba(255, 255, 255, 0.8); + color: rgba($color5, 0.8); font-size: 12px; } @@ -26,9 +26,9 @@ code { display: flex; label { - font-family: 'Roboto'; + font-family: inherit; font-size: 16px; - color: #fff; + color: $color5; width: 100px; display: block; flex: 0 0 auto; @@ -48,7 +48,7 @@ code { margin-bottom: 5px; label { - font-family: 'Roboto'; + font-family: inherit; font-size: 14px; color: white; display: block; @@ -75,42 +75,42 @@ code { background: transparent; box-sizing: border-box; border: 0; - border-bottom: 2px solid #9baec8; + border-bottom: 2px solid $color3; border-radius: 2px 2px 0 0; padding: 7px 4px; font-size: 16px; - color: #fff; + color: $color5; display: block; width: 100%; outline: 0; - font-family: 'Roboto'; + font-family: inherit; &:invalid { box-shadow: none; } &:focus:invalid { - border-bottom-color: #df405a; + border-bottom-color: $color6; } &:required:valid { - border-bottom-color: #79bd9a; + border-bottom-color: $color7; } &:active, &:focus { - border-bottom-color: #2b90d9; - background: rgba(0, 0, 0, 0.1); + border-bottom-color: $color4; + background: rgba($color8, 0.1); } } .input.field_with_errors { input[type=text], input[type=email], input[type=password] { - border-bottom-color: #df405a; + border-bottom-color: $color6; } .error { font-weight: 500; - color: #df405a; + color: $color6; } } @@ -123,8 +123,8 @@ code { width: 100%; border: 0; border-radius: 4px; - background: #2b90d9; - color: #fff; + background: $color4; + color: $color5; font-size: 18px; padding: 10px; text-transform: uppercase; @@ -134,36 +134,36 @@ code { margin-bottom: 10px; &:hover { - background-color: lighten(#2b90d9, 5%); + background-color: lighten($color4, 5%); } &:active, &:focus { position: relative; top: 1px; - background-color: darken(#2b90d9, 5%); + background-color: darken($color4, 5%); } &.negative { - background: #df405a; + background: $color6; &:hover { - background-color: lighten(#df405a, 5%); + background-color: lighten($color6, 5%); } &:active, &:focus { - background-color: darken(#df405a, 5%); + background-color: darken($color6, 5%); } } } } .flash-message { - background: #282c37; - color: #9baec8; + background: $color1; + color: $color3; border-radius: 4px; padding: 15px 10px; margin-bottom: 30px; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 5px rgba($color8, 0.2); text-align: center; strong { @@ -188,7 +188,7 @@ code { .oauth-prompt, .follow-prompt { margin-bottom: 30px; text-align: center; - color: #9baec8; + color: $color3; h2 { font-size: 16px; @@ -196,7 +196,7 @@ code { } strong { - color: #d9e1e8; + color: $color2; font-weight: 500; } } diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss index 7624bbdc8..2d3cb1436 100644 --- a/app/assets/stylesheets/stream_entries.scss +++ b/app/assets/stylesheets/stream_entries.scss @@ -1,12 +1,12 @@ .activity-stream { clear: both; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 15px rgba($color8, 0.2); .entry { - background: lighten(#d9e1e8, 8%); + background: lighten($color2, 8%); &, .detailed-status.light { - border-bottom: 1px solid #d9e1e8; + border-bottom: 1px solid $color2; } &:last-child { @@ -43,7 +43,7 @@ font-size: 14px; .status__relative-time { - color: #9baec8; + color: $color3; } } } @@ -52,7 +52,7 @@ display: block; max-width: 100%; padding-right: 25px; - color: #282c37; + color: $color1; } .status__avatar { @@ -82,20 +82,20 @@ strong { font-weight: 500; - color: #282c37; + color: $color1; } span { font-size: 14px; - color: #9baec8; + color: $color3; } } .status__content { - color: #282c37; + color: $color1; a { - color: #2b90d9; + color: $color4; } } @@ -111,7 +111,7 @@ .detailed-status.light { padding: 14px; - background: #fff; + background: $color5; cursor: default; .detailed-status__display-name { @@ -133,12 +133,12 @@ strong { font-weight: 500; - color: #282c37; + color: $color1; } span { font-size: 14px; - color: #9baec8; + color: $color3; } } } @@ -154,16 +154,16 @@ } .status__content { - color: #282c37; + color: $color1; a { - color: #2b90d9; + color: $color4; } } .detailed-status__meta { margin-top: 15px; - color: #9baec8; + color: $color3; font-size: 14px; line-height: 18px; @@ -248,12 +248,13 @@ transform: translate(-50%, -50%); padding: 5px; border-radius: 100px; - color: rgba(255, 255, 255, 0.8); + color: rgba($color5, 0.8); + z-index: 1; } } .media-spoiler { - background: #9baec8; + background: $color3; width: 100%; height: 100%; cursor: pointer; @@ -263,9 +264,10 @@ flex-direction: column; text-align: center; transition: all 100ms linear; + z-index: 2; &:hover { - background: darken(#9baec8, 5%); + background: darken($color3, 5%); } span { @@ -287,7 +289,7 @@ padding-left: (48px + 14px*2); padding-bottom: 0; margin-bottom: -4px; - color: #9baec8; + color: $color3; font-size: 14px; position: relative; @@ -297,7 +299,7 @@ } .status__display-name.muted strong { - color: #9baec8; + color: $color3; } } } diff --git a/app/assets/stylesheets/tables.scss b/app/assets/stylesheets/tables.scss index a37870786..ad8050580 100644 --- a/app/assets/stylesheets/tables.scss +++ b/app/assets/stylesheets/tables.scss @@ -9,13 +9,13 @@ padding: 8px; line-height: 18px; vertical-align: top; - border-top: 1px solid #282c37; + border-top: 1px solid $color1; text-align: left; } & > thead > tr > th { vertical-align: bottom; - border-bottom: 2px solid #282c37; + border-bottom: 2px solid $color1; border-top: 0; font-weight: 500; } @@ -25,17 +25,21 @@ } & > tbody > tr:nth-child(odd) > td, & > tbody > tr:nth-child(odd) > th { - background: lighten(#1a1c23, 2%); + background: $color1; } a { - color: #2b90d9; + color: $color4; text-decoration: underline; &:hover { text-decoration: none; } } + + strong { + font-weight: 500; + } } samp { @@ -47,11 +51,11 @@ a.table-action-link { display: inline-block; margin-right: 5px; padding: 0 10px; - color: rgba(255, 255, 255, 0.7); + color: rgba($color5, 0.7); font-weight: 500; &:hover { - color: #fff; + color: $color5; } i.fa { diff --git a/app/assets/stylesheets/variables.scss b/app/assets/stylesheets/variables.scss new file mode 100644 index 000000000..de4157af8 --- /dev/null +++ b/app/assets/stylesheets/variables.scss @@ -0,0 +1,8 @@ +$color1: #282c37; // darkest +$color2: #d9e1e8; // lightest +$color3: #9baec8; // lighter +$color4: #2b90d9; // vibrant +$color5: #fff; // white +$color6: #df405a; // error red +$color7: #79bd9a; // succ green +$color8: #000; // black diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 7df58444f..491036db2 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -4,11 +4,21 @@ class AboutController < ApplicationController before_action :set_body_classes def index + @description = Setting.site_description end - def terms + def more + @description = Setting.site_description + @extended_description = Setting.site_extended_description + @contact_account = Account.find_local(Setting.site_contact_username) + @contact_email = Setting.site_contact_email + @user_count = Rails.cache.fetch('user_count') { User.count } + @status_count = Rails.cache.fetch('local_status_count') { Status.local.count } + @domain_count = Rails.cache.fetch('distinct_domain_count') { Account.distinct.count(:domain) } end + def terms; end + private def set_body_classes diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb new file mode 100644 index 000000000..af0be8823 --- /dev/null +++ b/app/controllers/admin/settings_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Admin::SettingsController < ApplicationController + before_action :require_admin! + + layout 'admin' + + def index + @settings = Setting.all_as_records + end + + def update + @setting = Setting.where(var: params[:id]).first_or_initialize(var: params[:id]) + + if @setting.value != params[:setting][:value] + @setting.value = params[:setting][:value] + @setting.save + end + + respond_to do |format| + format.html { redirect_to admin_settings_path } + format.json { respond_with_bip(@setting) } + end + end +end diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index 2360061ff..379e910e6 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::OembedController < ApiController +class Api::OEmbedController < ApiController respond_to :json def show diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 05ff806c5..d97010c0e 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -16,13 +16,13 @@ class Api::V1::AccountsController < ApiController end def following - results = Follow.where(account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) + results = Follow.where(account: @account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |f| accounts[f.target_account_id] } set_account_counters_maps(@accounts) - next_path = following_api_v1_account_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = following_api_v1_account_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) prev_path = following_api_v1_account_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) @@ -31,13 +31,13 @@ class Api::V1::AccountsController < ApiController end def followers - results = Follow.where(target_account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) + results = Follow.where(target_account: @account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |f| accounts[f.account_id] } set_account_counters_maps(@accounts) - next_path = followers_api_v1_account_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = followers_api_v1_account_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) prev_path = followers_api_v1_account_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) @@ -46,13 +46,13 @@ class Api::V1::AccountsController < ApiController end def statuses - @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses, Status) set_maps(@statuses) set_counters_maps(@statuses) - next_path = statuses_api_v1_account_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT + next_path = statuses_api_v1_account_url(max_id: @statuses.last.id) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) @@ -66,7 +66,12 @@ class Api::V1::AccountsController < ApiController def block BlockService.new.call(current_user.account, @account) - set_relationship + + @following = { @account.id => false } + @followed_by = { @account.id => false } + @blocking = { @account.id => true } + @requested = { @account.id => false } + render action: :relationship end @@ -93,10 +98,9 @@ class Api::V1::AccountsController < ApiController end def search - limit = params[:limit] ? [DEFAULT_ACCOUNTS_LIMIT, params[:limit].to_i].min : DEFAULT_ACCOUNTS_LIMIT - @accounts = SearchService.new.call(params[:q], limit, params[:resolve] == 'true') + @accounts = SearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true') - set_account_counters_maps(@accounts) + set_account_counters_maps(@accounts) unless @accounts.nil? render action: :index end diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index 1b33770f4..ca9dd0b7e 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -4,6 +4,6 @@ class Api::V1::AppsController < ApiController respond_to :json def create - @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes)) + @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website]) end end diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index 8629242ab..b9816e052 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -7,13 +7,13 @@ class Api::V1::BlocksController < ApiController respond_to :json def index - results = Block.where(account: current_account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) + results = Block.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |f| accounts[f.target_account_id] } set_account_counters_maps(@accounts) - next_path = api_v1_blocks_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = api_v1_blocks_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) prev_path = api_v1_blocks_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index a71592acd..ef0a4854a 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -7,13 +7,13 @@ class Api::V1::FavouritesController < ApiController respond_to :json def index - results = Favourite.where(account: current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + results = Favourite.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(Status.where(id: results.map(&:status_id)), Status) set_maps(@statuses) set_counters_maps(@statuses) - next_path = api_v1_favourites_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = api_v1_favourites_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = api_v1_favourites_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index c8f162cb0..877356a75 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -6,8 +6,10 @@ class Api::V1::NotificationsController < ApiController respond_to :json + DEFAULT_NOTIFICATIONS_LIMIT = 15 + def index - @notifications = Notification.where(account: current_account).browserable.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @notifications = Notification.where(account: current_account).browserable.paginate_by_max_id(limit_param(DEFAULT_NOTIFICATIONS_LIMIT), params[:max_id], params[:since_id]) @notifications = cache_collection(@notifications, Notification) statuses = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status) @@ -15,9 +17,18 @@ class Api::V1::NotificationsController < ApiController set_counters_maps(statuses) set_account_counters_maps(@notifications.map(&:from_account)) - next_path = api_v1_notifications_url(max_id: @notifications.last.id) if @notifications.size == 20 + next_path = api_v1_notifications_url(max_id: @notifications.last.id) if @notifications.size == limit_param(DEFAULT_NOTIFICATIONS_LIMIT) prev_path = api_v1_notifications_url(since_id: @notifications.first.id) unless @notifications.empty? set_pagination_headers(next_path, prev_path) end + + def show + @notification = Notification.where(account: current_account).find(params[:id]) + end + + def clear + Notification.where(account: current_account).delete_all + render_empty + end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index f7b4ed610..4b095a570 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -3,8 +3,8 @@ class Api::V1::StatusesController < ApiController before_action -> { doorkeeper_authorize! :read }, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite] before_action -> { doorkeeper_authorize! :write }, only: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite] - before_action :require_user!, except: [:show, :context, :reblogged_by, :favourited_by] - before_action :set_status, only: [:show, :context, :reblogged_by, :favourited_by] + before_action :require_user!, except: [:show, :context, :card, :reblogged_by, :favourited_by] + before_action :set_status, only: [:show, :context, :card, :reblogged_by, :favourited_by] respond_to :json @@ -14,21 +14,26 @@ class Api::V1::StatusesController < ApiController end def context - @context = OpenStruct.new(ancestors: @status.ancestors(current_account), descendants: @status.descendants(current_account)) + @context = OpenStruct.new(ancestors: @status.in_reply_to_id.nil? ? [] : @status.ancestors(current_account), descendants: @status.descendants(current_account)) statuses = [@status] + @context[:ancestors] + @context[:descendants] set_maps(statuses) set_counters_maps(statuses) end + def card + @card = PreviewCard.find_by(status: @status) + render_empty if @card.nil? + end + def reblogged_by - results = @status.reblogs.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) + results = @status.reblogs.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |r| accounts[r.account_id] } set_account_counters_maps(@accounts) - next_path = reblogged_by_api_v1_status_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = reblogged_by_api_v1_status_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) prev_path = reblogged_by_api_v1_status_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) @@ -37,13 +42,13 @@ class Api::V1::StatusesController < ApiController end def favourited_by - results = @status.favourites.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) + results = @status.favourites.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |f| accounts[f.account_id] } set_account_counters_maps(@accounts) - next_path = favourited_by_api_v1_status_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = favourited_by_api_v1_status_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) prev_path = favourited_by_api_v1_status_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) @@ -52,7 +57,12 @@ class Api::V1::StatusesController < ApiController end def create - @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], sensitive: params[:sensitive], visibility: params[:visibility]) + @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], + sensitive: params[:sensitive], + spoiler_text: params[:spoiler_text], + visibility: params[:visibility], + application: doorkeeper_token.application) + render action: :show end diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb index 9727797e5..5042550db 100644 --- a/app/controllers/api/v1/timelines_controller.rb +++ b/app/controllers/api/v1/timelines_controller.rb @@ -7,14 +7,14 @@ class Api::V1::TimelinesController < ApiController respond_to :json def home - @statuses = Feed.new(:home, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + @statuses = Feed.new(:home, current_account).get(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) - next_path = api_v1_home_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT + next_path = api_v1_home_timeline_url(max_id: @statuses.last.id) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) @@ -23,14 +23,14 @@ class Api::V1::TimelinesController < ApiController end def mentions - @statuses = Feed.new(:mentions, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + @statuses = Feed.new(:mentions, current_account).get(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) - next_path = api_v1_mentions_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT + next_path = api_v1_mentions_timeline_url(max_id: @statuses.last.id) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = api_v1_mentions_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) @@ -39,14 +39,14 @@ class Api::V1::TimelinesController < ApiController end def public - @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) - next_path = api_v1_public_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT + next_path = api_v1_public_timeline_url(max_id: @statuses.last.id) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) @@ -56,14 +56,14 @@ class Api::V1::TimelinesController < ApiController def tag @tag = Tag.find_by(name: params[:id].downcase) - @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) - next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT + next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) diff --git a/app/controllers/api/web/settings_controller.rb b/app/controllers/api/web/settings_controller.rb new file mode 100644 index 000000000..c00e016a4 --- /dev/null +++ b/app/controllers/api/web/settings_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Api::Web::SettingsController < ApiController + respond_to :json + + before_action :require_user! + + def update + setting = ::Web::Setting.where(user: current_user).first_or_initialize(user: current_user) + setting.data = params[:data] + setting.save! + + render_empty + end +end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 8f1c8ac8a..5d2bd9a22 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -62,6 +62,11 @@ class ApiController < ApplicationController response.headers['Link'] = LinkHeader.new(links) end + def limit_param(default_limit) + return default_limit unless params[:limit] + [params[:limit].to_i.abs, default_limit * 2].min + end + def current_resource_owner @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token end @@ -89,19 +94,19 @@ class ApiController < ApplicationController return end - status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact.uniq + status_ids = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq @reblogs_map = Status.reblogs_map(status_ids, current_account) @favourites_map = Status.favourites_map(status_ids, current_account) end def set_counters_maps(statuses) # rubocop:disable Style/AccessorMethodName - status_ids = statuses.map { |s| s.reblog? ? s.reblog_of_id : s.id }.uniq + status_ids = statuses.compact.map { |s| s.reblog? ? s.reblog_of_id : s.id }.uniq @favourites_counts_map = Favourite.select('status_id, COUNT(id) AS favourites_count').group('status_id').where(status_id: status_ids).map { |f| [f.status_id, f.favourites_count] }.to_h @reblogs_counts_map = Status.select('statuses.id, COUNT(reblogs.id) AS reblogs_count').joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.id = reblogs.reblog_of_id').where(id: status_ids).group('statuses.id').map { |r| [r.id, r.reblogs_count] }.to_h end def set_account_counters_maps(accounts) # rubocop:disable Style/AccessorMethodName - account_ids = accounts.map(&:id) + account_ids = accounts.compact.map(&:id).uniq @followers_counts_map = Follow.unscoped.select('target_account_id, COUNT(account_id) AS followers_count').group('target_account_id').where(target_account_id: account_ids).map { |f| [f.target_account_id, f.followers_count] }.to_h @following_counts_map = Follow.unscoped.select('account_id, COUNT(target_account_id) AS following_count').group('account_id').where(account_id: account_ids).map { |f| [f.account_id, f.following_count] }.to_h @statuses_counts_map = Status.unscoped.select('account_id, COUNT(id) AS statuses_count').group('account_id').where(account_id: account_ids).map { |s| [s.account_id, s.statuses_count] }.to_h diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0a6b50a29..e4b6d0faf 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base rescue_from ActionController::RoutingError, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found + rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :set_locale @@ -50,12 +51,21 @@ class ApplicationController < ActionController::Base def not_found respond_to do |format| format.any { head 404 } + format.html { render 'errors/404', layout: 'error' } end end def gone respond_to do |format| format.any { head 410 } + format.html { render 'errors/410', layout: 'error' } + end + end + + def unprocessable_entity + respond_to do |format| + format.any { head 422 } + format.html { render 'errors/422', layout: 'error' } end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 60eb9905a..6ce4984bb 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -23,6 +23,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController new_user_session_path end + def after_inactive_sign_up_path_for(_resource) + new_user_session_path + end + def check_single_user_mode redirect_to root_path if Rails.configuration.x.single_user_mode end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index a25fe77da..814b1f758 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -6,6 +6,7 @@ class HomeController < ApplicationController def index @body_classes = 'app-body' @token = find_or_create_access_token.token + @web_settings = Web::Setting.find_by(user: current_user)&.data || {} end private diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 6f1f7ec48..488c4f944 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -10,6 +10,7 @@ class MediaController < ApplicationController private def set_media_attachment - @media_attachment = MediaAttachment.where.not(status_id: nil).find(params[:id]) + @media_attachment = MediaAttachment.where.not(status_id: nil).find_by!(shortcode: params[:id]) + raise ActiveRecord::RecordNotFound unless @media_attachment.status.permitted?(current_account) end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 3b6d109a6..f273b5f21 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -8,14 +8,18 @@ class Settings::PreferencesController < ApplicationController def show; end def update - current_user.settings(:notification_emails).follow = user_params[:notification_emails][:follow] == '1' - current_user.settings(:notification_emails).follow_request = user_params[:notification_emails][:follow_request] == '1' - current_user.settings(:notification_emails).reblog = user_params[:notification_emails][:reblog] == '1' - current_user.settings(:notification_emails).favourite = user_params[:notification_emails][:favourite] == '1' - current_user.settings(:notification_emails).mention = user_params[:notification_emails][:mention] == '1' - - current_user.settings(:interactions).must_be_follower = user_params[:interactions][:must_be_follower] == '1' - current_user.settings(:interactions).must_be_following = user_params[:interactions][:must_be_following] == '1' + current_user.settings['notification_emails'] = { + follow: user_params[:notification_emails][:follow] == '1', + follow_request: user_params[:notification_emails][:follow_request] == '1', + reblog: user_params[:notification_emails][:reblog] == '1', + favourite: user_params[:notification_emails][:favourite] == '1', + mention: user_params[:notification_emails][:mention] == '1', + } + + current_user.settings['interactions'] = { + must_be_follower: user_params[:interactions][:must_be_follower] == '1', + must_be_following: user_params[:interactions][:must_be_following] == '1', + } if current_user.update(user_params.except(:notification_emails, :interactions)) redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg') diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index 3f60bb0c4..5701b2efa 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -46,7 +46,7 @@ class StreamEntriesController < ApplicationController @stream_entry = @account.stream_entries.find(params[:id]) @type = @stream_entry.activity_type.downcase - raise ActiveRecord::RecordNotFound if @stream_entry.hidden? && (@stream_entry.activity_type != 'Status' || (@stream_entry.activity_type == 'Status' && !@stream_entry.activity.permitted?(current_account))) + raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? || (@stream_entry.hidden? && (@stream_entry.activity_type != 'Status' || (@stream_entry.activity_type == 'Status' && !@stream_entry.activity.permitted?(current_account)))) end def check_account_suspension diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb index 036a72166..c08d80ea0 100644 --- a/app/helpers/atom_builder_helper.rb +++ b/app/helpers/atom_builder_helper.rb @@ -41,7 +41,8 @@ module AtomBuilderHelper xml['activity'].send('verb', TagManager::VERBS[verb]) end - def content(xml, content) + def content(xml, content, warning = nil) + xml.summary(warning) unless warning.blank? xml.content({ type: 'html' }, content) unless content.blank? end @@ -153,12 +154,20 @@ module AtomBuilderHelper portable_contact xml, account end + def rich_content(xml, activity) + if activity.is_a?(Status) + content xml, conditionally_formatted(activity), activity.spoiler_text + else + content xml, conditionally_formatted(activity) + end + end + def include_entry(xml, stream_entry) unique_id xml, stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type published_at xml, stream_entry.created_at updated_at xml, stream_entry.updated_at title xml, stream_entry.title - content xml, conditionally_formatted(stream_entry.activity) + rich_content xml, stream_entry.activity verb xml, stream_entry.verb link_self xml, account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom') link_alternate xml, account_stream_entry_url(stream_entry.account, stream_entry) diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index 6f87c7b72..d3c6b13a6 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -3,8 +3,6 @@ module HomeHelper def default_props { - token: @token, - account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json), locale: I18n.locale, } end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index fa569e73a..aed8770c8 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -14,4 +14,8 @@ module SettingsHelper def human_locale(locale) HUMAN_LOCALES[locale] end + + def hash_to_object(hash) + HashObject.new(hash) + end end diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index ae2f575b5..15601a079 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -15,10 +15,10 @@ module StreamEntriesHelper def entry_classes(status, is_predecessor, is_successor, include_threads) classes = ['entry'] - classes << 'entry-reblog' if status.reblog? - classes << 'entry-predecessor' if is_predecessor - classes << 'entry-successor' if is_successor - classes << 'entry-center' if include_threads + classes << 'entry-reblog u-repost-of h-cite' if status.reblog? + classes << 'entry-predecessor u-in-reply-to h-cite' if is_predecessor + classes << 'entry-successor u-comment h-cite' if is_successor + classes << 'entry-center h-entry' if include_threads classes.join(' ') end diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb new file mode 100644 index 000000000..93c0f42f0 --- /dev/null +++ b/app/lib/application_extension.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ApplicationExtension + extend ActiveSupport::Concern + + included do + validates :website, url: true, unless: 'website.blank?' + end +end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 0056321fa..cdd26e69c 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -43,12 +43,22 @@ class FeedManager timeline_key = key(:home, into_account.id) from_account.statuses.limit(MAX_ITEMS).each do |status| + next if filter?(:home, status, into_account) redis.zadd(timeline_key, status.id, status.id) end trim(:home, into_account.id) end + def unmerge_from_timeline(from_account, into_account) + timeline_key = key(:home, into_account.id) + + from_account.statuses.select('id').find_each do |status| + redis.zrem(timeline_key, status.id) + redis.zremrangebyscore(timeline_key, status.id, status.id) + end + end + def inline_render(target_account, template, object) rabl_scope = Class.new do include RoutingHelper diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 04386d295..ff2a16f1b 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -14,7 +14,7 @@ class Formatter html = status.text html = encode(html) - html = simple_format(html, sanitize: false) + html = simple_format(html, {}, sanitize: false) html = html.gsub(/\n/, '') html = link_urls(html) html = link_mentions(html, status.mentions) @@ -32,6 +32,7 @@ class Formatter html = encode(account.note) html = link_urls(html) + html = link_hashtags(html) html.html_safe # rubocop:disable Rails/OutputSafety end @@ -43,8 +44,8 @@ class Formatter end def link_urls(html) - auto_link(html, link: :urls, html: { rel: 'nofollow noopener', target: '_blank' }) do |text| - truncate(text.gsub(/\Ahttps?:\/\/(www\.)?/, ''), length: 30) + html.gsub(URI.regexp(%w(http https))) do |match| + link_html(match) end end @@ -63,6 +64,14 @@ class Formatter end end + def link_html(url) + prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s + text = url[prefix.length, 30] + suffix = url[prefix.length + 30..-1] + + "<a rel=\"nofollow noopener\" target=\"_blank\" href=\"#{url}\"><span class=\"invisible\">#{prefix}</span><span class=\"ellipsis\">#{text}</span><span class=\"invisible\">#{suffix}</span></a>" + end + def hashtag_html(match) prefix, affix = match.split('#') "#{prefix}<a href=\"#{tag_url(affix.downcase)}\" class=\"mention hashtag\">#<span>#{affix}</span></a>" diff --git a/app/lib/hash_object.rb b/app/lib/hash_object.rb new file mode 100644 index 000000000..274c020ad --- /dev/null +++ b/app/lib/hash_object.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class HashObject + def initialize(hash) + hash.each do |k, v| + instance_variable_set("@#{k}", v) + self.class.send(:define_method, k, proc { instance_variable_get("@#{k}") }) + end + end +end diff --git a/app/lib/settings/extend.rb b/app/lib/settings/extend.rb new file mode 100644 index 000000000..407c3480f --- /dev/null +++ b/app/lib/settings/extend.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Settings + module Extend + extend ActiveSupport::Concern + + def settings + ScopedSettings.for_thing(self) + end + end +end diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb new file mode 100644 index 000000000..82b70d128 --- /dev/null +++ b/app/lib/settings/scoped_settings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Settings + class ScopedSettings < ::Setting + def self.for_thing(object) + @object = object + self + end + + def self.thing_scoped + unscoped.where(thing_type: @object.class.base_class.to_s, thing_id: @object.id) + end + end +end diff --git a/app/lib/status_length_validator.rb b/app/lib/status_length_validator.rb new file mode 100644 index 000000000..55135a598 --- /dev/null +++ b/app/lib/status_length_validator.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class StatusLengthValidator < ActiveModel::Validator + MAX_CHARS = 500 + + def validate(status) + return unless status.local? && !status.reblog? + status.errors.add(:text, I18n.t('statuses.over_character_limit', max: MAX_CHARS)) if [status.text, status.spoiler_text].join.length > MAX_CHARS + end +end diff --git a/app/lib/url_validator.rb b/app/lib/url_validator.rb new file mode 100644 index 000000000..4a5c4ef3f --- /dev/null +++ b/app/lib/url_validator.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class UrlValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, I18n.t('applications.invalid_url')) unless compliant?(value) + end + + private + + def compliant?(url) + parsed_url = Addressable::URI.parse(url) + !parsed_url.nil? && %w(http https).include?(parsed_url.scheme) && parsed_url.host + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 5c1f6e7c1..c2a41c4c6 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -62,8 +62,8 @@ class Account < ApplicationRecord scope :expiring, ->(time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers } scope :silenced, -> { where(silenced: true) } scope :suspended, -> { where(suspended: true) } - scope :recent, -> { reorder('id desc') } - scope :alphabetic, -> { order('domain ASC, username ASC') } + scope :recent, -> { reorder(id: :desc) } + scope :alphabetic, -> { order(domain: :asc, username: :asc) } def follow!(other_account) active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account) @@ -104,7 +104,7 @@ class Account < ApplicationRecord end def subscribed? - !subscription_expires_at.nil? + !subscription_expires_at.blank? end def favourited?(status) @@ -125,13 +125,10 @@ class Account < ApplicationRecord def save_with_optional_avatar! save! - rescue ActiveRecord::RecordInvalid => invalid - if invalid.record.errors[:avatar_file_size] || invalid[:avatar_content_type] - self.avatar = nil - retry - end - - raise invalid + rescue ActiveRecord::RecordInvalid + self.avatar = nil + self[:avatar_remote_url] = '' + save! end def avatar_remote_url=(url) @@ -159,6 +156,7 @@ class Account < ApplicationRecord end def find_remote!(username, domain) + return if username.blank? where(arel_table[:username].matches(username.gsub(/[%_]/, '\\\\\0'))).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain.gsub(/[%_]/, '\\\\\0'))).take! end @@ -175,19 +173,25 @@ class Account < ApplicationRecord end def following_map(target_account_ids, account_id) - Follow.where(target_account_id: target_account_ids).where(account_id: account_id).map { |f| [f.target_account_id, true] }.to_h + follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) end def followed_by_map(target_account_ids, account_id) - Follow.where(account_id: target_account_ids).where(target_account_id: account_id).map { |f| [f.account_id, true] }.to_h + follow_mapping(Follow.where(account_id: target_account_ids, target_account_id: account_id), :account_id) end def blocking_map(target_account_ids, account_id) - Block.where(target_account_id: target_account_ids).where(account_id: account_id).map { |b| [b.target_account_id, true] }.to_h + follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) end def requested_map(target_account_ids, account_id) - FollowRequest.where(target_account_id: target_account_ids).where(account_id: account_id).map { |r| [r.target_account_id, true] }.to_h + follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + end + + private + + def follow_mapping(query, field) + query.pluck(field).inject({}) { |mapping, id| mapping[id] = true; mapping } end end diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index 9075b90a0..b4606da60 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class DomainBlock < ApplicationRecord + enum severity: [:silence, :suspend] + validates :domain, presence: true, uniqueness: true def self.blocked?(domain) diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index 8eef3abf4..936ad0691 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -13,7 +13,7 @@ class FollowRequest < ApplicationRecord def authorize! account.follow!(target_account) - FeedManager.instance.merge_into_timeline(target_account, account) + MergeWorker.perform_async(target_account.id, account.id) destroy! end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 2a5d23739..ecbed03e3 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -16,6 +16,7 @@ class MediaAttachment < ApplicationRecord validates :account, presence: true + scope :local, -> { where(remote_url: '') } default_scope { order('id asc') } def local? @@ -38,6 +39,12 @@ class MediaAttachment < ApplicationRecord image? ? 'image' : 'video' end + def to_param + shortcode + end + + before_create :set_shortcode + class << self private @@ -62,4 +69,15 @@ class MediaAttachment < ApplicationRecord end end end + + private + + def set_shortcode + return unless local? + + loop do + self.shortcode = SecureRandom.urlsafe_base64(14) + break if MediaAttachment.find_by(shortcode: shortcode).nil? + end + end end diff --git a/app/models/notification.rb b/app/models/notification.rb index c0b5c45a8..b7e8c9e71 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -39,9 +39,9 @@ class Notification < ApplicationRecord def target_status case type when :reblog - activity.reblog + activity&.reblog when :favourite, :mention - activity.status + activity&.status end end diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb new file mode 100644 index 000000000..e59b05eb8 --- /dev/null +++ b/app/models/preview_card.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class PreviewCard < ApplicationRecord + IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze + + belongs_to :status + + has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' } + + validates :url, presence: true + validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES + validates_attachment_size :image, less_than: 1.megabytes + + def save_with_optional_image! + save! + rescue ActiveRecord::RecordInvalid + self.image = nil + save! + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb new file mode 100644 index 000000000..3796253d4 --- /dev/null +++ b/app/models/setting.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Setting < RailsSettings::Base + source Rails.root.join('config/settings.yml') + namespace Rails.env + + def to_param + var + end + + class << self + def [](key) + return super(key) unless rails_initialized? + + val = Rails.cache.fetch(cache_key(key, @object)) do + db_val = object(key) + + if db_val + default_value = default_settings[key] + + return default_value.with_indifferent_access.merge!(db_val.value) if default_value.is_a?(Hash) + db_val.value + else + default_settings[key] + end + end + + val + end + + def all_as_records + vars = thing_scoped + records = vars.map { |r| [r.var, r] }.to_h + + default_settings.each do |key, default_value| + next if records.key?(key) || default_value.is_a?(Hash) + records[key] = Setting.new(var: key, value: default_value) + end + + records + end + + private + + def default_settings + return {} unless RailsSettings::Default.enabled? + RailsSettings::Default.instance + end + end +end diff --git a/app/models/status.rb b/app/models/status.rb index bc595c93b..651d0dbc9 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -1,12 +1,15 @@ # frozen_string_literal: true class Status < ApplicationRecord + include ActiveModel::Validations include Paginable include Streamable include Cacheable enum visibility: [:public, :unlisted, :private], _suffix: :visibility + belongs_to :application, class_name: 'Doorkeeper::Application' + belongs_to :account, inverse_of: :statuses belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account' @@ -21,11 +24,12 @@ class Status < ApplicationRecord has_and_belongs_to_many :tags has_one :notification, as: :activity, dependent: :destroy + has_one :preview_card, dependent: :destroy validates :account, presence: true validates :uri, uniqueness: true, unless: 'local?' - validates :text, presence: true, length: { maximum: 500 }, if: proc { |s| s.local? && !s.reblog? } - validates :text, presence: true, if: proc { |s| !s.local? && !s.reblog? } + validates :text, presence: true, unless: 'reblog?' + validates_with StatusLengthValidator validates :reblog, uniqueness: { scope: :account, message: 'of status already exists' }, if: 'reblog?' default_scope { order('id desc') } @@ -33,7 +37,7 @@ class Status < ApplicationRecord scope :remote, -> { where.not(uri: nil) } scope :local, -> { where(uri: nil) } - cache_associated :account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account + cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account def local? uri.nil? @@ -171,6 +175,7 @@ class Status < ApplicationRecord before_validation do text.strip! + spoiler_text&.strip! self.reblog = reblog.reblog if reblog? && reblog.reblog? self.in_reply_to_account_id = (thread.account_id == account_id && thread.reply? ? thread.in_reply_to_account_id : thread.account_id) if reply? diff --git a/app/models/user.rb b/app/models/user.rb index d5a52da06..71d3ee0b8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class User < ApplicationRecord + include Settings::Extend + devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable, :confirmable belongs_to :account, inverse_of: :user @@ -14,11 +16,6 @@ class User < ApplicationRecord scope :recent, -> { order('id desc') } scope :admins, -> { where(admin: true) } - has_settings do |s| - s.key :notification_emails, defaults: { follow: false, reblog: false, favourite: false, mention: false, follow_request: true } - s.key :interactions, defaults: { must_be_follower: false, must_be_following: false } - end - def send_devise_notification(notification, *args) devise_mailer.send(notification, self, *args).deliver_later end diff --git a/app/models/web.rb b/app/models/web.rb new file mode 100644 index 000000000..58654fd77 --- /dev/null +++ b/app/models/web.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Web + def self.table_name_prefix + 'web_' + end +end diff --git a/app/models/web/setting.rb b/app/models/web/setting.rb new file mode 100644 index 000000000..3d601189b --- /dev/null +++ b/app/models/web/setting.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Web::Setting < ApplicationRecord + belongs_to :user + + validates :user, uniqueness: true +end diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb new file mode 100644 index 000000000..8c6197f2c --- /dev/null +++ b/app/services/after_block_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class AfterBlockService < BaseService + def call(account, target_account) + clear_timelines(account, target_account) + clear_notifications(account, target_account) + end + + private + + def clear_timelines(account, target_account) + mentions_key = FeedManager.instance.key(:mentions, account.id) + home_key = FeedManager.instance.key(:home, account.id) + + target_account.statuses.select('id').find_each do |status| + redis.zrem(mentions_key, status.id) + redis.zrem(home_key, status.id) + end + end + + def clear_notifications(account, target_account) + Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).destroy_all + Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).destroy_all + Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).destroy_all + Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).destroy_all + end + + def redis + Redis.current + end +end diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index a8fafe412..9518b1fcf 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -1,15 +1,16 @@ # frozen_string_literal: true class BlockDomainService < BaseService - def call(domain) - DomainBlock.find_or_create_by!(domain: domain) + def call(domain, severity) + DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity) - Account.where(domain: domain).find_each do |account| - if account.subscribed? - account.subscription(api_subscription_url(account.id)).unsubscribe + if severity == :silence + Account.where(domain: domain).update_all(silenced: true) + else + Account.where(domain: domain).find_each do |account| + account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed? + SuspendAccountService.new.call(account) end - - account.destroy! end end end diff --git a/app/services/block_service.rb b/app/services/block_service.rb index b08cf8ca8..e04b6cc39 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -9,32 +9,7 @@ class BlockService < BaseService block = account.block!(target_account) - clear_timelines(account, target_account) - clear_notifications(account, target_account) - + BlockWorker.perform_async(account.id, target_account.id) NotificationWorker.perform_async(block.stream_entry.id, target_account.id) unless target_account.local? end - - private - - def clear_timelines(account, target_account) - mentions_key = FeedManager.instance.key(:mentions, account.id) - home_key = FeedManager.instance.key(:home, account.id) - - target_account.statuses.select('id').find_each do |status| - redis.zrem(mentions_key, status.id) - redis.zrem(home_key, status.id) - end - end - - def clear_notifications(account, target_account) - Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).destroy_all - Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).destroy_all - Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).destroy_all - Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).destroy_all - end - - def redis - Redis.current - end end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb new file mode 100644 index 000000000..005e5acea --- /dev/null +++ b/app/services/fetch_link_card_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class FetchLinkCardService < BaseService + def call(status) + # Get first URL + url = URI.extract(status.text).reject { |uri| (uri =~ /\Ahttps?:\/\//).nil? }.first + + return if url.nil? + + response = http_client.get(url) + + return if response.code != 200 || response.mime_type != 'text/html' + + page = Nokogiri::HTML(response.to_s) + card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url) + + card.title = meta_property(page, 'og:title') || page.at_xpath('//title')&.content + card.description = meta_property(page, 'og:description') || meta_property(page, 'description') + card.image = URI.parse(meta_property(page, 'og:image')) if meta_property(page, 'og:image') + + return if card.title.blank? + + card.save_with_optional_image! + end + + private + + def http_client + HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow + end + + def meta_property(html, property) + html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value + end +end diff --git a/app/services/follow_remote_account_service.rb b/app/services/follow_remote_account_service.rb index f640222b0..b39eafc70 100644 --- a/app/services/follow_remote_account_service.rb +++ b/app/services/follow_remote_account_service.rb @@ -14,7 +14,6 @@ class FollowRemoteAccountService < BaseService username, domain = uri.split('@') return Account.find_local(username) if TagManager.instance.local_domain?(domain) - return nil if DomainBlock.blocked?(domain) account = Account.find_remote(username, domain) return account unless account.nil? @@ -36,11 +35,15 @@ class FollowRemoteAccountService < BaseService Rails.logger.debug "Creating new remote account for #{uri}" + domain_block = DomainBlock.find_by(domain: domain) + account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href account.salmon_url = data.link('salmon').href account.url = data.link('http://webfinger.net/rel/profile-page').href account.public_key = magic_key_to_pem(data.link('magic-public-key').href) account.private_key = nil + account.suspended = true if domain_block && domain_block.suspend? + account.silenced = true if domain_block && domain_block.silence? xml = get_feed(account.remote_url) hubs = get_hubs(xml) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 555f01b6d..87c16a621 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -38,7 +38,7 @@ class FollowService < BaseService NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) end - FeedManager.instance.merge_into_timeline(target_account, source_account) + MergeWorker.perform_async(target_account.id, source_account.id) Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id) follow diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 2fb1d3919..1ec36637c 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -6,7 +6,7 @@ class NotifyService < BaseService @activity = activity @notification = Notification.new(account: @recipient, activity: @activity) - return if blocked? + return if blocked? || recipient.user.nil? create_notification send_email if email_enabled? @@ -37,13 +37,13 @@ class NotifyService < BaseService end def blocked? - blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway - blocked ||= @recipient.id == @notification.from_account.id # Skip for interactions with self - blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts - blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account)) # Hellban - blocked ||= (@recipient.user.settings(:interactions).must_be_follower && !@notification.from_account.following?(@recipient)) # Options - blocked ||= (@recipient.user.settings(:interactions).must_be_following && !@recipient.following?(@notification.from_account)) # Options - blocked ||= send("blocked_#{@notification.type}?") # Type-dependent filters + blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway + blocked ||= @recipient.id == @notification.from_account.id # Skip for interactions with self + blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts + blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account)) # Hellban + blocked ||= (@recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient)) # Options + blocked ||= (@recipient.user.settings.interactions['must_be_following'] && !@recipient.following?(@notification.from_account)) # Options + blocked ||= send("blocked_#{@notification.type}?") # Type-dependent filters blocked end @@ -58,6 +58,6 @@ class NotifyService < BaseService end def email_enabled? - @recipient.user.settings(:notification_emails).send(@notification.type) + @recipient.user.settings.notification_emails[@notification.type] end end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 55405c0db..979941c84 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -7,14 +7,24 @@ class PostStatusService < BaseService # @param [Status] in_reply_to Optional status to reply to # @param [Hash] options # @option [Boolean] :sensitive + # @option [String] :visibility + # @option [String] :spoiler_text # @option [Enumerable] :media_ids Optional array of media IDs to attach + # @option [Doorkeeper::Application] :application # @return [Status] def call(account, text, in_reply_to = nil, options = {}) - status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive], visibility: options[:visibility]) + status = account.statuses.create!(text: text, + thread: in_reply_to, + sensitive: options[:sensitive], + spoiler_text: options[:spoiler_text] || '', + visibility: options[:visibility], + application: options[:application]) + attach_media(status, options[:media_ids]) process_mentions_service.call(status) process_hashtags_service.call(status) + LinkCrawlWorker.perform_async(status.id) DistributionWorker.perform_async(status.id) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb index 3860a3504..626534176 100644 --- a/app/services/process_feed_service.rb +++ b/app/services/process_feed_service.rb @@ -44,6 +44,8 @@ class ProcessFeedService < BaseService Rails.logger.debug "Creating remote status #{id}" status = status_from_xml(@xml) + return if status.nil? + if verb == :share original_status = status_from_xml(@xml.at_xpath('.//activity:object', activity: TagManager::AS_XMLNS)) status.reblog = original_status @@ -59,6 +61,7 @@ class ProcessFeedService < BaseService status.save! NotifyService.new.call(status.reblog.account, status) if status.reblog? && status.reblog.account.local? + # LinkCrawlWorker.perform_async(status.reblog? ? status.reblog_of_id : status.id) Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution" DistributionWorker.perform_async(status.id) status @@ -100,6 +103,7 @@ class ProcessFeedService < BaseService url: url(entry), account: account, text: content(entry), + spoiler_text: content_warning(entry), created_at: published(entry) ) @@ -177,6 +181,8 @@ class ProcessFeedService < BaseService end def media_from_xml(parent, xml) + return if DomainBlock.find_by(domain: parent.account.domain)&.reject_media? + xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: TagManager::XMLNS).each do |link| next unless link['href'] @@ -218,6 +224,10 @@ class ProcessFeedService < BaseService xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content end + def content_warning(xml = @xml) + xml.at_xpath('./xmlns:summary', xmlns: TagManager::XMLNS)&.content || '' + end + def published(xml = @xml) xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content end diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb index ddcc64aa5..617a38159 100644 --- a/app/services/process_hashtags_service.rb +++ b/app/services/process_hashtags_service.rb @@ -4,7 +4,7 @@ class ProcessHashtagsService < BaseService def call(status, tags = []) tags = status.text.scan(Tag::HASHTAG_RE).map(&:first) if status.local? - tags.map { |str| str.mb_chars.downcase }.uniq.each do |tag| + tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |tag| status.tags << Tag.where(name: tag).first_or_initialize(name: tag) end diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb index 11ec0d2dd..5f91e3127 100644 --- a/app/services/process_interaction_service.rb +++ b/app/services/process_interaction_service.rb @@ -17,8 +17,6 @@ class ProcessInteractionService < BaseService domain = Addressable::URI.parse(url).host account = Account.find_by(username: username, domain: domain) - return if DomainBlock.blocked?(domain) - if account.nil? account = follow_remote_account_service.call("#{username}@#{domain}") end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index ee42a5df2..72568e702 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -28,7 +28,7 @@ class ProcessMentionsService < BaseService status.mentions.each do |mention| mentioned_account = mention.account - next if status.private_visibility? && !mentioned_account.following?(status.account) + next if status.private_visibility? && (!mentioned_account.following?(status.account) || !mentioned_account.local?) if mentioned_account.local? NotifyService.new.call(mentioned_account, mention) diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 0cb51eecd..4ea0dbf6c 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -6,7 +6,9 @@ class ReblogService < BaseService # @param [Status] reblogged_status Status to be reblogged # @return [Status] def call(account, reblogged_status) - raise Mastodon::NotPermitted if reblogged_status.private_visibility? + reblogged_status = reblogged_status.reblog if reblogged_status.reblog? + + raise Mastodon::NotPermitted if reblogged_status.private_visibility? || !reblogged_status.permitted?(account) reblog = account.statuses.create!(reblog: reblogged_status, text: '') diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 836b8fdc5..7aca24d12 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -53,7 +53,12 @@ class RemoveStatusService < BaseService end def unpush(type, receiver, status) - redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id) + if status.reblog? + redis.zadd(FeedManager.instance.key(type, receiver.id), status.reblog_of_id, status.reblog_of_id) + else + redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id) + end + FeedManager.instance.broadcast(receiver.id, type: 'delete', id: status.id) end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index 04a086613..8528ef62a 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -18,7 +18,6 @@ class SuspendAccountService < BaseService @account.media_attachments.destroy_all @account.stream_entries.destroy_all - @account.mentions.destroy_all @account.notifications.destroy_all @account.favourites.destroy_all @account.active_relationships.destroy_all diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 7973a3611..f469793c1 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -7,21 +7,6 @@ class UnfollowService < BaseService def call(source_account, target_account) follow = source_account.unfollow!(target_account) NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) unless target_account.local? - unmerge_from_timeline(target_account, source_account) - end - - private - - def unmerge_from_timeline(from_account, into_account) - timeline_key = FeedManager.instance.key(:home, into_account.id) - - from_account.statuses.select('id').find_each do |status| - redis.zrem(timeline_key, status.id) - redis.zremrangebyscore(timeline_key, status.id, status.id) - end - end - - def redis - Redis.current + UnmergeWorker.perform_async(target_account.id, source_account.id) end end diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb index d961eda39..ad9c56540 100644 --- a/app/services/update_remote_profile_service.rb +++ b/app/services/update_remote_profile_service.rb @@ -8,9 +8,12 @@ class UpdateRemoteProfileService < BaseService hub_link = xml.at_xpath('./xmlns:link[@rel="hub"]', xmlns: TagManager::XMLNS) unless author_xml.nil? - account.display_name = author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).nil? - account.note = author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).nil? - account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'].blank? + account.display_name = author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).nil? + account.note = author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).nil? + + unless account.suspended? || DomainBlock.find_by(domain: account.domain)&.reject_media? + account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'].blank? + end end old_hub_url = account.hub_url diff --git a/app/views/about/index.html.haml b/app/views/about/index.html.haml index 6dd182205..88bfe3d61 100644 --- a/app/views/about/index.html.haml +++ b/app/views/about/index.html.haml @@ -1,3 +1,6 @@ +- content_for :header_tags do + = javascript_include_tag 'application_public' + - content_for :page_title do = Rails.configuration.x.local_domain @@ -5,10 +8,11 @@ %meta{ property: 'og:site_name', content: 'Mastodon' }/ %meta{ property: 'og:type', content: 'website' }/ %meta{ property: 'og:title', content: Rails.configuration.x.local_domain }/ - %meta{ property: 'og:description', content: "Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly" }/ + %meta{ property: 'og:description', content: @description.blank? ? "Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly" : strip_tags(@description) }/ %meta{ property: 'og:image', content: asset_url('mastodon_small.jpg') }/ %meta{ property: 'og:image:width', content: '400' }/ %meta{ property: 'og:image:height', content: '400' }/ + %meta{ property: 'twitter:card', content: 'summary' }/ .wrapper %h1 @@ -20,10 +24,14 @@ .screenshot= image_tag 'screenshot.png' + - unless @description.blank? + %p= @description.html_safe + .actions .info + = link_to t('about.learn_more'), about_more_path = link_to t('about.terms'), terms_path - = link_to t('about.source_code'), 'https://github.com/Gargron/mastodon' + = link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon' = link_to t('about.get_started'), new_user_registration_path, class: 'button webapp-btn' = link_to t('auth.login'), new_user_session_path, class: 'button webapp-btn' diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml new file mode 100644 index 000000000..2de3bf986 --- /dev/null +++ b/app/views/about/more.html.haml @@ -0,0 +1,56 @@ +- content_for :page_title do + #{Rails.configuration.x.local_domain} + +.wrapper.thicc + .sidebar-layout + .main + .panel + %h2= Rails.configuration.x.local_domain + + - unless @description.blank? + %p= @description.html_safe + + .information-board + .section + %span= t 'about.user_count_before' + %strong= number_with_delimiter @user_count + %span= t 'about.user_count_after' + .section + %span= t 'about.status_count_before' + %strong= number_with_delimiter @status_count + %span= t 'about.status_count_after' + .section + %span= t 'about.domain_count_before' + %strong= number_with_delimiter @domain_count + %span= t 'about.domain_count_after' + + - unless @extended_description.blank? + .panel= @extended_description.html_safe + + .sidebar + .panel + .panel-header= t 'about.contact' + .panel-body + - if @contact_account + .owner + .avatar= image_tag @contact_account.avatar.url + .name + = link_to TagManager.instance.url_for(@contact_account) do + %span.display_name.emojify= display_name(@contact_account) + %span.username= "@#{@contact_account.acct}" + + - unless @contact_email.blank? + .contact-email + = t 'about.business_email' + %strong= @contact_email + .panel + .panel-header= t 'about.links' + .panel-list + %ul + - if user_signed_in? + %li= link_to t('about.get_started'), root_path + - else + %li= link_to t('about.get_started'), new_user_registration_path + %li= link_to t('auth.login'), new_user_session_path + %li= link_to t('about.terms'), terms_path + %li= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon' diff --git a/app/views/accounts/_grid_card.html.haml b/app/views/accounts/_grid_card.html.haml index dfdb23161..d5418fca5 100644 --- a/app/views/accounts/_grid_card.html.haml +++ b/app/views/accounts/_grid_card.html.haml @@ -3,6 +3,6 @@ .avatar= image_tag account.avatar.url(:original) .name = link_to TagManager.instance.url_for(account) do - %span.display_name= display_name(account) + %span.display_name.emojify= display_name(account) %span.username= "@#{account.acct}" - %p.note= truncate(strip_tags(account.note), length: 150) + %p.note.emojify= truncate(strip_tags(account.note), length: 150) diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index 1c6b5f0f6..f575e855e 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -1,27 +1,27 @@ -.card{ style: "background-image: url(#{@account.header.url( :original)})" } +.card.h-card.p-author{ style: "background-image: url(#{@account.header.url( :original)})" } - if user_signed_in? && current_account.id != @account.id && !current_account.requested?(@account) .controls - if current_account.following?(@account) = link_to t('accounts.unfollow'), unfollow_account_path(@account), data: { method: :post }, class: 'button' - else = link_to t('accounts.follow'), follow_account_path(@account), data: { method: :post }, class: 'button' - - else + - elsif !user_signed_in? .controls .remote-follow = link_to t('accounts.remote_follow'), account_remote_follow_path(@account), class: 'button' - .avatar= image_tag @account.avatar.url(:original) + .avatar= image_tag @account.avatar.url(:original), class: 'u-photo' %h1.name - = display_name(@account) + %span.p-name.emojify= display_name(@account) %small - = "@#{@account.username}" + %span.p-nickname= "@#{@account.username}" = fa_icon('lock') if @account.locked? .details .bio - .account__header__content= Formatter.instance.simplified_format(@account) + .account__header__content.p-note.emojify= Formatter.instance.simplified_format(@account) .details-counters .counter{ class: active_nav_class(account_url(@account)) } - = link_to account_url(@account) do + = link_to account_url(@account), class: 'u-url u-uid' do %span.counter-label= t('accounts.posts') %span.counter-number= number_with_delimiter @account.statuses.count .counter{ class: active_nav_class(following_account_url(@account)) } diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index 7afeb68a9..c194ce33d 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -12,16 +12,20 @@ %meta{ property: 'og:image', content: full_asset_url(@account.avatar.url(:original)) }/ %meta{ property: 'og:image:width', content: '120' }/ %meta{ property: 'og:image:height', content: '120' }/ + %meta{ property: 'twitter:card', content: 'summary' }/ -= render partial: 'header' +.h-feed + %data.p-name{ value: "#{@account.username} on #{Rails.configuration.x.local_domain}" }/ -- if @statuses.empty? - .accounts-grid - = render partial: 'nothing_here' -- else - .activity-stream - = render partial: 'stream_entries/status', collection: @statuses, as: :status + = render partial: 'header' -.pagination - - if @statuses.size == 20 - = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), account_url(@account, max_id: @statuses.last.id), class: 'next_page', rel: 'next' + - if @statuses.empty? + .accounts-grid + = render partial: 'nothing_here' + - else + .activity-stream + = render partial: 'stream_entries/status', collection: @statuses, as: :status + + .pagination + - if @statuses.size == 20 + = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), account_url(@account, max_id: @statuses.last.id), class: 'next_page', rel: 'next' diff --git a/app/views/admin/domain_blocks/index.html.haml b/app/views/admin/domain_blocks/index.html.haml index aedf163f7..dbaeb4716 100644 --- a/app/views/admin/domain_blocks/index.html.haml +++ b/app/views/admin/domain_blocks/index.html.haml @@ -5,10 +5,12 @@ %thead %tr %th Domain + %th Severity %tbody - @blocks.each do |block| %tr %td %samp= block.domain + %td= block.severity = will_paginate @blocks, pagination_options diff --git a/app/views/admin/settings/index.html.haml b/app/views/admin/settings/index.html.haml new file mode 100644 index 000000000..5b482213b --- /dev/null +++ b/app/views/admin/settings/index.html.haml @@ -0,0 +1,36 @@ +- content_for :page_title do + Site Settings + +%table.table + %colgroup + %col{ width: '35%' }/ + %thead + %tr + %th Setting + %th Click to edit + %tbody + %tr + %td{ rowspan: 2 } + %strong Contact information + %td= best_in_place @settings['site_contact_username'], :value, url: admin_setting_path(@settings['site_contact_username']), place_holder: 'Enter a username' + %tr + %td= best_in_place @settings['site_contact_email'], :value, url: admin_setting_path(@settings['site_contact_email']), place_holder: 'Enter a public e-mail address' + %tr + %td + %strong Site description + %br/ + Displayed as a paragraph on the frontpage and used as a meta tag. + %br/ + You can use HTML tags, in particular + %code= '<a>' + and + %code= '<em>' + %td= best_in_place @settings['site_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_description']) + %tr + %td + %strong Extended site description + %br/ + Displayed on extended information page + %br/ + You can use HTML tags + %td= best_in_place @settings['site_extended_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_extended_description']) \ No newline at end of file diff --git a/app/views/api/v1/apps/show.rabl b/app/views/api/v1/apps/show.rabl new file mode 100644 index 000000000..6d9e607db --- /dev/null +++ b/app/views/api/v1/apps/show.rabl @@ -0,0 +1,3 @@ +object @application + +attributes :name, :website diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl index a3391a67e..7309a78b8 100644 --- a/app/views/api/v1/statuses/_show.rabl +++ b/app/views/api/v1/statuses/_show.rabl @@ -1,4 +1,4 @@ -attributes :id, :created_at, :in_reply_to_id, :sensitive, :visibility +attributes :id, :created_at, :in_reply_to_id, :sensitive, :spoiler_text, :visibility node(:uri) { |status| TagManager.instance.uri_for(status) } node(:content) { |status| Formatter.instance.format(status) } @@ -6,6 +6,10 @@ node(:url) { |status| TagManager.instance.url_for(status) } node(:reblogs_count) { |status| defined?(@reblogs_counts_map) ? (@reblogs_counts_map[status.id] || 0) : status.reblogs.count } node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites.count } +child :application do + extends 'api/v1/apps/show' +end + child :account do extends 'api/v1/accounts/show' end diff --git a/app/views/api/v1/statuses/card.rabl b/app/views/api/v1/statuses/card.rabl new file mode 100644 index 000000000..8ba8dcbb1 --- /dev/null +++ b/app/views/api/v1/statuses/card.rabl @@ -0,0 +1,5 @@ +object @card + +attributes :url, :title, :description + +node(:image) { |card| card.image? ? full_asset_url(card.image.url(:original)) : nil } diff --git a/app/views/authorize_follow/_card.html.haml b/app/views/authorize_follow/_card.html.haml index a9b02c746..eef0bec07 100644 --- a/app/views/authorize_follow/_card.html.haml +++ b/app/views/authorize_follow/_card.html.haml @@ -4,8 +4,8 @@ = image_tag account.avatar.url(:original), alt: '', width: 48, height: 48, class: 'avatar' %span.display-name - %strong= display_name(account) + %strong.emojify= display_name(account) %span= "@#{account.acct}" - unless account.note.blank? - .account__header__content= Formatter.instance.simplified_format(account) + .account__header__content.emojify= Formatter.instance.simplified_format(account) diff --git a/app/views/errors/404.html.haml b/app/views/errors/404.html.haml new file mode 100644 index 000000000..ba1d5f72d --- /dev/null +++ b/app/views/errors/404.html.haml @@ -0,0 +1,5 @@ +- content_for :page_title do + The page you were looking for doesn't exist + +- content_for :content do + The page you were looking for doesn't exist diff --git a/app/views/errors/410.html.haml b/app/views/errors/410.html.haml new file mode 100644 index 000000000..07cf3742f --- /dev/null +++ b/app/views/errors/410.html.haml @@ -0,0 +1,5 @@ +- content_for :page_title do + The page you were looking for doesn't exist anymore + +- content_for :content do + The page you were looking for doesn't exist anymore diff --git a/app/views/errors/422.html.haml b/app/views/errors/422.html.haml new file mode 100644 index 000000000..e369cded6 --- /dev/null +++ b/app/views/errors/422.html.haml @@ -0,0 +1,5 @@ +- content_for :page_title do + Security verification failed + +- content_for :content do + Security verification failed. Are you blocking cookies? diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 498fae105..0147f4064 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,4 +1,7 @@ - content_for :header_tags do + :javascript + window.INITIAL_STATE = #{json_escape(render(file: 'home/initial_state', formats: :json))} + = javascript_include_tag 'application' = react_component 'Mastodon', default_props, class: 'app-holder', prerender: false diff --git a/app/views/home/initial_state.json.rabl b/app/views/home/initial_state.json.rabl new file mode 100644 index 000000000..0e9736f5f --- /dev/null +++ b/app/views/home/initial_state.json.rabl @@ -0,0 +1,24 @@ +object false + +node(:meta) { + { + access_token: @token, + locale: I18n.locale, + me: current_account.id, + } +} + +node(:compose) { + { + me: current_account.id, + private: current_account.locked?, + } +} + +node(:accounts) { + { + current_account.id => partial('api/v1/accounts/show', object: current_account), + } +} + +node(:settings) { @web_settings } diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml new file mode 100644 index 000000000..54563f7d8 --- /dev/null +++ b/app/views/layouts/error.html.haml @@ -0,0 +1,36 @@ +!!! +%html{:lang => "en"} + %head + %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/ + %meta{:charset => "utf-8"}/ + %title= yield :page_title + %meta{:content => "width=device-width,initial-scale=1", :name => "viewport"}/ + %link{:href => "https://fonts.googleapis.com/css?family=Roboto:400", :rel => "stylesheet"}/ + :css + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #282c37; + color: #9baec8; + text-align: center; + margin: 0; + padding: 20px; + } + + .dialog img { + display: block; + margin: 20px auto; + margin-top: 50px; + max-width: 600px; + width: 100%; + height: auto; + } + + .dialog h1 { + font: 20px/28px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-weight: 400; + } + %body + .dialog + %img{:alt => "Mastodon", :src => "/oops.png"}/ + %div + %h1= yield :content diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml index e6de7d017..808fb0a0e 100644 --- a/app/views/layouts/public.html.haml +++ b/app/views/layouts/public.html.haml @@ -6,6 +6,6 @@ .footer %span.domain= link_to Rails.configuration.x.local_domain, root_path %span.powered-by - = t('generic.powered_by', link: link_to('Mastodon', 'https://github.com/Gargron/mastodon')).html_safe + = t('generic.powered_by', link: link_to('Mastodon', 'https://github.com/tootsuite/mastodon')).html_safe = render template: "layouts/application" diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index a0860c94b..a9a1d21ac 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -6,14 +6,14 @@ = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) } - = f.simple_fields_for :notification_emails, current_user.settings(:notification_emails) do |ff| + = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| = ff.input :follow, as: :boolean, wrapper: :with_label = ff.input :follow_request, as: :boolean, wrapper: :with_label = ff.input :reblog, as: :boolean, wrapper: :with_label = ff.input :favourite, as: :boolean, wrapper: :with_label = ff.input :mention, as: :boolean, wrapper: :with_label - = f.simple_fields_for :interactions, current_user.settings(:interactions) do |ff| + = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff| = ff.input :must_be_follower, as: :boolean, wrapper: :with_label = ff.input :must_be_following, as: :boolean, wrapper: :with_label diff --git a/app/views/settings/shared/_links.html.haml b/app/views/settings/shared/_links.html.haml index 44f097950..a6e90f457 100644 --- a/app/views/settings/shared/_links.html.haml +++ b/app/views/settings/shared/_links.html.haml @@ -5,3 +5,4 @@ %li= link_to t('settings.preferences'), settings_preferences_path - if controller_name != 'registrations' %li= link_to t('auth.change_password'), edit_user_registration_path + %li= link_to t('settings.back'), root_path \ No newline at end of file diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 94451d3bd..6ee8c9e5b 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -1,36 +1,46 @@ .detailed-status.light - = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name', target: @external_links ? '_blank' : nil, rel: 'noopener' do + = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name p-author h-card', target: @external_links ? '_blank' : nil, rel: 'noopener' do %div %div.avatar - = image_tag status.account.avatar.url(:original), width: 48, height: 48, alt: '' + = image_tag status.account.avatar.url(:original), width: 48, height: 48, alt: '', class: 'u-photo' %span.display-name - %strong= display_name(status.account) - %span= acct(status.account) + %strong.p-name.emojify= display_name(status.account) + %span.p-nickname= acct(status.account) - .status__content= Formatter.instance.format(status) + .status__content.e-content.p-name.emojify< + - unless status.spoiler_text.blank? + %p= status.spoiler_text + = Formatter.instance.format(status) - unless status.media_attachments.empty? - if status.media_attachments.first.video? .video-player - if status.sensitive? = render partial: 'stream_entries/content_spoiler' - %video{ src: status.media_attachments.first.file.url(:original), loop: true } + %video{ src: status.media_attachments.first.file.url(:original), loop: true, class: 'u-video' } - else .detailed-status__attachments - if status.sensitive? = render partial: 'stream_entries/content_spoiler' - status.media_attachments.each do |media| .media-item - = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener' + = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}" %div.detailed-status__meta - = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: @external_links ? '_blank' : nil, rel: 'noopener' do + %data.dt-published{ value: status.created_at.to_time.iso8601 } + = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: @external_links ? '_blank' : nil, rel: 'noopener' do %span= l(status.created_at) · - %span + - if status.application + - if status.application.website.blank? + %strong.detailed-status__application= status.application.name + - else + = link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener' + · + %span< = fa_icon('retweet') %span= status.reblogs.count · - %span + %span< = fa_icon('star') %span= status.favourites.count diff --git a/app/views/stream_entries/_favourite.html.haml b/app/views/stream_entries/_favourite.html.haml index aac90dcdf..ea4879328 100644 --- a/app/views/stream_entries/_favourite.html.haml +++ b/app/views/stream_entries/_favourite.html.haml @@ -1,5 +1,5 @@ .entry.entry-favourite - .content + .content.emojify %strong= favourite.account.acct = t('stream_entries.favourited') %strong= favourite.status.account.acct diff --git a/app/views/stream_entries/_follow.html.haml b/app/views/stream_entries/_follow.html.haml index 1a2e2c554..da6d062f0 100644 --- a/app/views/stream_entries/_follow.html.haml +++ b/app/views/stream_entries/_follow.html.haml @@ -1,5 +1,5 @@ .entry.entry-follow - .content + .content.emojify %strong= link_to follow.account.acct, account_path(follow.account) = t('stream_entries.is_now_following') %strong= link_to follow.target_account.acct, TagManager.instance.url_for(follow.target_account) diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index da3bc0ccb..95f90abd9 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -1,17 +1,21 @@ .status.light .status__header .status__meta - = link_to time_ago_in_words(status.created_at), TagManager.instance.url_for(status), class: 'status__relative-time', title: l(status.created_at), target: @external_links ? '_blank' : nil, rel: 'noopener' + = link_to time_ago_in_words(status.created_at), TagManager.instance.url_for(status), class: 'status__relative-time u-url u-uid', title: l(status.created_at), target: @external_links ? '_blank' : nil, rel: 'noopener' + %data.dt-published{ value: status.created_at.to_time.iso8601 } - = link_to TagManager.instance.url_for(status.account), class: 'status__display-name', target: @external_links ? '_blank' : nil, rel: 'noopener' do + = link_to TagManager.instance.url_for(status.account), class: 'status__display-name p-author h-card', target: @external_links ? '_blank' : nil, rel: 'noopener' do .status__avatar %div - = image_tag status.account.avatar(:original), width: 48, height: 48, alt: '' + = image_tag status.account.avatar(:original), width: 48, height: 48, alt: '', class: 'u-photo' %span.display-name - %strong= display_name(status.account) - %span= acct(status.account) + %strong.p-name.emojify= display_name(status.account) + %span.p-nickname= acct(status.account) - .status__content= Formatter.instance.format(status) + .status__content.e-content.p-name.emojify< + - unless status.spoiler_text.blank? + %p= status.spoiler_text + = Formatter.instance.format(status) - unless status.media_attachments.empty? .status__attachments @@ -19,10 +23,10 @@ = render partial: 'stream_entries/content_spoiler' - if status.media_attachments.first.video? .video-item - = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener' do + = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do .video-item__play = fa_icon('play') - else - status.media_attachments.each do |media| .media-item - = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener' + = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}" diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml index 43935da60..6bad45705 100644 --- a/app/views/stream_entries/show.html.haml +++ b/app/views/stream_entries/show.html.haml @@ -5,7 +5,11 @@ %meta{ property: 'og:site_name', content: 'Mastodon' }/ %meta{ property: 'og:type', content: 'article' }/ %meta{ property: 'og:title', content: "#{@account.username} on #{Rails.configuration.x.local_domain}" }/ - %meta{ property: 'og:description', content: @stream_entry.activity.content }/ + + - if @stream_entry.activity.is_a?(Status) && !@stream_entry.activity.spoiler_text.blank? + %meta{ property: 'og:description', content: @stream_entry.activity.spoiler_text }/ + - else + %meta{ property: 'og:description', content: @stream_entry.activity.content }/ - if @stream_entry.activity.is_a?(Status) && @stream_entry.activity.media_attachments.size > 0 %meta{ property: 'og:image', content: full_asset_url(@stream_entry.activity.media_attachments.first.file.url(:small)) }/ @@ -14,5 +18,7 @@ %meta{ property: 'og:image:width', content: '120' }/ %meta{ property: 'og:image:height', content: '120' }/ + %meta{ property: 'twitter:card', content: 'summary' }/ + .activity-stream.activity-stream-headless = render partial: @type, locals: { @type.to_sym => @stream_entry.activity, include_threads: true } diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml index dd42fe22c..412ec4fa5 100644 --- a/app/views/tags/show.html.haml +++ b/app/views/tags/show.html.haml @@ -2,7 +2,7 @@ .accounts-grid = render partial: 'accounts/nothing_here' - else - .activity-stream + .activity-stream.h-feed = render partial: 'stream_entries/status', collection: @statuses, as: :status, cached: true .pagination diff --git a/app/workers/block_worker.rb b/app/workers/block_worker.rb new file mode 100644 index 000000000..0820490d3 --- /dev/null +++ b/app/workers/block_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class BlockWorker + include Sidekiq::Worker + + def perform(account_id, target_account_id) + AfterBlockService.new.call(Account.find(account_id), Account.find(target_account_id)) + end +end diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb new file mode 100644 index 000000000..af3394b8b --- /dev/null +++ b/app/workers/link_crawl_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class LinkCrawlWorker + include Sidekiq::Worker + + sidekiq_options retry: false + + def perform(status_id) + FetchLinkCardService.new.call(Status.find(status_id)) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb new file mode 100644 index 000000000..0f288f43f --- /dev/null +++ b/app/workers/merge_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MergeWorker + include Sidekiq::Worker + + def perform(from_account_id, into_account_id) + FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id)) + end +end diff --git a/app/workers/notification_worker.rb b/app/workers/notification_worker.rb index 386e94111..e4c38d384 100644 --- a/app/workers/notification_worker.rb +++ b/app/workers/notification_worker.rb @@ -3,6 +3,8 @@ class NotificationWorker include Sidekiq::Worker + sidekiq_options retry: 5 + def perform(stream_entry_id, target_account_id) SendInteractionService.new.call(StreamEntry.find(stream_entry_id), Account.find(target_account_id)) end diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb index 489bd8359..868fd9f97 100644 --- a/app/workers/pubsubhubbub/confirmation_worker.rb +++ b/app/workers/pubsubhubbub/confirmation_worker.rb @@ -4,7 +4,7 @@ class Pubsubhubbub::ConfirmationWorker include Sidekiq::Worker include RoutingHelper - sidekiq_options queue: 'push' + sidekiq_options queue: 'push', retry: false def perform(subscription_id, mode, secret = nil, lease_seconds = nil) subscription = Subscription.find(subscription_id) diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb index 35bf7b2f0..15005bc80 100644 --- a/app/workers/pubsubhubbub/delivery_worker.rb +++ b/app/workers/pubsubhubbub/delivery_worker.rb @@ -4,7 +4,11 @@ class Pubsubhubbub::DeliveryWorker include Sidekiq::Worker include RoutingHelper - sidekiq_options queue: 'push', retry: 5 + sidekiq_options queue: 'push', retry: 3, dead: false + + sidekiq_retry_in do |count| + 5 * (count + 1) + end def perform(subscription_id, payload) subscription = Subscription.find(subscription_id) diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb index 84eae73be..593edd032 100644 --- a/app/workers/thread_resolve_worker.rb +++ b/app/workers/thread_resolve_worker.rb @@ -3,6 +3,8 @@ class ThreadResolveWorker include Sidekiq::Worker + sidekiq_options retry: false + def perform(child_status_id, parent_url) child_status = Status.find(child_status_id) parent_status = FetchRemoteStatusService.new.call(parent_url) diff --git a/app/workers/unfavourite_worker.rb b/app/workers/unfavourite_worker.rb index a14c82d6f..cce07e486 100644 --- a/app/workers/unfavourite_worker.rb +++ b/app/workers/unfavourite_worker.rb @@ -5,5 +5,7 @@ class UnfavouriteWorker def perform(account_id, status_id) UnfavouriteService.new.call(Account.find(account_id), Status.find(status_id)) + rescue ActiveRecord::RecordNotFound + true end end diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb new file mode 100644 index 000000000..dbf7243de --- /dev/null +++ b/app/workers/unmerge_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class UnmergeWorker + include Sidekiq::Worker + + def perform(from_account_id, into_account_id) + FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id)) + end +end |