diff options
Diffstat (limited to 'app')
270 files changed, 6135 insertions, 1906 deletions
diff --git a/app/assets/images/fluffy-elephant-friend.png b/app/assets/images/fluffy-elephant-friend.png new file mode 100644 index 000000000..f0df29927 --- /dev/null +++ b/app/assets/images/fluffy-elephant-friend.png Binary files differdiff --git a/app/assets/images/mastodon-not-found.png b/app/assets/images/mastodon-not-found.png new file mode 100644 index 000000000..76108d41f --- /dev/null +++ b/app/assets/images/mastodon-not-found.png Binary files differdiff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx index 0be05034e..37ebb9969 100644 --- a/app/assets/javascripts/components/actions/accounts.jsx +++ b/app/assets/javascripts/components/actions/accounts.jsx @@ -21,6 +21,14 @@ export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; +export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; +export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; +export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; + +export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; +export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; +export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; + export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST'; export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS'; export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL'; @@ -67,11 +75,16 @@ export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; export function fetchAccount(id) { return (dispatch, getState) => { + dispatch(fetchRelationships([id])); + + if (getState().getIn(['accounts', id], null) !== null) { + return; + } + dispatch(fetchAccountRequest(id)); api(getState).get(`/api/v1/accounts/${id}`).then(response => { dispatch(fetchAccountSuccess(response.data)); - dispatch(fetchRelationships([id])); }).catch(error => { dispatch(fetchAccountFail(id, error)); }); @@ -138,7 +151,8 @@ export function fetchAccountFail(id, error) { return { type: ACCOUNT_FETCH_FAIL, id, - error + error, + skipAlert: true }; }; @@ -231,7 +245,8 @@ export function fetchAccountTimelineFail(id, error, skipLoading) { type: ACCOUNT_TIMELINE_FETCH_FAIL, id, error, - skipLoading + skipLoading, + skipAlert: error.response.status === 404 }; }; @@ -326,6 +341,76 @@ export function unblockAccountFail(error) { }; }; + +export function muteAccount(id) { + return (dispatch, getState) => { + dispatch(muteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); + }).catch(error => { + dispatch(muteAccountFail(id, error)); + }); + }; +}; + +export function unmuteAccount(id) { + return (dispatch, getState) => { + dispatch(unmuteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { + dispatch(unmuteAccountSuccess(response.data)); + }).catch(error => { + dispatch(unmuteAccountFail(id, error)); + }); + }; +}; + +export function muteAccountRequest(id) { + return { + type: ACCOUNT_MUTE_REQUEST, + id + }; +}; + +export function muteAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_MUTE_SUCCESS, + relationship, + statuses + }; +}; + +export function muteAccountFail(error) { + return { + type: ACCOUNT_MUTE_FAIL, + error + }; +}; + +export function unmuteAccountRequest(id) { + return { + type: ACCOUNT_UNMUTE_REQUEST, + id + }; +}; + +export function unmuteAccountSuccess(relationship) { + return { + type: ACCOUNT_UNMUTE_SUCCESS, + relationship + }; +}; + +export function unmuteAccountFail(error) { + return { + type: ACCOUNT_UNMUTE_FAIL, + error + }; +}; + + export function fetchFollowers(id) { return (dispatch, getState) => { dispatch(fetchFollowersRequest(id)); @@ -494,15 +579,18 @@ export function expandFollowingFail(id, error) { }; }; -export function fetchRelationships(account_ids) { +export function fetchRelationships(accountIds) { return (dispatch, getState) => { - if (account_ids.length === 0) { + const loadedRelationships = getState().get('relationships'); + const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); + + if (newAccountIds.length === 0) { return; } - dispatch(fetchRelationshipsRequest(account_ids)); + dispatch(fetchRelationshipsRequest(newAccountIds)); - api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => { + api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { dispatch(fetchRelationshipsSuccess(response.data)); }).catch(error => { dispatch(fetchRelationshipsFail(error)); diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx index 503c2bfeb..d4c1eda60 100644 --- a/app/assets/javascripts/components/actions/cards.jsx +++ b/app/assets/javascripts/components/actions/cards.jsx @@ -6,6 +6,10 @@ export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL'; export function fetchStatusCard(id) { return (dispatch, getState) => { + if (getState().getIn(['cards', id], null) !== null) { + return; + } + dispatch(fetchStatusCardRequest(id)); api(getState).get(`/api/v1/statuses/${id}/card`).then(response => { @@ -42,6 +46,7 @@ export function fetchStatusCardFail(id, error) { type: STATUS_CARD_FETCH_FAIL, id, error, - skipLoading: true + skipLoading: true, + skipAlert: true }; }; diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index f87518751..1b3cc60dc 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -1,4 +1,4 @@ -import api from '../api' +import api from '../api'; import { updateTimeline } from './timelines'; @@ -28,6 +28,8 @@ 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'; +export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -77,7 +79,7 @@ export function submitCompose() { 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') + visibility: getState().getIn(['compose', 'privacy']) }).then(function (response) { dispatch(submitComposeSuccess({ ...response.data })); @@ -85,7 +87,13 @@ export function submitCompose() { dispatch(updateTimeline('home', { ...response.data })); if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { - dispatch(updateTimeline('public', { ...response.data })); + if (getState().getIn(['timelines', 'community', 'loaded'])) { + dispatch(updateTimeline('community', { ...response.data })); + } + + if (getState().getIn(['timelines', 'public', 'loaded'])) { + dispatch(updateTimeline('public', { ...response.data })); + } } }).catch(function (error) { dispatch(submitComposeFail(error)); @@ -115,6 +123,10 @@ export function submitComposeFail(error) { export function uploadCompose(files) { return function (dispatch, getState) { + if (getState().getIn(['compose', 'media_attachments']).size > 3) { + return; + } + dispatch(uploadComposeRequest()); let data = new FormData(); @@ -134,7 +146,8 @@ export function uploadCompose(files) { export function uploadComposeRequest() { return { - type: COMPOSE_UPLOAD_REQUEST + type: COMPOSE_UPLOAD_REQUEST, + skipLoading: true }; }; @@ -149,14 +162,16 @@ export function uploadComposeProgress(loaded, total) { export function uploadComposeSuccess(media) { return { type: COMPOSE_UPLOAD_SUCCESS, - media: media + media: media, + skipLoading: true }; }; export function uploadComposeFail(error) { return { type: COMPOSE_UPLOAD_FAIL, - error: error + error: error, + skipLoading: true }; }; @@ -220,17 +235,15 @@ export function unmountCompose() { }; }; -export function changeComposeSensitivity(checked) { +export function changeComposeSensitivity() { return { type: COMPOSE_SENSITIVITY_CHANGE, - checked }; }; -export function changeComposeSpoilerness(checked) { +export function changeComposeSpoilerness() { return { - type: COMPOSE_SPOILERNESS_CHANGE, - checked + type: COMPOSE_SPOILERNESS_CHANGE }; }; @@ -241,16 +254,17 @@ export function changeComposeSpoilerText(text) { }; }; -export function changeComposeVisibility(checked) { +export function changeComposeVisibility(value) { return { type: COMPOSE_VISIBILITY_CHANGE, - checked + value }; }; -export function changeComposeListability(checked) { +export function insertEmojiCompose(position, emoji) { return { - type: COMPOSE_LISTABILITY_CHANGE, - checked + type: COMPOSE_EMOJI_INSERT, + position, + emoji }; }; diff --git a/app/assets/javascripts/components/actions/modal.jsx b/app/assets/javascripts/components/actions/modal.jsx index d19218c48..615cd6bfe 100644 --- a/app/assets/javascripts/components/actions/modal.jsx +++ b/app/assets/javascripts/components/actions/modal.jsx @@ -1,14 +1,11 @@ -export const MEDIA_OPEN = 'MEDIA_OPEN'; +export const MODAL_OPEN = 'MODAL_OPEN'; export const MODAL_CLOSE = 'MODAL_CLOSE'; -export const MODAL_INDEX_DECREASE = 'MODAL_INDEX_DECREASE'; -export const MODAL_INDEX_INCREASE = 'MODAL_INDEX_INCREASE'; - -export function openMedia(media, index) { +export function openModal(type, props) { return { - type: MEDIA_OPEN, - media, - index + type: MODAL_OPEN, + modalType: type, + modalProps: props }; }; @@ -17,15 +14,3 @@ export function closeModal() { type: MODAL_CLOSE }; }; - -export function decreaseIndexInModal() { - return { - type: MODAL_INDEX_DECREASE - }; -}; - -export function increaseIndexInModal() { - return { - type: MODAL_INDEX_INCREASE - }; -}; diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx index df82e73fc..980b7d63e 100644 --- a/app/assets/javascripts/components/actions/notifications.jsx +++ b/app/assets/javascripts/components/actions/notifications.jsx @@ -14,7 +14,8 @@ 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_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; const fetchRelatedRelationships = (dispatch, notifications) => { const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); @@ -151,3 +152,10 @@ export function clearNotifications() { api(getState).post('/api/v1/notifications/clear'); }; }; + +export function scrollTopNotifications(top) { + return { + type: NOTIFICATIONS_SCROLL_TOP, + top + }; +}; diff --git a/app/assets/javascripts/components/actions/reports.jsx b/app/assets/javascripts/components/actions/reports.jsx new file mode 100644 index 000000000..2c1245dc4 --- /dev/null +++ b/app/assets/javascripts/components/actions/reports.jsx @@ -0,0 +1,64 @@ +import api from '../api'; + +export const REPORT_INIT = 'REPORT_INIT'; +export const REPORT_CANCEL = 'REPORT_CANCEL'; + +export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; +export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; +export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; + +export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; + +export function initReport(account, status) { + return { + type: REPORT_INIT, + account, + status + }; +}; + +export function cancelReport() { + return { + type: REPORT_CANCEL + }; +}; + +export function toggleStatusReport(statusId, checked) { + return { + type: REPORT_STATUS_TOGGLE, + statusId, + checked, + }; +}; + +export function submitReport() { + return (dispatch, getState) => { + dispatch(submitReportRequest()); + + api(getState).post('/api/v1/reports', { + account_id: getState().getIn(['reports', 'new', 'account_id']), + status_ids: getState().getIn(['reports', 'new', 'status_ids']), + comment: getState().getIn(['reports', 'new', 'comment']) + }).then(response => dispatch(submitReportSuccess(response.data))).catch(error => dispatch(submitReportFail(error))); + }; +}; + +export function submitReportRequest() { + return { + type: REPORT_SUBMIT_REQUEST + }; +}; + +export function submitReportSuccess(report) { + return { + type: REPORT_SUBMIT_SUCCESS, + report + }; +}; + +export function submitReportFail(error) { + return { + type: REPORT_SUBMIT_FAIL, + error + }; +}; diff --git a/app/assets/javascripts/components/actions/search.jsx b/app/assets/javascripts/components/actions/search.jsx index ceb0e4a0c..df3ae0db1 100644 --- a/app/assets/javascripts/components/actions/search.jsx +++ b/app/assets/javascripts/components/actions/search.jsx @@ -1,9 +1,12 @@ import api from '../api' -export const SEARCH_CHANGE = 'SEARCH_CHANGE'; -export const SEARCH_SUGGESTIONS_CLEAR = 'SEARCH_SUGGESTIONS_CLEAR'; -export const SEARCH_SUGGESTIONS_READY = 'SEARCH_SUGGESTIONS_READY'; -export const SEARCH_RESET = 'SEARCH_RESET'; +export const SEARCH_CHANGE = 'SEARCH_CHANGE'; +export const SEARCH_CLEAR = 'SEARCH_CLEAR'; +export const SEARCH_SHOW = 'SEARCH_SHOW'; + +export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; +export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; +export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; export function changeSearch(value) { return { @@ -12,40 +15,59 @@ export function changeSearch(value) { }; }; -export function clearSearchSuggestions() { - return { - type: SEARCH_SUGGESTIONS_CLEAR - }; -}; - -export function readySearchSuggestions(value, accounts) { +export function clearSearch() { return { - type: SEARCH_SUGGESTIONS_READY, - value, - accounts + type: SEARCH_CLEAR }; }; -export function fetchSearchSuggestions(value) { +export function submitSearch() { return (dispatch, getState) => { - if (getState().getIn(['search', 'loaded_value']) === value) { + const value = getState().getIn(['search', 'value']); + + if (value.length === 0) { return; } - api(getState).get('/api/v1/accounts/search', { + dispatch(fetchSearchRequest()); + + api(getState).get('/api/v1/search', { params: { q: value, - resolve: true, - limit: 4 + resolve: true } }).then(response => { - dispatch(readySearchSuggestions(value, response.data)); + dispatch(fetchSearchSuccess(response.data)); + }).catch(error => { + dispatch(fetchSearchFail(error)); }); }; }; -export function resetSearch() { +export function fetchSearchRequest() { + return { + type: SEARCH_FETCH_REQUEST + }; +}; + +export function fetchSearchSuccess(results) { + return { + type: SEARCH_FETCH_SUCCESS, + results, + accounts: results.accounts, + statuses: results.statuses + }; +}; + +export function fetchSearchFail(error) { + return { + type: SEARCH_FETCH_FAIL, + error + }; +}; + +export function showSearch() { return { - type: SEARCH_RESET + type: SEARCH_SHOW }; }; diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx index 9ac215727..19df2c36c 100644 --- a/app/assets/javascripts/components/actions/statuses.jsx +++ b/app/assets/javascripts/components/actions/statuses.jsx @@ -27,12 +27,17 @@ export function fetchStatus(id) { return (dispatch, getState) => { const skipLoading = getState().getIn(['statuses', id], null) !== null; + dispatch(fetchContext(id)); + dispatch(fetchStatusCard(id)); + + if (skipLoading) { + return; + } + dispatch(fetchStatusRequest(id, skipLoading)); api(getState).get(`/api/v1/statuses/${id}`).then(response => { dispatch(fetchStatusSuccess(response.data, skipLoading)); - dispatch(fetchContext(id)); - dispatch(fetchStatusCard(id)); }).catch(error => { dispatch(fetchStatusFail(id, error, skipLoading)); }); @@ -52,7 +57,8 @@ export function fetchStatusFail(id, error, skipLoading) { type: STATUS_FETCH_FAIL, id, error, - skipLoading + skipLoading, + skipAlert: true }; }; @@ -97,7 +103,12 @@ export function fetchContext(id) { api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); + }).catch(error => { + if (error.response.status === 404) { + dispatch(deleteFromTimelines(id)); + } + dispatch(fetchContextFail(id, error)); }); }; @@ -124,6 +135,7 @@ export function fetchContextFail(id, error) { return { type: CONTEXT_FETCH_FAIL, id, - error + error, + skipAlert: true }; }; diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx index 1531b89a3..6cd1f04b3 100644 --- a/app/assets/javascripts/components/actions/timelines.jsx +++ b/app/assets/javascripts/components/actions/timelines.jsx @@ -1,4 +1,4 @@ -import api from '../api' +import api, { getLinks } from '../api' import Immutable from 'immutable'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; @@ -14,12 +14,16 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; -export function refreshTimelineSuccess(timeline, statuses, skipLoading) { +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; +export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; + +export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { return { type: TIMELINE_REFRESH_SUCCESS, timeline, statuses, - skipLoading + skipLoading, + next }; }; @@ -69,25 +73,27 @@ export function refreshTimeline(timeline, id = null) { const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List()); const newestId = ids.size > 0 ? ids.first() : null; + let params = getState().getIn(['timelines', timeline, 'params'], {}); + const path = getState().getIn(['timelines', timeline, 'path'])(id); - let params = ''; - let path = timeline; let skipLoading = false; if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) { - params = `?since_id=${newestId}`; - skipLoading = true; - } + if (id === null && getState().getIn(['timelines', timeline, 'online'])) { + // Skip refreshing when timeline is live anyway + return; + } - if (id) { - path = `${path}/${id}` + params = { ...params, since_id: newestId }; + skipLoading = true; } dispatch(refreshTimelineRequest(timeline, id, skipLoading)); - api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) { - dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading)); - }).catch(function (error) { + api(getState).get(path, { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading, next ? next.uri : null)); + }).catch(error => { dispatch(refreshTimelineFail(timeline, error, skipLoading)); }); }; @@ -102,50 +108,50 @@ export function refreshTimelineFail(timeline, error, skipLoading) { }; }; -export function expandTimeline(timeline, id = null) { +export function expandTimeline(timeline) { 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 + if (getState().getIn(['timelines', timeline, 'isLoading'])) { return; } - dispatch(expandTimelineRequest(timeline, id)); + if (getState().getIn(['timelines', timeline, 'items']).size === 0) { + return; + } - let path = timeline; + const path = getState().getIn(['timelines', timeline, 'path'])(getState().getIn(['timelines', timeline, 'id'])); + const params = getState().getIn(['timelines', timeline, 'params'], {}); + const lastId = getState().getIn(['timelines', timeline, 'items']).last(); - if (id) { - path = `${path}/${id}` - } + dispatch(expandTimelineRequest(timeline)); - api(getState).get(`/api/v1/timelines/${path}`, { + api(getState).get(path, { params: { - limit: 10, - max_id: lastId + ...params, + max_id: lastId, + limit: 10 } }).then(response => { - dispatch(expandTimelineSuccess(timeline, response.data)); + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandTimelineSuccess(timeline, response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandTimelineFail(timeline, error)); }); }; }; -export function expandTimelineRequest(timeline, id) { +export function expandTimelineRequest(timeline) { return { type: TIMELINE_EXPAND_REQUEST, - timeline, - id + timeline }; }; -export function expandTimelineSuccess(timeline, statuses) { +export function expandTimelineSuccess(timeline, statuses, next) { return { type: TIMELINE_EXPAND_SUCCESS, timeline, - statuses + statuses, + next }; }; @@ -164,3 +170,17 @@ export function scrollTopTimeline(timeline, top) { top }; }; + +export function connectTimeline(timeline) { + return { + type: TIMELINE_CONNECT, + timeline + }; +}; + +export function disconnectTimeline(timeline) { + return { + type: TIMELINE_DISCONNECT, + timeline + }; +}; diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx index 4e4c2090c..744424661 100644 --- a/app/assets/javascripts/components/components/autosuggest_textarea.jsx +++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx @@ -1,5 +1,6 @@ import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { isRtl } from '../rtl'; const textAtCursorMatchesToken = (str, caretPosition) => { let word; @@ -32,20 +33,18 @@ const AutosuggestTextarea = React.createClass({ value: React.PropTypes.string, suggestions: ImmutablePropTypes.list, disabled: React.PropTypes.bool, - fileDropDate: React.PropTypes.instanceOf(Date), placeholder: React.PropTypes.string, onSuggestionSelected: React.PropTypes.func.isRequired, onSuggestionsClearRequested: React.PropTypes.func.isRequired, onSuggestionsFetchRequested: React.PropTypes.func.isRequired, onChange: React.PropTypes.func.isRequired, onKeyUp: React.PropTypes.func, - onKeyDown: React.PropTypes.func + onKeyDown: React.PropTypes.func, + onPaste: React.PropTypes.func.isRequired, }, getInitialState () { return { - isFileDragging: false, - fileDraggingDate: undefined, suggestionsHidden: false, selectedSuggestion: 0, lastToken: null, @@ -137,45 +136,28 @@ const AutosuggestTextarea = React.createClass({ if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { this.setState({ suggestionsHidden: false }); } - - const fileDropDate = nextProps.fileDropDate; - const { isFileDragging, fileDraggingDate } = this.state; - - /* - * We can't detect drop events, because they might not be on the textarea (the app allows dropping anywhere in the - * window). Instead, on-drop, we notify this textarea to stop its hover effect by passing in a prop with the - * drop-date. - */ - if (isFileDragging && fileDraggingDate && fileDropDate // if dragging when props updated, and dates aren't undefined - && fileDropDate > fileDraggingDate) { // and if the drop date is now greater than when we started dragging - // then we should stop dragging - this.setState({ - isFileDragging: false - }); - } }, setTextarea (c) { this.textarea = c; }, - onDragEnter () { - this.setState({ - isFileDragging: true, - fileDraggingDate: new Date() - }) - }, - - onDragExit () { - this.setState({ - isFileDragging: false - }) + onPaste (e) { + if (e.clipboardData && e.clipboardData.files.length === 1) { + this.props.onPaste(e.clipboardData.files) + e.preventDefault(); + } }, render () { - const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props; - const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state; - const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea'; + const { value, suggestions, disabled, placeholder, onKeyUp } = this.props; + const { suggestionsHidden, selectedSuggestion } = this.state; + const className = 'autosuggest-textarea__textarea'; + const style = { direction: 'ltr' }; + + if (isRtl(value)) { + style.direction = 'rtl'; + } return ( <div className='autosuggest-textarea'> @@ -190,8 +172,8 @@ const AutosuggestTextarea = React.createClass({ onKeyDown={this.onKeyDown} onKeyUp={onKeyUp} onBlur={this.onBlur} - onDragEnter={this.onDragEnter} - onDragExit={this.onDragExit} + onPaste={this.onPaste} + style={style} /> <div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'> diff --git a/app/assets/javascripts/components/components/collapsable.jsx b/app/assets/javascripts/components/components/collapsable.jsx new file mode 100644 index 000000000..aeebb4b0f --- /dev/null +++ b/app/assets/javascripts/components/components/collapsable.jsx @@ -0,0 +1,19 @@ +import { Motion, spring } from 'react-motion'; + +const Collapsable = ({ fullHeight, isVisible, children }) => ( + <Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}> + {({ opacity, height }) => + <div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}> + {children} + </div> + } + </Motion> +); + +Collapsable.propTypes = { + fullHeight: React.PropTypes.number.isRequired, + isVisible: React.PropTypes.bool.isRequired, + children: React.PropTypes.node.isRequired +}; + +export default Collapsable; diff --git a/app/assets/javascripts/components/components/column_back_button.jsx b/app/assets/javascripts/components/components/column_back_button.jsx index 6abf11239..6b5ffee53 100644 --- a/app/assets/javascripts/components/components/column_back_button.jsx +++ b/app/assets/javascripts/components/components/column_back_button.jsx @@ -15,7 +15,8 @@ const ColumnBackButton = React.createClass({ mixins: [PureRenderMixin], handleClick () { - this.context.router.goBack(); + if (window.history && window.history.length == 1) this.context.router.push("/"); + else this.context.router.goBack(); }, render () { diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx index 90c561bce..44added8a 100644 --- a/app/assets/javascripts/components/components/column_collapsable.jsx +++ b/app/assets/javascripts/components/components/column_collapsable.jsx @@ -7,7 +7,8 @@ const iconStyle = { position: 'absolute', right: '0', top: '-48px', - cursor: 'pointer' + cursor: 'pointer', + zIndex: '3' }; const ColumnCollapsable = React.createClass({ @@ -40,10 +41,11 @@ const ColumnCollapsable = React.createClass({ render () { const { icon, fullHeight, children } = this.props; const { collapsed } = this.state; + const collapsedClassName = collapsed ? 'collapsable-collapsed' : 'collapsable'; 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> + <div style={{...iconStyle }} className={`column-icon ${collapsedClassName}`} 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 }) => diff --git a/app/assets/javascripts/components/components/display_name.jsx b/app/assets/javascripts/components/components/display_name.jsx index 053b5290c..aa48608d3 100644 --- a/app/assets/javascripts/components/components/display_name.jsx +++ b/app/assets/javascripts/components/components/display_name.jsx @@ -1,6 +1,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; -import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; +import escapeTextContentForBrowser from 'escape-html'; import emojify from '../emoji'; const DisplayName = React.createClass({ diff --git a/app/assets/javascripts/components/components/dropdown_menu.jsx b/app/assets/javascripts/components/components/dropdown_menu.jsx index ffef29c00..2b42eaa60 100644 --- a/app/assets/javascripts/components/components/dropdown_menu.jsx +++ b/app/assets/javascripts/components/components/dropdown_menu.jsx @@ -1,32 +1,72 @@ import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; -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 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') { - e.preventDefault(); - action(); - } - }}>{text}</a></li>)} - </ul> - </DropdownContent> - </Dropdown> - ); -}; - -DropdownMenu.propTypes = { - icon: React.PropTypes.string.isRequired, - items: React.PropTypes.array.isRequired, - size: React.PropTypes.number.isRequired -}; +const DropdownMenu = React.createClass({ + + propTypes: { + icon: React.PropTypes.string.isRequired, + items: React.PropTypes.array.isRequired, + size: React.PropTypes.number.isRequired, + direction: React.PropTypes.string + }, + + getDefaultProps () { + return { + direction: 'left' + }; + }, + + mixins: [PureRenderMixin], + + setRef (c) { + this.dropdown = c; + }, + + handleClick (i, e) { + const { action } = this.props.items[i]; + + if (typeof action === 'function') { + e.preventDefault(); + action(); + this.dropdown.hide(); + } + }, + + renderItem (item, i) { + if (item === null) { + return <li key={i} className='dropdown__sep' />; + } + + const { text, action, href = '#' } = item; + + return ( + <li key={i}> + <a href={href} target='_blank' rel='noopener' onClick={this.handleClick.bind(this, i)}> + {text} + </a> + </li> + ); + }, + + render () { + const { icon, items, size, direction } = this.props; + const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right"; + + return ( + <Dropdown ref={this.setRef}> + <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 className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}> + <ul> + {items.map(this.renderItem)} + </ul> + </DropdownContent> + </Dropdown> + ); + } + +}); export default DropdownMenu; diff --git a/app/assets/javascripts/components/components/extended_video_player.jsx b/app/assets/javascripts/components/components/extended_video_player.jsx new file mode 100644 index 000000000..66e5dee16 --- /dev/null +++ b/app/assets/javascripts/components/components/extended_video_player.jsx @@ -0,0 +1,21 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; + +const ExtendedVideoPlayer = React.createClass({ + + propTypes: { + src: React.PropTypes.string.isRequired + }, + + mixins: [PureRenderMixin], + + render () { + return ( + <div> + <video src={this.props.src} autoPlay muted loop /> + </div> + ); + }, + +}); + +export default ExtendedVideoPlayer; diff --git a/app/assets/javascripts/components/components/icon_button.jsx b/app/assets/javascripts/components/components/icon_button.jsx index f9b6192c0..a08b1159b 100644 --- a/app/assets/javascripts/components/components/icon_button.jsx +++ b/app/assets/javascripts/components/components/icon_button.jsx @@ -12,6 +12,7 @@ const IconButton = React.createClass({ style: React.PropTypes.object, activeStyle: React.PropTypes.object, disabled: React.PropTypes.bool, + inverted: React.PropTypes.bool, animate: React.PropTypes.bool }, @@ -36,10 +37,6 @@ const IconButton = React.createClass({ render () { let style = { - display: 'inline-block', - border: 'none', - padding: '0', - background: 'transparent', fontSize: `${this.props.size}px`, width: `${this.props.size * 1.28571429}px`, height: `${this.props.size}px`, @@ -57,7 +54,7 @@ const IconButton = React.createClass({ <button aria-label={this.props.title} title={this.props.title} - className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`} + className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''} ${this.props.inverted ? 'inverted' : ''}`} onClick={this.handleClick} style={style}> <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx deleted file mode 100644 index f04ca47ba..000000000 --- a/app/assets/javascripts/components/components/lightbox.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import IconButton from './icon_button'; -import { Motion, spring } from 'react-motion'; -import { injectIntl } from 'react-intl'; - -const overlayStyle = { - position: 'fixed', - top: '0', - left: '0', - width: '100%', - height: '100%', - background: 'rgba(0, 0, 0, 0.5)', - display: 'flex', - justifyContent: 'center', - alignContent: 'center', - flexDirection: 'row', - zIndex: '9999' -}; - -const dialogStyle = { - color: '#282c37', - boxShadow: '0 0 30px rgba(0, 0, 0, 0.8)', - margin: 'auto', - position: 'relative' -}; - -const closeStyle = { - position: 'absolute', - top: '4px', - right: '4px' -}; - -const Lightbox = React.createClass({ - - propTypes: { - isVisible: React.PropTypes.bool, - onOverlayClicked: React.PropTypes.func, - onCloseClicked: React.PropTypes.func, - intl: React.PropTypes.object.isRequired, - children: React.PropTypes.node - }, - - mixins: [PureRenderMixin], - - componentDidMount () { - this._listener = e => { - if (this.props.isVisible && e.key === 'Escape') { - this.props.onCloseClicked(); - } - }; - - window.addEventListener('keyup', this._listener); - }, - - componentWillUnmount () { - window.removeEventListener('keyup', this._listener); - }, - - stopPropagation (e) { - e.stopPropagation(); - }, - - render () { - const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props; - - return ( - <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', pointerEvents: !isVisible ? 'none' : 'auto'}} onClick={onOverlayClicked}> - <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }} onClick={this.stopPropagation}> - <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} /> - {children} - </div> - </div> - } - </Motion> - ); - } - -}); - -export default injectIntl(Lightbox); diff --git a/app/assets/javascripts/components/components/loading_indicator.jsx b/app/assets/javascripts/components/components/loading_indicator.jsx index c8a263924..913a4bf99 100644 --- a/app/assets/javascripts/components/components/loading_indicator.jsx +++ b/app/assets/javascripts/components/components/loading_indicator.jsx @@ -4,12 +4,11 @@ const style = { textAlign: 'center', fontSize: '16px', fontWeight: '500', - color: '#616b86', paddingTop: '120px' }; const LoadingIndicator = () => ( - <div style={style}> + <div className='loading-indicator' style={style}> <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /> </div> ); diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx index a13448d0b..72b5e977f 100644 --- a/app/assets/javascripts/components/components/media_gallery.jsx +++ b/app/assets/javascripts/components/components/media_gallery.jsx @@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { isIOS } from '../is_mobile'; const messages = defineMessages({ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' } @@ -16,8 +17,6 @@ const outerStyle = { }; const spoilerStyle = { - background: '#000', - color: '#fff', textAlign: 'center', height: '100%', cursor: 'pointer', @@ -45,6 +44,141 @@ const spoilerButtonStyle = { zIndex: '100' }; +const itemStyle = { + boxSizing: 'border-box', + position: 'relative', + float: 'left', + border: 'none', + display: 'block' +}; + +const thumbStyle = { + display: 'block', + width: '100%', + height: '100%', + textDecoration: 'none', + backgroundSize: 'cover', + cursor: 'zoom-in' +}; + +const gifvThumbStyle = { + position: 'relative', + zIndex: '1', + width: '100%', + height: '100%', + objectFit: 'cover', + top: '50%', + transform: 'translateY(-50%)', + cursor: 'zoom-in' +}; + +const Item = React.createClass({ + + propTypes: { + attachment: ImmutablePropTypes.map.isRequired, + index: React.PropTypes.number.isRequired, + size: React.PropTypes.number.isRequired, + onClick: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + handleClick (e) { + const { index, onClick } = this.props; + + if (e.button === 0) { + e.preventDefault(); + onClick(index); + } + + e.stopPropagation(); + }, + + render () { + const { attachment, index, size } = this.props; + + let width = 50; + let height = 100; + let top = 'auto'; + let left = 'auto'; + let bottom = 'auto'; + let right = 'auto'; + + if (size === 1) { + width = 100; + } + + if (size === 4 || (size === 3 && index > 0)) { + height = 50; + } + + if (size === 2) { + if (index === 0) { + right = '2px'; + } else { + left = '2px'; + } + } else if (size === 3) { + if (index === 0) { + right = '2px'; + } else if (index > 0) { + left = '2px'; + } + + if (index === 1) { + bottom = '2px'; + } else if (index > 1) { + top = '2px'; + } + } else if (size === 4) { + if (index === 0 || index === 2) { + right = '2px'; + } + + if (index === 1 || index === 3) { + left = '2px'; + } + + if (index < 2) { + bottom = '2px'; + } else { + top = '2px'; + } + } + + let thumbnail = ''; + + if (attachment.get('type') === 'image') { + thumbnail = ( + <a + href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} + onClick={this.handleClick} + target='_blank' + style={{ background: `url(${attachment.get('preview_url')}) no-repeat center`, ...thumbStyle }} + /> + ); + } else if (attachment.get('type') === 'gifv') { + thumbnail = ( + <video + src={attachment.get('url')} + onClick={this.handleClick} + autoPlay={!isIOS()} + loop={true} + muted={true} + style={gifvThumbStyle} + /> + ); + } + + return ( + <div key={attachment.get('id')} style={{ ...itemStyle, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + {thumbnail} + </div> + ); + } + +}); + const MediaGallery = React.createClass({ getInitialState () { @@ -63,17 +197,12 @@ const MediaGallery = React.createClass({ mixins: [PureRenderMixin], - handleClick (index, e) { - if (e.button === 0) { - e.preventDefault(); - this.props.onOpenMedia(this.props.media, index); - } - - e.stopPropagation(); + handleOpen (e) { + this.setState({ visible: !this.state.visible }); }, - handleOpen () { - this.setState({ visible: !this.state.visible }); + handleClick (index) { + this.props.onOpenMedia(this.props.media, index); }, render () { @@ -82,87 +211,31 @@ const MediaGallery = React.createClass({ let children; if (!this.state.visible) { + let warning; + 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> - ); + warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; } 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> - ); + warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; } + + children = ( + <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}> + <span style={spoilerSpanStyle}>{warning}</span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); } else { const size = media.take(4).size; - - children = media.take(4).map((attachment, i) => { - let width = 50; - let height = 100; - let top = 'auto'; - let left = 'auto'; - let bottom = 'auto'; - let right = 'auto'; - - if (size === 1) { - width = 100; - } - - if (size === 4 || (size === 3 && i > 0)) { - height = 50; - } - - if (size === 2) { - if (i === 0) { - right = '2px'; - } else { - left = '2px'; - } - } else if (size === 3) { - if (i === 0) { - right = '2px'; - } else if (i > 0) { - left = '2px'; - } - - if (i === 1) { - bottom = '2px'; - } else if (i > 1) { - top = '2px'; - } - } else if (size === 4) { - if (i === 0 || i === 2) { - right = '2px'; - } - - if (i === 1 || i === 3) { - left = '2px'; - } - - if (i < 2) { - bottom = '2px'; - } else { - top = '2px'; - } - } - - return ( - <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}> - <a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, i)} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} /> - </div> - ); - }); + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />); } return ( <div style={{ ...outerStyle, height: `${this.props.height}px` }}> - <div style={spoilerButtonStyle} > + <div style={spoilerButtonStyle}> <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} /> </div> + {children} </div> ); diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx index 66c41b5f7..110d26c6d 100644 --- a/app/assets/javascripts/components/components/status.jsx +++ b/app/assets/javascripts/components/components/status.jsx @@ -9,7 +9,7 @@ import StatusContent from './status_content'; import StatusActionBar from './status_action_bar'; import { FormattedMessage } from 'react-intl'; import emojify from '../emoji'; -import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; +import escapeTextContentForBrowser from 'escape-html'; const Status = React.createClass({ diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx index f2cc1fb12..4ebb76ea7 100644 --- a/app/assets/javascripts/components/components/status_action_bar.jsx +++ b/app/assets/javascripts/components/components/status_action_bar.jsx @@ -6,12 +6,14 @@ import { defineMessages, injectIntl } from 'react-intl'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, - mention: { id: 'status.mention', defaultMessage: 'Mention' }, - block: { id: 'account.block', defaultMessage: 'Block' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, reblog: { id: 'status.reblog', defaultMessage: 'Reblog' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, - open: { id: 'status.open', defaultMessage: 'Expand' } + open: { id: 'status.open', defaultMessage: 'Expand this status' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' } }); const StatusActionBar = React.createClass({ @@ -27,7 +29,11 @@ const StatusActionBar = React.createClass({ onReblog: React.PropTypes.func, onDelete: React.PropTypes.func, onMention: React.PropTypes.func, - onBlock: React.PropTypes.func + onMute: React.PropTypes.func, + onBlock: React.PropTypes.func, + onReport: React.PropTypes.func, + me: React.PropTypes.number.isRequired, + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], @@ -52,6 +58,10 @@ const StatusActionBar = React.createClass({ this.props.onMention(this.props.status.get('account'), this.context.router); }, + handleMuteClick () { + this.props.onMute(this.props.status.get('account')); + }, + handleBlockClick () { this.props.onBlock(this.props.status.get('account')); }, @@ -60,23 +70,32 @@ const StatusActionBar = React.createClass({ this.context.router.push(`/statuses/${this.props.status.get('id')}`); }, + handleReport () { + this.props.onReport(this.props.status); + this.context.router.push('/report'); + }, + render () { const { status, me, intl } = this.props; let menu = []; menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); + menu.push(null); if (status.getIn(['account', 'id']) === me) { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { - menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick }); - menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick }); + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); + menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); } return ( <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 disabled={status.get('visibility') === 'private' || status.get('visibility') === 'direct'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'direct' ? 'envelope' : (status.get('visibility') === 'private' ? 'lock' : 'retweet')} onClick={this.handleReblogClick} /></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' }}> diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx index c0397e81c..6c25afdea 100644 --- a/app/assets/javascripts/components/components/status_content.jsx +++ b/app/assets/javascripts/components/components/status_content.jsx @@ -1,7 +1,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; -import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; +import escapeTextContentForBrowser from 'escape-html'; import emojify from '../emoji'; +import { isRtl } from '../rtl'; import { FormattedMessage } from 'react-intl'; import Permalink from './permalink'; @@ -92,6 +93,11 @@ const StatusContent = React.createClass({ const content = { __html: emojify(status.get('content')) }; const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) }; + const directionStyle = { direction: 'ltr' }; + + if (isRtl(status.get('content'))) { + directionStyle.direction = 'rtl'; + } if (status.get('spoiler_text').length > 0) { let mentionsPlaceholder = ''; @@ -116,14 +122,14 @@ const StatusContent = React.createClass({ {mentionsPlaceholder} - <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} /> + <div style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} /> </div> ); } else { return ( <div className='status__content' - style={{ cursor: 'pointer' }} + style={{ cursor: 'pointer', ...directionStyle }} 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 0e64f0ee6..345944e4d 100644 --- a/app/assets/javascripts/components/components/status_list.jsx +++ b/app/assets/javascripts/components/components/status_list.jsx @@ -14,7 +14,10 @@ const StatusList = React.createClass({ onScroll: React.PropTypes.func, trackScroll: React.PropTypes.bool, isLoading: React.PropTypes.bool, - prepend: React.PropTypes.node + isUnread: React.PropTypes.bool, + hasMore: React.PropTypes.bool, + prepend: React.PropTypes.node, + emptyMessage: React.PropTypes.node }, getDefaultProps () { @@ -71,27 +74,43 @@ const StatusList = React.createClass({ }, render () { - const { statusIds, onScrollToBottom, trackScroll, isLoading, prepend } = this.props; + const { statusIds, onScrollToBottom, trackScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props; - let loadMore = ''; + let loadMore = ''; + let scrollableArea = ''; + let unread = ''; - if (!isLoading && statusIds.size > 0) { + if (!isLoading && statusIds.size > 0 && hasMore) { loadMore = <LoadMore onClick={this.handleLoadMore} />; } - const scrollableArea = ( - <div className='scrollable' ref={this.setRef}> - <div> - {prepend} + if (isUnread) { + unread = <div className='status-list__unread-indicator' />; + } + + if (isLoading || statusIds.size > 0 || !emptyMessage) { + scrollableArea = ( + <div className='scrollable' ref={this.setRef}> + {unread} - {statusIds.map((statusId) => { - return <StatusContainer key={statusId} id={statusId} />; - })} + <div> + {prepend} - {loadMore} + {statusIds.map((statusId) => { + return <StatusContainer key={statusId} id={statusId} />; + })} + + {loadMore} + </div> + </div> + ); + } else { + scrollableArea = ( + <div className='empty-column-indicator' ref={this.setRef}> + {emptyMessage} </div> - </div> - ); + ); + } if (trackScroll) { return ( diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx index 3edc8f672..ab21ca9cd 100644 --- a/app/assets/javascripts/components/components/video_player.jsx +++ b/app/assets/javascripts/components/components/video_player.jsx @@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { isIOS } from '../is_mobile'; const messages = defineMessages({ toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, @@ -22,14 +23,14 @@ const muteStyle = { position: 'absolute', top: '10px', right: '10px', + color: 'white', + textShadow: "0px 1px 1px black, 1px 0px 1px black", opacity: '0.8', zIndex: '5' }; const spoilerStyle = { marginTop: '8px', - background: '#000', - color: '#fff', textAlign: 'center', height: '100%', cursor: 'pointer', @@ -55,6 +56,8 @@ const spoilerButtonStyle = { position: 'absolute', top: '6px', left: '8px', + color: 'white', + textShadow: "0px 1px 1px black, 1px 0px 1px black", zIndex: '100' }; @@ -63,12 +66,14 @@ const VideoPlayer = React.createClass({ media: ImmutablePropTypes.map.isRequired, width: React.PropTypes.number, height: React.PropTypes.number, - sensitive: React.PropTypes.bool + sensitive: React.PropTypes.bool, + intl: React.PropTypes.object.isRequired, + autoplay: React.PropTypes.bool }, getDefaultProps () { return { - width: 196, + width: 239, height: 110 }; }, @@ -77,7 +82,8 @@ const VideoPlayer = React.createClass({ return { visible: !this.props.sensitive, preview: true, - muted: true + muted: true, + hasAudio: true }; }, @@ -110,8 +116,42 @@ const VideoPlayer = React.createClass({ }); }, + setRef (c) { + this.video = c; + }, + + handleLoadedData () { + if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) { + this.setState({ hasAudio: false }); + } + }, + + componentDidMount () { + if (!this.video) { + return; + } + + this.video.addEventListener('loadeddata', this.handleLoadedData); + }, + + componentDidUpdate () { + if (!this.video) { + return; + } + + this.video.addEventListener('loadeddata', this.handleLoadedData); + }, + + componentWillUnmount () { + if (!this.video) { + return; + } + + this.video.removeEventListener('loadeddata', this.handleLoadedData); + }, + render () { - const { media, intl, width, height, sensitive } = this.props; + const { media, intl, width, height, sensitive, autoplay } = this.props; let spoilerButton = ( <div style={spoilerButtonStyle} > @@ -119,10 +159,20 @@ const VideoPlayer = React.createClass({ </div> ); + let muteButton = ''; + + if (this.state.hasAudio) { + muteButton = ( + <div style={muteStyle}> + <IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /> + </div> + ); + } + if (!this.state.visible) { if (sensitive) { return ( - <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleVisibility}> + <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' 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> @@ -130,7 +180,7 @@ const VideoPlayer = React.createClass({ ); } else { return ( - <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}> + <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}> {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> @@ -139,7 +189,7 @@ const VideoPlayer = React.createClass({ } } - if (this.state.preview) { + if (this.state.preview && !autoplay) { 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} @@ -151,8 +201,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' }}> {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} /> + {muteButton} + <video ref={this.setRef} src={media.get('url')} autoPlay={!isIOS()} 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 889c0ac4c..3c30be715 100644 --- a/app/assets/javascripts/components/containers/account_container.jsx +++ b/app/assets/javascripts/components/containers/account_container.jsx @@ -5,7 +5,9 @@ import { followAccount, unfollowAccount, blockAccount, - unblockAccount + unblockAccount, + muteAccount, + unmuteAccount, } from '../actions/accounts'; const makeMapStateToProps = () => { @@ -34,6 +36,14 @@ const mapDispatchToProps = (dispatch) => ({ } else { dispatch(blockAccount(account.get('id'))); } + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(muteAccount(account.get('id'))); + } } }); diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index e23c65121..cbb7b85bc 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -4,7 +4,9 @@ import { refreshTimelineSuccess, updateTimeline, deleteFromTimelines, - refreshTimeline + refreshTimeline, + connectTimeline, + disconnectTimeline } from '../actions/timelines'; import { updateNotifications, refreshNotifications } from '../actions/notifications'; import createBrowserHistory from 'history/lib/createBrowserHistory'; @@ -21,6 +23,7 @@ import UI from '../features/ui'; import Status from '../features/status'; import GettingStarted from '../features/getting_started'; import PublicTimeline from '../features/public_timeline'; +import CommunityTimeline from '../features/community_timeline'; import AccountTimeline from '../features/account_timeline'; import HomeTimeline from '../features/home_timeline'; import Compose from '../features/compose'; @@ -34,6 +37,7 @@ import FollowRequests from '../features/follow_requests'; import GenericNotFound from '../features/generic_not_found'; import FavouritedStatuses from '../features/favourited_statuses'; import Blocks from '../features/blocks'; +import Report from '../features/report'; import { IntlProvider, addLocaleData } from 'react-intl'; import en from 'react-intl/locale-data/en'; import de from 'react-intl/locale-data/de'; @@ -42,6 +46,7 @@ import fr from 'react-intl/locale-data/fr'; 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 fi from 'react-intl/locale-data/fi'; import getMessagesForLocale from '../locales'; import { hydrateStore } from '../actions/store'; import createStream from '../stream'; @@ -54,7 +59,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({ basename: '/web' }); -addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]); +addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...fi]); const Mastodon = React.createClass({ @@ -68,6 +73,14 @@ const Mastodon = React.createClass({ this.subscription = createStream(accessToken, 'user', { + connected () { + store.dispatch(connectTimeline('home')); + }, + + disconnected () { + store.dispatch(disconnectTimeline('home')); + }, + received (data) { switch(data.event) { case 'update': @@ -83,6 +96,7 @@ const Mastodon = React.createClass({ }, reconnected () { + store.dispatch(connectTimeline('home')); store.dispatch(refreshTimeline('home')); store.dispatch(refreshNotifications()); } @@ -115,6 +129,7 @@ const Mastodon = React.createClass({ <Route path='getting-started' component={GettingStarted} /> <Route path='timelines/home' component={HomeTimeline} /> <Route path='timelines/public' component={PublicTimeline} /> + <Route path='timelines/public/local' component={CommunityTimeline} /> <Route path='timelines/tag/:id' component={HashtagTimeline} /> <Route path='notifications' component={Notifications} /> @@ -131,6 +146,7 @@ const Mastodon = React.createClass({ <Route path='follow_requests' component={FollowRequests} /> <Route path='blocks' component={Blocks} /> + <Route path='report' component={Report} /> <Route path='*' component={GenericNotFound} /> </Route> diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx index f5fb09d52..fd3fbe4c3 100644 --- a/app/assets/javascripts/components/containers/status_container.jsx +++ b/app/assets/javascripts/components/containers/status_container.jsx @@ -11,51 +11,22 @@ import { unreblog, unfavourite } from '../actions/interactions'; -import { blockAccount } from '../actions/accounts'; +import { + blockAccount, + muteAccount +} from '../actions/accounts'; import { deleteStatus } from '../actions/statuses'; -import { openMedia } from '../actions/modal'; +import { initReport } from '../actions/reports'; +import { openModal } from '../actions/modal'; import { createSelector } from 'reselect' import { isMobile } from '../is_mobile' -const mapStateToProps = (state, props) => ({ - statusBase: state.getIn(['statuses', props.id]), - me: state.getIn(['meta', 'me']) -}); - -const makeMapStateToPropsInner = () => { - const getStatus = (() => { - return createSelector( - [ - (_, base) => base, - (state, base) => (base ? state.getIn(['accounts', base.get('account')]) : null), - (state, base) => (base ? state.getIn(['statuses', base.get('reblog')], null) : null) - ], - - (base, account, reblog) => (base ? base.set('account', account).set('reblog', reblog) : null) - ); - })(); - - const mapStateToProps = (state, { statusBase }) => ({ - status: getStatus(state, statusBase) - }); - - return mapStateToProps; -}; - -const makeMapStateToPropsLast = () => { - const getStatus = (() => { - return createSelector( - [ - (_, status) => status, - (state, status) => (status ? state.getIn(['accounts', status.getIn(['reblog', 'account'])], null) : null) - ], +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); - (status, reblogAccount) => (status && status.get('reblog') ? status.setIn(['reblog', 'account'], reblogAccount) : status) - ); - })(); - - const mapStateToProps = (state, { status }) => ({ - status: getStatus(state, status) + const mapStateToProps = (state, props) => ({ + status: getStatus(state, props.id), + me: state.getIn(['meta', 'me']) }); return mapStateToProps; @@ -92,17 +63,21 @@ const mapDispatchToProps = (dispatch) => ({ }, onOpenMedia (media, index) { - dispatch(openMedia(media, index)); + dispatch(openModal('MEDIA', { media, index })); }, onBlock (account) { dispatch(blockAccount(account.get('id'))); - } + }, + + onReport (status) { + dispatch(initReport(status.get('account'), status)); + }, + + onMute (account) { + dispatch(muteAccount(account.get('id'))); + }, }); -export default connect(mapStateToProps, mapDispatchToProps)( - connect(makeMapStateToPropsInner)( - connect(makeMapStateToPropsLast)(Status) - ) -); +export default connect(makeMapStateToProps, mapDispatchToProps)(Status); diff --git a/app/assets/javascripts/components/emoji.jsx b/app/assets/javascripts/components/emoji.jsx index c93c07c74..eee657b86 100644 --- a/app/assets/javascripts/components/emoji.jsx +++ b/app/assets/javascripts/components/emoji.jsx @@ -1,9 +1,35 @@ import emojione from 'emojione'; -emojione.imageType = 'png'; -emojione.sprites = false; -emojione.imagePathPNG = '/emoji/'; +const toImage = str => shortnameToImage(unicodeToImage(str)); + +const unicodeToImage = str => { + const mappedUnicode = emojione.mapUnicodeToShort(); + + return str.replace(emojione.regUnicode, unicodeChar => { + if (typeof unicodeChar === 'undefined' || unicodeChar === '' || !(unicodeChar in emojione.jsEscapeMap)) { + return unicodeChar; + } + + const unicode = emojione.jsEscapeMap[unicodeChar]; + const short = mappedUnicode[unicode]; + const filename = emojione.emojioneList[short].fname; + const alt = emojione.convert(unicode.toUpperCase()); + + return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${filename}.svg" />`; + }); +}; + +const shortnameToImage = str => str.replace(emojione.regShortNames, shortname => { + if (typeof shortname === 'undefined' || shortname === '' || !(shortname in emojione.emojioneList)) { + return shortname; + } + + const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1]; + const alt = emojione.convert(unicode.toUpperCase()); + + return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${unicode}.svg" />`; +}); export default function emojify(text) { - return emojione.toImage(text); + return 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 fe110954d..80a32d3e2 100644 --- a/app/assets/javascripts/components/features/account/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx @@ -5,13 +5,16 @@ import { Link } from 'react-router'; import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; const messages = defineMessages({ - mention: { id: 'account.mention', defaultMessage: 'Mention' }, + mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, - unblock: { id: 'account.unblock', defaultMessage: 'Unblock' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - block: { id: 'account.block', defaultMessage: 'Block' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, - block: { id: 'account.block', defaultMessage: 'Block' } + report: { id: 'account.report', defaultMessage: 'Report @{name}' }, + disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' } }); const outerDropdownStyle = { @@ -32,7 +35,10 @@ const ActionBar = React.createClass({ me: React.PropTypes.number.isRequired, onFollow: React.PropTypes.func, onBlock: React.PropTypes.func.isRequired, - onMention: React.PropTypes.func.isRequired + onMention: React.PropTypes.func.isRequired, + onReport: React.PropTypes.func.isRequired, + onMute: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], @@ -41,17 +47,31 @@ const ActionBar = React.createClass({ const { account, me, intl } = this.props; let menu = []; + let extraInfo = ''; - menu.push({ text: intl.formatMessage(messages.mention), action: this.props.onMention }); + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); + menu.push(null); if (account.get('id') === me) { menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); - } else if (account.getIn(['relationship', 'blocking'])) { - menu.push({ text: intl.formatMessage(messages.unblock), action: this.props.onBlock }); - } else if (account.getIn(['relationship', 'following'])) { - menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock }); } else { - menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock }); + if (account.getIn(['relationship', 'muting'])) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); + } else { + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute }); + } + + if (account.getIn(['relationship', 'blocking'])) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock }); + } + + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport }); + } + + if (account.get('acct') !== account.get('username')) { + extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>; } return ( @@ -63,17 +83,17 @@ const ActionBar = React.createClass({ <div style={outerLinksStyle}> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span> - <strong><FormattedNumber value={account.get('statuses_count')} /></strong> + <strong><FormattedNumber value={account.get('statuses_count')} /> {extraInfo}</strong> </Link> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span> - <strong><FormattedNumber value={account.get('following_count')} /></strong> + <strong><FormattedNumber value={account.get('following_count')} /> {extraInfo}</strong> </Link> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span> - <strong><FormattedNumber value={account.get('followers_count')} /></strong> + <strong><FormattedNumber value={account.get('followers_count')} /> {extraInfo}</strong> </Link> </div> </div> diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx index b2d943c1c..a359963c4 100644 --- a/app/assets/javascripts/components/features/account/components/header.jsx +++ b/app/assets/javascripts/components/features/account/components/header.jsx @@ -1,9 +1,10 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import emojify from '../../../emoji'; -import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; +import escapeTextContentForBrowser from 'escape-html'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import IconButton from '../../../components/icon_button'; +import { Motion, spring } from 'react-motion'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, @@ -11,10 +12,51 @@ const messages = defineMessages({ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' } }); +const Avatar = React.createClass({ + + propTypes: { + account: ImmutablePropTypes.map.isRequired + }, + + getInitialState () { + return { + isHovered: false + }; + }, + + mixins: [PureRenderMixin], + + handleMouseOver () { + if (this.state.isHovered) return; + this.setState({ isHovered: true }); + }, + + handleMouseOut () { + if (!this.state.isHovered) return; + this.setState({ isHovered: false }); + }, + + render () { + const { account } = this.props; + const { isHovered } = this.state; + + return ( + <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}> + {({ radius }) => + <a href={account.get('url')} className='account__header__avatar' target='_blank' rel='noopener' style={{ display: 'block', width: '90px', height: '90px', margin: '0 auto', marginBottom: '10px', borderRadius: `${radius}px`, overflow: 'hidden' }} onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}> + <img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} /> + </a> + } + </Motion> + ); + } + +}); + const Header = React.createClass({ propTypes: { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.map, me: React.PropTypes.number.isRequired, onFollow: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired @@ -25,6 +67,10 @@ const Header = React.createClass({ render () { const { account, me, intl } = this.props; + if (!account) { + return null; + } + let displayName = account.get('display_name'); let info = ''; let actionBtn = ''; @@ -35,7 +81,7 @@ const Header = React.createClass({ } if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { - info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span> + info = <span className='account--follows-info' style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span> } if (me !== account.get('id')) { @@ -64,14 +110,9 @@ const Header = React.createClass({ return ( <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> <div style={{ padding: '20px 10px' }}> - <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}> - <div className='account__header__avatar' style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}> - <img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} /> - </div> - - <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> - </a> + <Avatar account={account} /> + <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> <span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span> <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> diff --git a/app/assets/javascripts/components/features/account_timeline/components/header.jsx b/app/assets/javascripts/components/features/account_timeline/components/header.jsx index ff3e8af2d..99a10562e 100644 --- a/app/assets/javascripts/components/features/account_timeline/components/header.jsx +++ b/app/assets/javascripts/components/features/account_timeline/components/header.jsx @@ -2,6 +2,7 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import InnerHeader from '../../account/components/header'; import ActionBar from '../../account/components/action_bar'; +import MissingIndicator from '../../../components/missing_indicator'; const Header = React.createClass({ contextTypes: { @@ -9,11 +10,13 @@ const Header = React.createClass({ }, propTypes: { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.map, me: React.PropTypes.number.isRequired, onFollow: React.PropTypes.func.isRequired, onBlock: React.PropTypes.func.isRequired, - onMention: React.PropTypes.func.isRequired + onMention: React.PropTypes.func.isRequired, + onReport: React.PropTypes.func.isRequired, + onMute: React.PropTypes.func.isRequired }, mixins: [PureRenderMixin], @@ -30,11 +33,20 @@ const Header = React.createClass({ this.props.onMention(this.props.account, this.context.router); }, + handleReport () { + this.props.onReport(this.props.account); + this.context.router.push('/report'); + }, + + handleMute() { + this.props.onMute(this.props.account); + }, + render () { const { account, me } = this.props; - if (!account) { - return null; + if (account === null) { + return <MissingIndicator />; } return ( @@ -50,6 +62,8 @@ const Header = React.createClass({ me={me} onBlock={this.handleBlock} onMention={this.handleMention} + onReport={this.handleReport} + onMute={this.handleMute} /> </div> ); diff --git a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx index dca826596..8472d25a5 100644 --- a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx +++ b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx @@ -5,9 +5,12 @@ import { followAccount, unfollowAccount, blockAccount, - unblockAccount + unblockAccount, + muteAccount, + unmuteAccount } from '../../../actions/accounts'; import { mentionCompose } from '../../../actions/compose'; +import { initReport } from '../../../actions/reports'; const makeMapStateToProps = () => { const getAccount = makeGetAccount(); @@ -39,6 +42,18 @@ const mapDispatchToProps = dispatch => ({ onMention (account, router) { dispatch(mentionCompose(account, router)); + }, + + onReport (account) { + dispatch(initReport(account)); + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(muteAccount(account.get('id'))); + } } }); diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx index 349510295..f92e1b49c 100644 --- a/app/assets/javascripts/components/features/account_timeline/index.jsx +++ b/app/assets/javascripts/components/features/account_timeline/index.jsx @@ -16,6 +16,7 @@ import Immutable from 'immutable'; const mapStateToProps = (state, props) => ({ statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items'], Immutable.List()), isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']), + hasMore: !!state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'next']), me: state.getIn(['meta', 'me']) }); @@ -26,6 +27,7 @@ const AccountTimeline = React.createClass({ dispatch: React.PropTypes.func.isRequired, statusIds: ImmutablePropTypes.list, isLoading: React.PropTypes.bool, + hasMore: React.PropTypes.bool, me: React.PropTypes.number.isRequired }, @@ -48,7 +50,7 @@ const AccountTimeline = React.createClass({ }, render () { - const { statusIds, isLoading, me } = this.props; + const { statusIds, isLoading, hasMore, me } = this.props; if (!statusIds && isLoading) { return ( @@ -66,6 +68,7 @@ const AccountTimeline = React.createClass({ prepend={<HeaderContainer accountId={this.props.params.accountId} />} statusIds={statusIds} isLoading={isLoading} + hasMore={hasMore} me={me} onScrollToBottom={this.handleScrollToBottom} /> diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx new file mode 100644 index 000000000..0957338cf --- /dev/null +++ b/app/assets/javascripts/components/features/community_timeline/index.jsx @@ -0,0 +1,95 @@ +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, + updateTimeline, + deleteFromTimelines, + connectTimeline, + disconnectTimeline +} from '../../actions/timelines'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import createStream from '../../stream'; + +const messages = defineMessages({ + title: { id: 'column.community', defaultMessage: 'Local' } +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, + accessToken: state.getIn(['meta', 'access_token']) +}); + +let subscription; + +const CommunityTimeline = React.createClass({ + + propTypes: { + dispatch: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired, + accessToken: React.PropTypes.string.isRequired, + hasUnread: React.PropTypes.bool + }, + + mixins: [PureRenderMixin], + + componentDidMount () { + const { dispatch, accessToken } = this.props; + + dispatch(refreshTimeline('community')); + + if (typeof subscription !== 'undefined') { + return; + } + + subscription = createStream(accessToken, 'public:local', { + + connected () { + dispatch(connectTimeline('community')); + }, + + reconnected () { + dispatch(connectTimeline('community')); + }, + + disconnected () { + dispatch(disconnectTimeline('community')); + }, + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline('community', JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + } + } + + }); + }, + + componentWillUnmount () { + // if (typeof subscription !== 'undefined') { + // subscription.close(); + // subscription = null; + // } + }, + + render () { + const { intl, hasUnread } = this.props; + + return ( + <Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}> + <ColumnBackButtonSlim /> + <StatusListContainer type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} /> + </Column> + ); + }, + +}); + +export default connect(mapStateToProps)(injectIntl(CommunityTimeline)); diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx index 9ea7f190f..5591b45cf 100644 --- a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx +++ b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx @@ -1,11 +1,16 @@ import Avatar from '../../../components/avatar'; import DisplayName from '../../../components/display_name'; +import ImmutablePropTypes from 'react-immutable-proptypes'; const AutosuggestAccount = ({ account }) => ( - <div style={{ overflow: 'hidden' }}> + <div style={{ overflow: 'hidden' }} className='autosuggest-account'> <div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div> <DisplayName account={account} /> </div> ); +AutosuggestAccount.propTypes = { + account: ImmutablePropTypes.map.isRequired +}; + export default AutosuggestAccount; diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx new file mode 100644 index 000000000..086488649 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx @@ -0,0 +1,15 @@ +import { FormattedMessage } from 'react-intl'; +import DisplayName from '../../../components/display_name'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const AutosuggestStatus = ({ status }) => ( + <div style={{ overflow: 'hidden' }} className='autosuggest-status'> + <FormattedMessage id='search.status_by' defaultMessage='Status by {name}' values={{ name: <strong>@{status.getIn(['account', 'acct'])}</strong> }} /> + </div> +); + +AutosuggestStatus.propTypes = { + status: ImmutablePropTypes.map.isRequired +}; + +export default AutosuggestStatus; diff --git a/app/assets/javascripts/components/features/compose/components/character_counter.jsx b/app/assets/javascripts/components/features/compose/components/character_counter.jsx index f0c1b7c8d..e6b675354 100644 --- a/app/assets/javascripts/components/features/compose/components/character_counter.jsx +++ b/app/assets/javascripts/components/features/compose/components/character_counter.jsx @@ -10,7 +10,7 @@ const CharacterCounter = React.createClass({ mixins: [PureRenderMixin], render () { - const diff = this.props.max - this.props.text.length; + const diff = this.props.max - this.props.text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length; return ( <span style={{ fontSize: '16px', cursor: 'default' }}> 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 46b62964a..b016d3f28 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -2,15 +2,19 @@ import CharacterCounter from './character_counter'; import Button from '../../../components/button'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import ReplyIndicator from './reply_indicator'; -import UploadButton from './upload_button'; +import ReplyIndicatorContainer from '../containers/reply_indicator_container'; import AutosuggestTextarea from '../../../components/autosuggest_textarea'; -import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container'; import { debounce } from 'react-decoration'; import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; -import { Motion, spring } from 'react-motion'; +import Collapsable from '../../../components/collapsable'; +import SpoilerButtonContainer from '../containers/spoiler_button_container'; +import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import SensitiveButtonContainer from '../containers/sensitive_button_container'; +import EmojiPickerDropdown from './emoji_picker_dropdown'; +import UploadFormContainer from '../containers/upload_form_container'; +import TextIconButton from './text_icon_button'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, @@ -25,28 +29,24 @@ const ComposeForm = React.createClass({ text: React.PropTypes.string.isRequired, suggestion_token: React.PropTypes.string, suggestions: ImmutablePropTypes.list, - sensitive: React.PropTypes.bool, spoiler: React.PropTypes.bool, + privacy: React.PropTypes.string, spoiler_text: React.PropTypes.string, - unlisted: React.PropTypes.bool, - private: React.PropTypes.bool, - fileDropDate: React.PropTypes.instanceOf(Date), + focusDate: React.PropTypes.instanceOf(Date), + preselectDate: React.PropTypes.instanceOf(Date), is_submitting: React.PropTypes.bool, is_uploading: React.PropTypes.bool, - in_reply_to: ImmutablePropTypes.map, - media_count: React.PropTypes.number, me: React.PropTypes.number, + needsPrivacyWarning: React.PropTypes.bool, + mentionedDomains: React.PropTypes.array.isRequired, onChange: React.PropTypes.func.isRequired, onSubmit: React.PropTypes.func.isRequired, - onCancelReply: React.PropTypes.func.isRequired, onClearSuggestions: React.PropTypes.func.isRequired, 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, + onPaste: React.PropTypes.func.isRequired, + onPickEmoji: React.PropTypes.func.isRequired }, mixins: [PureRenderMixin], @@ -75,37 +75,31 @@ const ComposeForm = React.createClass({ }, onSuggestionSelected (tokenStart, token, value) { + this._restoreCaret = null; this.props.onSuggestionSelected(tokenStart, token, value); }, - handleChangeSensitivity (e) { - 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); - }, - - handleChangeListability (e) { - this.props.onChangeListability(e.target.checked); - }, - componentDidUpdate (prevProps) { - 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 (this.props.focusDate !== prevProps.focusDate) { // 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; + let selectionEnd, selectionStart; + + if (this.props.preselectDate !== prevProps.preselectDate) { + selectionEnd = this.props.text.length; + selectionStart = this.props.text.search(/\s/) + 1; + } else if (typeof this._restoreCaret === 'number') { + selectionStart = this._restoreCaret; + selectionEnd = this._restoreCaret; + } else { + selectionEnd = this.props.text.length; + selectionStart = selectionEnd; + } this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); this.autosuggestTextarea.textarea.focus(); @@ -116,83 +110,85 @@ const ComposeForm = React.createClass({ this.autosuggestTextarea = c; }, - render () { - const { intl } = this.props; - let replyArea = ''; - let publishText = ''; - const disabled = this.props.is_submitting || this.props.is_uploading; + handleEmojiPick (data) { + const position = this.autosuggestTextarea.textarea.selectionStart; + this._restoreCaret = position + data.shortname.length + 1; + this.props.onPickEmoji(position, data); + }, - if (this.props.in_reply_to) { - replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />; + render () { + const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props; + const disabled = this.props.is_submitting || this.props.is_uploading; + + let publishText = ''; + let privacyWarning = ''; + let reply_to_other = false; + + if (needsPrivacyWarning) { + privacyWarning = ( + <div className='compose-form__warning'> + <FormattedMessage + id='compose_form.privacy_disclaimer' + defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?' + values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }} + /> + </div> + ); } - let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me); - - if (this.props.private) { + if (this.props.privacy === 'private' || this.props.privacy === 'direct') { publishText = <span><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; } else { - publishText = intl.formatMessage(messages.publish) + (!this.props.unlisted ? '!' : ''); + publishText = intl.formatMessage(messages.publish) + (this.props.privacy !== 'unlisted' ? '!' : ''); } 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 - ref={this.setAutosuggestTextarea} - placeholder={intl.formatMessage(messages.placeholder)} - disabled={disabled} - fileDropDate={this.props.fileDropDate} - value={this.props.text} - onChange={this.handleChange} - suggestions={this.props.suggestions} - onKeyDown={this.handleKeyDown} - onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} - onSuggestionsClearRequested={this.onSuggestionsClearRequested} - onSuggestionSelected={this.onSuggestionSelected} - /> - - <div style={{ marginTop: '10px', overflow: 'hidden' }}> - <div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></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' }} /> + <Collapsable isVisible={this.props.spoiler} fullHeight={50}> + <div className="spoiler-input"> + <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type="text" className="spoiler-input__input" /> + </div> + </Collapsable> + + {privacyWarning} + + <ReplyIndicatorContainer /> + + <div style={{ position: 'relative' }}> + <AutosuggestTextarea + ref={this.setAutosuggestTextarea} + placeholder={intl.formatMessage(messages.placeholder)} + disabled={disabled} + value={this.props.text} + onChange={this.handleChange} + suggestions={this.props.suggestions} + onKeyDown={this.handleKeyDown} + onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} + onSuggestionsClearRequested={this.onSuggestionsClearRequested} + onSuggestionSelected={this.onSuggestionSelected} + onPaste={onPaste} + /> + + <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> + </div> + + <div className='compose-form__modifiers'> + <UploadFormContainer /> </div> - <label className='compose-form__label with-border' style={{ marginTop: '10px' }}> - <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} /> - <span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span> - </label> - - <label className='compose-form__label with-border'> - <Toggle checked={this.props.private} onChange={this.handleChangeVisibility} /> - <span className='compose-form__label__text'><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></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 className='compose-form__label' style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}> - <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} /> - <span className='compose-form__label__text'><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display in public timeline' /></span> - </label> - } - </Motion> - - <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(this.props.media_count === 0 ? 0 : 100), height: spring(this.props.media_count === 0 ? 0 : 39.5) }}> - {({ opacity, height }) => - <label className='compose-form__label' style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}> - <Toggle checked={this.props.sensitive} onChange={this.handleChangeSensitivity} /> - <span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span> - </label> - } - </Motion> + <div style={{ display: 'flex', justifyContent: 'space-between' }}> + <div className='compose-form__buttons'> + <UploadButtonContainer /> + <PrivacyDropdownContainer /> + <SensitiveButtonContainer /> + <SpoilerButtonContainer /> + </div> + + <div style={{ display: 'flex' }}> + <div style={{ paddingTop: '10px', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div> + <div style={{ paddingTop: '10px' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div> + </div> + </div> </div> ); } diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx deleted file mode 100644 index 83f3fa27d..000000000 --- a/app/assets/javascripts/components/features/compose/components/drawer.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Link } from 'react-router'; -import { injectIntl, defineMessages } from 'react-intl'; - -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 Drawer = ({ children, withHeader, intl }) => { - let header = ''; - - if (withHeader) { - header = ( - <div className='drawer__header'> - <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> - <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link> - <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a> - <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a> - </div> - ); - } - - return ( - <div className='drawer'> - {header} - - <div className='drawer__inner'> - {children} - </div> - </div> - ); -}; - -Drawer.propTypes = { - withHeader: React.PropTypes.bool, - children: React.PropTypes.node, - intl: React.PropTypes.object -}; - -export default injectIntl(Drawer); diff --git a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx new file mode 100644 index 000000000..1920b29bf --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx @@ -0,0 +1,58 @@ +import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; +import EmojiPicker from 'emojione-picker'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' } +}); + +const settings = { + imageType: 'png', + sprites: false, + imagePathPNG: '/emoji/' +}; + +const style = { + position: 'absolute', + right: '5px', + top: '5px' +}; + +const EmojiPickerDropdown = React.createClass({ + + propTypes: { + intl: React.PropTypes.object.isRequired, + onPickEmoji: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + setRef (c) { + this.dropdown = c; + }, + + handleChange (data) { + this.dropdown.hide(); + this.props.onPickEmoji(data); + }, + + render () { + const { intl } = this.props; + + return ( + <Dropdown ref={this.setRef} style={style}> + <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)} style={{ fontSize: `24px`, width: `24px`, lineHeight: `24px`, display: 'block', marginLeft: '2px' }}> + <img draggable="false" className="emojione" alt="🙂" src="/emoji/1f602.svg" /> + </DropdownTrigger> + + <DropdownContent className='dropdown__left'> + <EmojiPicker emojione={settings} onChange={this.handleChange} /> + </DropdownContent> + </Dropdown> + ); + } + +}); + +export default injectIntl(EmojiPickerDropdown); diff --git a/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx new file mode 100644 index 000000000..e54fa4d28 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx @@ -0,0 +1,101 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { injectIntl, defineMessages } from 'react-intl'; +import IconButton from '../../../components/icon_button'; + +const messages = defineMessages({ + public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, + public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, + unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' }, + private_short: { id: 'privacy.private.short', defaultMessage: 'Private' }, + private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, + direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, + direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, + change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' } +}); + +const iconStyle = { + lineHeight: '27px', + height: null +}; + +const PrivacyDropdown = React.createClass({ + + propTypes: { + value: React.PropTypes.string.isRequired, + onChange: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired + }, + + getInitialState () { + return { + open: false + }; + }, + + mixins: [PureRenderMixin], + + handleToggle () { + this.setState({ open: !this.state.open }); + }, + + handleClick (value, e) { + e.preventDefault(); + this.setState({ open: false }); + this.props.onChange(value); + }, + + onGlobalClick (e) { + if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { + this.setState({ open: false }); + } + }, + + componentDidMount () { + window.addEventListener('click', this.onGlobalClick); + window.addEventListener('touchstart', this.onGlobalClick); + }, + + componentWillUnmount () { + window.removeEventListener('click', this.onGlobalClick); + window.removeEventListener('touchstart', this.onGlobalClick); + }, + + setRef (c) { + this.node = c; + }, + + render () { + const { value, onChange, intl } = this.props; + const { open } = this.state; + + const options = [ + { icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) }, + { icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) }, + { icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) }, + { icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) } + ]; + + const valueOption = options.find(item => item.value === value); + + return ( + <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> + <div className='privacy-dropdown__value'><IconButton icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> + <div className='privacy-dropdown__dropdown'> + {options.map(item => + <div key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> + <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div> + <div className='privacy-dropdown__option__content'> + <strong>{item.shortText}</strong> + {item.longText} + </div> + </div> + )} + </div> + </div> + ); + } + +}); + +export default injectIntl(PrivacyDropdown); diff --git a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx index 73e5ee99e..a72bd32c2 100644 --- a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx +++ b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx @@ -17,7 +17,7 @@ const ReplyIndicator = React.createClass({ }, propTypes: { - status: ImmutablePropTypes.map.isRequired, + status: ImmutablePropTypes.map, onCancel: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired }, @@ -36,17 +36,22 @@ const ReplyIndicator = React.createClass({ }, render () { - const { intl } = this.props; - const content = { __html: emojify(this.props.status.get('content')) }; + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const content = { __html: emojify(status.get('content')) }; return ( <div className='reply-indicator'> <div style={{ overflow: 'hidden', marginBottom: '5px' }}> <div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> - <a href={this.props.status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}> - <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={this.props.status.getIn(['account', 'avatar'])} /></div> - <DisplayName account={this.props.status.get('account')} /> + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}> + <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} /></div> + <DisplayName account={status.get('account')} /> </a> </div> diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx index c1f23939d..936e003f2 100644 --- a/app/assets/javascripts/components/features/compose/components/search.jsx +++ b/app/assets/javascripts/components/features/compose/components/search.jsx @@ -1,118 +1,68 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Autosuggest from 'react-autosuggest'; -import AutosuggestAccountContainer from '../containers/autosuggest_account_container'; -import { debounce } from 'react-decoration'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; const messages = defineMessages({ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' } }); -const getSuggestionValue = suggestion => suggestion.value; - -const renderSuggestion = suggestion => { - if (suggestion.type === 'account') { - return <AutosuggestAccountContainer id={suggestion.id} />; - } else { - return <span>#{suggestion.id}</span> - } -}; - -const renderSectionTitle = section => ( - <strong><FormattedMessage id={`search.${section.title}`} defaultMessage={section.title} /></strong> -); - -const getSectionSuggestions = section => section.items; - -const outerStyle = { - padding: '10px', - lineHeight: '20px', - position: 'relative' -}; - -const iconStyle = { - position: 'absolute', - top: '18px', - right: '20px', - fontSize: '18px', - pointerEvents: 'none' -}; - const Search = React.createClass({ - contextTypes: { - router: React.PropTypes.object - }, - propTypes: { - suggestions: React.PropTypes.array.isRequired, value: React.PropTypes.string.isRequired, + submitted: React.PropTypes.bool, onChange: React.PropTypes.func.isRequired, + onSubmit: React.PropTypes.func.isRequired, onClear: React.PropTypes.func.isRequired, - onFetch: React.PropTypes.func.isRequired, - onReset: React.PropTypes.func.isRequired, + onShow: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], - onChange (_, { newValue }) { - if (typeof newValue !== 'string') { - return; - } - - this.props.onChange(newValue); + handleChange (e) { + this.props.onChange(e.target.value); }, - onSuggestionsClearRequested () { + handleClear (e) { + e.preventDefault(); this.props.onClear(); }, - @debounce(500) - onSuggestionsFetchRequested ({ value }) { - value = value.replace('#', ''); - this.props.onFetch(value.trim()); + handleKeyDown (e) { + if (e.key === 'Enter') { + e.preventDefault(); + this.props.onSubmit(); + } }, - onSuggestionSelected (_, { suggestion }) { - if (suggestion.type === 'account') { - this.context.router.push(`/accounts/${suggestion.id}`); - } else { - this.context.router.push(`/timelines/tag/${suggestion.id}`); - } + handleFocus () { + this.props.onShow(); }, render () { - const inputProps = { - placeholder: this.props.intl.formatMessage(messages.placeholder), - value: this.props.value, - onChange: this.onChange, - className: 'search__input' - }; + const { intl, value, submitted } = this.props; + const hasValue = value.length > 0 || submitted; return ( - <div className='search' style={outerStyle}> - <Autosuggest - multiSection={true} - suggestions={this.props.suggestions} - focusFirstSuggestion={true} - focusInputOnSuggestionClick={false} - alwaysRenderSuggestions={false} - onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} - onSuggestionsClearRequested={this.onSuggestionsClearRequested} - onSuggestionSelected={this.onSuggestionSelected} - getSuggestionValue={getSuggestionValue} - renderSuggestion={renderSuggestion} - renderSectionTitle={renderSectionTitle} - getSectionSuggestions={getSectionSuggestions} - inputProps={inputProps} + <div className='search'> + <input + className='search__input' + type='text' + placeholder={intl.formatMessage(messages.placeholder)} + value={value} + onChange={this.handleChange} + onKeyUp={this.handleKeyDown} + onFocus={this.handleFocus} /> - <div style={iconStyle}><i className='fa fa-search' /></div> + <div className='search__icon'> + <i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> + <i className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} onClick={this.handleClear} /> + </div> </div> ); - }, + } }); diff --git a/app/assets/javascripts/components/features/compose/components/search_results.jsx b/app/assets/javascripts/components/features/compose/components/search_results.jsx new file mode 100644 index 000000000..fd05e7f7e --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/search_results.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 AccountContainer from '../../../containers/account_container'; +import StatusContainer from '../../../containers/status_container'; +import { Link } from 'react-router'; + +const SearchResults = React.createClass({ + + propTypes: { + results: ImmutablePropTypes.map.isRequired + }, + + mixins: [PureRenderMixin], + + render () { + const { results } = this.props; + + let accounts, statuses, hashtags; + let count = 0; + + if (results.get('accounts') && results.get('accounts').size > 0) { + count += results.get('accounts').size; + accounts = ( + <div className='search-results__section'> + {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} + </div> + ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + count += results.get('statuses').size; + statuses = ( + <div className='search-results__section'> + {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} + </div> + ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + count += results.get('hashtags').size; + hashtags = ( + <div className='search-results__section'> + {results.get('hashtags').map(hashtag => + <Link className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}> + #{hashtag} + </Link> + )} + </div> + ); + } + + return ( + <div className='search-results'> + <div className='search-results__header'> + <FormattedMessage id='search_results.total' defaultMessage='{count} {count, plural, one {result} other {results}}' values={{ count }} /> + </div> + + {accounts} + {statuses} + {hashtags} + </div> + ); + } + +}); + +export default SearchResults; diff --git a/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx b/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx new file mode 100644 index 000000000..e3ac63d87 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx @@ -0,0 +1,31 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; + +const TextIconButton = React.createClass({ + + propTypes: { + label: React.PropTypes.string.isRequired, + title: React.PropTypes.string, + active: React.PropTypes.bool, + onClick: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + handleClick (e) { + e.preventDefault(); + this.props.onClick(); + }, + + render () { + const { label, title, active } = this.props; + + return ( + <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} onClick={this.handleClick}> + {label} + </button> + ); + } + +}); + +export default TextIconButton; 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 4c8181aa1..2ba0e8fd2 100644 --- a/app/assets/javascripts/components/features/compose/components/upload_button.jsx +++ b/app/assets/javascripts/components/features/compose/components/upload_button.jsx @@ -6,6 +6,11 @@ const messages = defineMessages({ upload: { id: 'upload_button.label', defaultMessage: 'Add media' } }); +const iconStyle = { + lineHeight: '27px', + height: null +}; + const UploadButton = React.createClass({ propTypes: { @@ -37,7 +42,7 @@ const UploadButton = React.createClass({ return ( <div style={this.props.style}> - <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} /> + <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} style={iconStyle} size={18} inverted /> <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 94c94b4b7..77590d90d 100644 --- a/app/assets/javascripts/components/features/compose/components/upload_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/upload_form.jsx @@ -2,6 +2,8 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import IconButton from '../../../components/icon_button'; import { defineMessages, injectIntl } from 'react-intl'; +import UploadProgressContainer from '../containers/upload_progress_container'; +import { Motion, spring } from 'react-motion'; const messages = defineMessages({ undo: { id: 'upload_form.undo', defaultMessage: 'Undo' } @@ -11,7 +13,6 @@ const UploadForm = React.createClass({ propTypes: { media: ImmutablePropTypes.list.isRequired, - is_uploading: React.PropTypes.bool, onRemoveFile: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired }, @@ -21,21 +22,22 @@ const UploadForm = React.createClass({ render () { const { intl, media } = this.props; - 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'))} /> - </div> + const uploads = media.map(attachment => + <div key={attachment.get('id')} style={{ margin: '5px', flex: '1 1 0' }}> + <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> + {({ scale }) => + <div style={{ transform: `translateZ(0) scale(${scale})`, 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'))} /> + </div> + } + </Motion> </div> - )); + ); return ( - <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}> - {uploads} + <div style={{ overflow: 'hidden' }}> + <UploadProgressContainer /> + <div style={{ display: 'flex', padding: '5px' }}>{uploads}</div> </div> ); } diff --git a/app/assets/javascripts/components/features/compose/components/upload_progress.jsx b/app/assets/javascripts/components/features/compose/components/upload_progress.jsx new file mode 100644 index 000000000..86ffbf936 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/upload_progress.jsx @@ -0,0 +1,44 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { Motion, spring } from 'react-motion'; +import { FormattedMessage } from 'react-intl'; + +const UploadProgress = React.createClass({ + + propTypes: { + active: React.PropTypes.bool, + progress: React.PropTypes.number + }, + + mixins: [PureRenderMixin], + + render () { + const { active, progress } = this.props; + + if (!active) { + return null; + } + + return ( + <div className='upload-progress'> + <div> + <i className='fa fa-upload' /> + </div> + + <div style={{ flex: '1 1 auto' }}> + <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' /> + + <div className='upload-progress__backdrop'> + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> + {({ width }) => + <div className='upload-progress__tracker' style={{ width: `${width}%` }} /> + } + </Motion> + </div> + </div> + </div> + ); + } + +}); + +export default UploadProgress; diff --git a/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx b/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx new file mode 100644 index 000000000..ef46eb09c --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import AutosuggestStatus from '../components/autosuggest_status'; +import { makeGetStatus } from '../../../selectors'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, { id }) => ({ + status: getStatus(state, id) + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(AutosuggestStatus); 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 c027875cd..604e1182f 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 @@ -1,91 +1,78 @@ import { connect } from 'react-redux'; import ComposeForm from '../components/compose_form'; +import { uploadCompose } from '../../../actions/compose'; +import { createSelector } from 'reselect'; import { changeCompose, submitCompose, - cancelReplyCompose, clearComposeSuggestions, fetchComposeSuggestions, selectComposeSuggestion, - changeComposeSensitivity, - changeComposeSpoilerness, changeComposeSpoilerText, - changeComposeVisibility, - changeComposeListability + insertEmojiCompose } from '../../../actions/compose'; -import { makeGetStatus } from '../../../selectors'; -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); +const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig)); - const mapStateToProps = function (state, props) { - return { - text: state.getIn(['compose', 'text']), - 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, - me: state.getIn(['compose', 'me']), - }; - }; +const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => { + return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []; +}); - return mapStateToProps; -}; +const mapStateToProps = (state, props) => { + const mentionedUsernames = getMentionedUsernames(state); + const mentionedUsernamesWithDomains = getMentionedDomains(state); -const mapDispatchToProps = function (dispatch) { return { - onChange (text) { - dispatch(changeCompose(text)); - }, + text: state.getIn(['compose', 'text']), + suggestion_token: state.getIn(['compose', 'suggestion_token']), + suggestions: state.getIn(['compose', 'suggestions']), + spoiler: state.getIn(['compose', 'spoiler']), + spoiler_text: state.getIn(['compose', 'spoiler_text']), + privacy: state.getIn(['compose', 'privacy']), + focusDate: state.getIn(['compose', 'focusDate']), + preselectDate: state.getIn(['compose', 'preselectDate']), + is_submitting: state.getIn(['compose', 'is_submitting']), + is_uploading: state.getIn(['compose', 'is_uploading']), + me: state.getIn(['compose', 'me']), + needsPrivacyWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null, + mentionedDomains: mentionedUsernamesWithDomains + }; +}; - onSubmit () { - dispatch(submitCompose()); - }, +const mapDispatchToProps = (dispatch) => ({ - onCancelReply () { - dispatch(cancelReplyCompose()); - }, + onChange (text) { + dispatch(changeCompose(text)); + }, - onClearSuggestions () { - dispatch(clearComposeSuggestions()); - }, + onSubmit () { + dispatch(submitCompose()); + }, - onFetchSuggestions (token) { - dispatch(fetchComposeSuggestions(token)); - }, + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, - onSuggestionSelected (position, token, accountId) { - dispatch(selectComposeSuggestion(position, token, accountId)); - }, + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, - onChangeSensitivity (checked) { - dispatch(changeComposeSensitivity(checked)); - }, + onSuggestionSelected (position, token, accountId) { + dispatch(selectComposeSuggestion(position, token, accountId)); + }, - onChangeSpoilerness (checked) { - dispatch(changeComposeSpoilerness(checked)); - }, + onChangeSpoilerText (checked) { + dispatch(changeComposeSpoilerText(checked)); + }, - onChangeSpoilerText (checked) { - dispatch(changeComposeSpoilerText(checked)); - }, + onPaste (files) { + dispatch(uploadCompose(files)); + }, - onChangeVisibility (checked) { - dispatch(changeComposeVisibility(checked)); - }, + onPickEmoji (position, data) { + dispatch(insertEmojiCompose(position, data)); + }, - onChangeListability (checked) { - dispatch(changeComposeListability(checked)); - } - } -}; +}); -export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm); +export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); diff --git a/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx b/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx new file mode 100644 index 000000000..1eee8f84c --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import PrivacyDropdown from '../components/privacy_dropdown'; +import { changeComposeVisibility } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + value: state.getIn(['compose', 'privacy']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeComposeVisibility(value)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown); diff --git a/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx new file mode 100644 index 000000000..39b48f3b6 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { cancelReplyCompose } from '../../../actions/compose'; +import { makeGetStatus } from '../../../selectors'; +import ReplyIndicator from '../components/reply_indicator'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onCancel () { + dispatch(cancelReplyCompose()); + } + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); diff --git a/app/assets/javascripts/components/features/compose/containers/search_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_container.jsx index 17a68f2fc..906c0c28c 100644 --- a/app/assets/javascripts/components/features/compose/containers/search_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/search_container.jsx @@ -1,15 +1,15 @@ import { connect } from 'react-redux'; import { changeSearch, - clearSearchSuggestions, - fetchSearchSuggestions, - resetSearch + clearSearch, + submitSearch, + showSearch } from '../../../actions/search'; import Search from '../components/search'; const mapStateToProps = state => ({ - suggestions: state.getIn(['search', 'suggestions']), - value: state.getIn(['search', 'value']) + value: state.getIn(['search', 'value']), + submitted: state.getIn(['search', 'submitted']) }); const mapDispatchToProps = dispatch => ({ @@ -19,15 +19,15 @@ const mapDispatchToProps = dispatch => ({ }, onClear () { - dispatch(clearSearchSuggestions()); + dispatch(clearSearch()); }, - onFetch (value) { - dispatch(fetchSearchSuggestions(value)); + onSubmit () { + dispatch(submitSearch()); }, - onReset () { - dispatch(resetSearch()); + onShow () { + dispatch(showSearch()); } }); diff --git a/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx new file mode 100644 index 000000000..e5911fd38 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import SearchResults from '../components/search_results'; + +const mapStateToProps = state => ({ + results: state.getIn(['search', 'results']) +}); + +export default connect(mapStateToProps)(SearchResults); diff --git a/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx new file mode 100644 index 000000000..074b568f4 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx @@ -0,0 +1,49 @@ +import { connect } from 'react-redux'; +import TextIconButton from '../components/text_icon_button'; +import { changeComposeSensitivity } from '../../../actions/compose'; +import { Motion, spring } from 'react-motion'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' } +}); + +const mapStateToProps = state => ({ + visible: state.getIn(['compose', 'media_attachments']).size > 0, + active: state.getIn(['compose', 'sensitive']) +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSensitivity()); + } + +}); + +const SensitiveButton = React.createClass({ + + propTypes: { + visible: React.PropTypes.bool, + active: React.PropTypes.bool, + onClick: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired + }, + + render () { + const { visible, active, onClick, intl } = this.props; + + return ( + <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}> + {({ scale }) => + <div style={{ display: visible ? 'block' : 'none', transform: `translateZ(0) scale(${scale})` }}> + <TextIconButton onClick={onClick} label='NSFW' title={intl.formatMessage(messages.title)} active={active} /> + </div> + } + </Motion> + ); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); diff --git a/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx new file mode 100644 index 000000000..61ac32b85 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import TextIconButton from '../components/text_icon_button'; +import { changeComposeSpoilerness } from '../../../actions/compose'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind content warning' } +}); + +const mapStateToProps = (state, { intl }) => ({ + label: 'CW', + title: intl.formatMessage(messages.title), + active: state.getIn(['compose', 'spoiler']) +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSpoilerness()); + } + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton)); diff --git a/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx b/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx new file mode 100644 index 000000000..b0f1d4d19 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import UploadProgress from '../components/upload_progress'; + +const mapStateToProps = (state, props) => ({ + active: state.getIn(['compose', 'is_uploading']), + progress: state.getIn(['compose', 'progress']) +}); + +export default connect(mapStateToProps)(UploadProgress); diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx index f6095c0c6..9421de3ff 100644 --- a/app/assets/javascripts/components/features/compose/index.jsx +++ b/app/assets/javascripts/components/features/compose/index.jsx @@ -1,17 +1,34 @@ -import Drawer from './components/drawer'; import ComposeFormContainer from './containers/compose_form_container'; import UploadFormContainer from './containers/upload_form_container'; import NavigationContainer from './containers/navigation_container'; import PureRenderMixin from 'react-addons-pure-render-mixin'; -import SearchContainer from './containers/search_container'; import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; +import { Link } from 'react-router'; +import { injectIntl, defineMessages } from 'react-intl'; +import SearchContainer from './containers/search_container'; +import { Motion, spring } from 'react-motion'; +import SearchResultsContainer from './containers/search_results_container'; + +const messages = defineMessages({ + start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' }, + community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } +}); + +const mapStateToProps = state => ({ + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) +}); const Compose = React.createClass({ propTypes: { dispatch: React.PropTypes.func.isRequired, - withHeader: React.PropTypes.bool + withHeader: React.PropTypes.bool, + showSearch: React.PropTypes.bool, + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], @@ -25,16 +42,46 @@ const Compose = React.createClass({ }, render () { + const { withHeader, showSearch, intl } = this.props; + + let header = ''; + + if (withHeader) { + header = ( + <div className='drawer__header'> + <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> + <Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link> + <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link> + <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a> + <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a> + </div> + ); + } + return ( - <Drawer withHeader={this.props.withHeader}> + <div className='drawer'> + {header} + <SearchContainer /> - <NavigationContainer /> - <ComposeFormContainer /> - <UploadFormContainer /> - </Drawer> + + <div className='drawer__pager'> + <div className='drawer__inner'> + <NavigationContainer /> + <ComposeFormContainer /> + </div> + + <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => + <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + <SearchResultsContainer /> + </div> + } + </Motion> + </div> + </div> ); } }); -export default connect()(Compose); +export default connect(mapStateToProps)(injectIntl(Compose)); diff --git a/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx b/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx index 0d41d192f..1766655c2 100644 --- a/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx +++ b/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx @@ -16,11 +16,8 @@ const outerStyle = { }; const panelStyle = { - background: '#2f3441', display: 'flex', flexDirection: 'row', - borderTop: '1px solid #363c4b', - borderBottom: '1px solid #363c4b', padding: '10px 0' }; @@ -40,10 +37,10 @@ const AccountAuthorize = ({ intl, account, onAuthorize, onReject }) => { <DisplayName account={account} /> </Permalink> - <div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> + <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> </div> - <div style={panelStyle}> + <div className='account--panel' style={panelStyle}> <div style={btnStyle}><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div> <div style={btnStyle}><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div> </div> diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index 0e1937b43..d7a78d9cc 100644 --- a/app/assets/javascripts/components/features/getting_started/index.jsx +++ b/app/assets/javascripts/components/features/getting_started/index.jsx @@ -7,7 +7,8 @@ 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' }, + public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' }, + community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, 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' }, @@ -30,6 +31,7 @@ const GettingStarted = ({ intl, me }) => { return ( <Column icon='asterisk' heading={intl.formatMessage(messages.heading)}> <div style={{ position: 'relative' }}> + <ColumnLink icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' /> <ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' /> <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> @@ -39,12 +41,9 @@ const GettingStarted = ({ intl, me }) => { <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> </div> - <div className='scrollable optionally-scrollable'> + <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}> <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 href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></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}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p> </div> </div> </Column> diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx index 4a0e7684d..7fb413336 100644 --- a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx +++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx @@ -8,9 +8,11 @@ import { deleteFromTimelines } from '../../actions/timelines'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import { FormattedMessage } from 'react-intl'; import createStream from '../../stream'; const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0, accessToken: state.getIn(['meta', 'access_token']) }); @@ -19,7 +21,8 @@ const HashtagTimeline = React.createClass({ propTypes: { params: React.PropTypes.object.isRequired, dispatch: React.PropTypes.func.isRequired, - accessToken: React.PropTypes.string.isRequired + accessToken: React.PropTypes.string.isRequired, + hasUnread: React.PropTypes.bool }, mixins: [PureRenderMixin], @@ -71,12 +74,12 @@ const HashtagTimeline = React.createClass({ }, render () { - const { id } = this.props.params; + const { id, hasUnread } = this.props.params; return ( - <Column icon='hashtag' heading={id}> + <Column icon='hashtag' active={hasUnread} heading={id}> <ColumnBackButtonSlim /> - <StatusListContainer type='tag' id={id} /> + <StatusListContainer type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} /> </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 index 714be309b..92e700874 100644 --- a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx +++ b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx @@ -6,11 +6,10 @@ 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' } + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' } }); const outerStyle = { - background: '#373b4a', padding: '15px' }; @@ -18,7 +17,6 @@ const sectionStyle = { cursor: 'default', display: 'block', fontWeight: '500', - color: '#9baec8', marginBottom: '10px' }; @@ -42,18 +40,18 @@ const ColumnSettings = React.createClass({ return ( <ColumnCollapsable icon='sliders' fullHeight={209} onCollapse={onSave}> - <div style={outerStyle}> - <span style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> + <div className='column-settings--outer' style={outerStyle}> + <span className='column-settings--section' 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' />} /> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> </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> + <span className='column-settings--section' 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)} /> diff --git a/app/assets/javascripts/components/features/home_timeline/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx index 5d2263f15..a2b775764 100644 --- a/app/assets/javascripts/components/features/home_timeline/index.jsx +++ b/app/assets/javascripts/components/features/home_timeline/index.jsx @@ -1,32 +1,39 @@ +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 { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; +import { Link } from 'react-router'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' } }); +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0 +}); + const HomeTimeline = React.createClass({ propTypes: { - intl: React.PropTypes.object.isRequired + intl: React.PropTypes.object.isRequired, + hasUnread: React.PropTypes.bool }, mixins: [PureRenderMixin], render () { - const { intl } = this.props; + const { intl, hasUnread } = this.props; return ( - <Column icon='home' heading={intl.formatMessage(messages.title)}> + <Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}> <ColumnSettingsContainer /> - <StatusListContainer {...this.props} type='home' /> + <StatusListContainer {...this.props} type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} /> </Column> ); }, }); -export default injectIntl(HomeTimeline); +export default connect(mapStateToProps)(injectIntl(HomeTimeline)); diff --git a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx index d20a4d170..6aa9d1efa 100644 --- a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx +++ b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx @@ -5,11 +5,11 @@ const iconStyle = { right: '48px', top: '0', cursor: 'pointer', - background: '#2f3441' + zIndex: '2' }; const ClearColumnButton = ({ onClick }) => ( - <div className='column-icon' style={iconStyle} onClick={onClick}> + <div className='column-icon' tabindex='0' style={iconStyle} onClick={onClick}> <i className='fa fa-trash' /> </div> ); 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 b63c1881a..f1b8ef57f 100644 --- a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx +++ b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx @@ -5,7 +5,6 @@ import ColumnCollapsable from '../../../components/column_collapsable'; import SettingToggle from './setting_toggle'; const outerStyle = { - background: '#373b4a', padding: '15px' }; @@ -13,7 +12,6 @@ const sectionStyle = { cursor: 'default', display: 'block', fontWeight: '500', - color: '#9baec8', marginBottom: '10px' }; @@ -40,8 +38,8 @@ const ColumnSettings = React.createClass({ return ( <ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}> - <div style={outerStyle}> - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> + <div className='column-settings--outer' style={outerStyle}> + <span className='column-settings--section' 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} /> @@ -49,7 +47,7 @@ const ColumnSettings = React.createClass({ <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> </div> - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> + <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <div style={rowStyle}> <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> @@ -57,7 +55,7 @@ const ColumnSettings = React.createClass({ <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> </div> - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> + <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <div style={rowStyle}> <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> @@ -65,7 +63,7 @@ const ColumnSettings = React.createClass({ <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> </div> - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> + <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <div style={rowStyle}> <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> diff --git a/app/assets/javascripts/components/features/notifications/components/notification.jsx b/app/assets/javascripts/components/features/notifications/components/notification.jsx index 140ba9134..0de4df52e 100644 --- a/app/assets/javascripts/components/features/notifications/components/notification.jsx +++ b/app/assets/javascripts/components/features/notifications/components/notification.jsx @@ -5,17 +5,7 @@ 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', - padding: '8px 0', - paddingBottom: '0', - cursor: 'default', - color: '#d9e1e8', - fontSize: '15px', - position: 'relative' -}; +import escapeTextContentForBrowser from 'escape-html'; const linkStyle = { fontWeight: '500' @@ -32,9 +22,9 @@ const Notification = React.createClass({ renderFollow (account, link) { return ( <div className='notification'> - <div style={messageStyle}> + <div className='notification__message'> <div style={{ position: 'absolute', 'left': '-26px'}}> - <i className='fa fa-fw fa-user-plus' style={{ color: '#2b90d9' }} /> + <i className='fa fa-fw fa-user-plus' /> </div> <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> @@ -52,7 +42,7 @@ const Notification = React.createClass({ renderFavourite (notification, link) { return ( <div className='notification'> - <div style={messageStyle}> + <div className='notification__message'> <div style={{ position: 'absolute', 'left': '-26px'}}> <i className='fa fa-fw fa-star' style={{ color: '#ca8f04' }} /> </div> @@ -68,9 +58,9 @@ const Notification = React.createClass({ renderReblog (notification, link) { return ( <div className='notification'> - <div style={messageStyle}> + <div className='notification__message'> <div style={{ position: 'absolute', 'left': '-26px'}}> - <i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} /> + <i className='fa fa-fw fa-retweet' /> </div> <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> diff --git a/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx index c2438f716..eae3c2be2 100644 --- a/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx +++ b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx @@ -11,14 +11,13 @@ const labelSpanStyle = { display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', - marginLeft: '8px', - color: '#9baec8' + marginLeft: '8px' }; 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> + <span className='setting-toggle' style={labelSpanStyle}>{label}</span> </label> ); diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx index 6d10768de..74b914ffd 100644 --- a/app/assets/javascripts/components/features/notifications/index.jsx +++ b/app/assets/javascripts/components/features/notifications/index.jsx @@ -2,10 +2,10 @@ 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 { expandNotifications, clearNotifications } from '../../actions/notifications'; +import { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications'; import NotificationContainer from './containers/notification_container'; import { ScrollContainer } from 'react-router-scroll'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; import { createSelector } from 'reselect'; import Immutable from 'immutable'; @@ -13,7 +13,8 @@ import LoadMore from '../../components/load_more'; import ClearColumnButton from './components/clear_column_button'; const messages = defineMessages({ - title: { id: 'column.notifications', defaultMessage: 'Notifications' } + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + confirm: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to clear all your notifications?' } }); const getNotifications = createSelector([ @@ -23,7 +24,8 @@ const getNotifications = createSelector([ const mapStateToProps = state => ({ notifications: getNotifications(state), - isLoading: state.getIn(['notifications', 'isLoading'], true) + isLoading: state.getIn(['notifications', 'isLoading'], true), + isUnread: state.getIn(['notifications', 'unread']) > 0 }); const Notifications = React.createClass({ @@ -33,7 +35,8 @@ const Notifications = React.createClass({ dispatch: React.PropTypes.func.isRequired, trackScroll: React.PropTypes.bool, intl: React.PropTypes.object.isRequired, - isLoading: React.PropTypes.bool + isLoading: React.PropTypes.bool, + isUnread: React.PropTypes.bool }, getDefaultProps () { @@ -51,6 +54,10 @@ const Notifications = React.createClass({ if (250 > offset && !this.props.isLoading) { this.props.dispatch(expandNotifications()); + } else if (scrollTop < 100) { + this.props.dispatch(scrollTopNotifications(true)); + } else { + this.props.dispatch(scrollTopNotifications(false)); } }, @@ -66,7 +73,9 @@ const Notifications = React.createClass({ }, handleClear () { - this.props.dispatch(clearNotifications()); + if (window.confirm(this.props.intl.formatMessage(messages.confirm))) { + this.props.dispatch(clearNotifications()); + } }, setRef (c) { @@ -74,26 +83,42 @@ const Notifications = React.createClass({ }, render () { - const { intl, notifications, trackScroll, isLoading } = this.props; + const { intl, notifications, trackScroll, isLoading, isUnread } = this.props; - let loadMore = ''; + let loadMore = ''; + let scrollableArea = ''; + let unread = ''; if (!isLoading && notifications.size > 0) { loadMore = <LoadMore onClick={this.handleLoadMore} />; } - const scrollableArea = ( - <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}> - <div> - {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)} - {loadMore} + if (isUnread) { + unread = <div className='notifications__unread-indicator' />; + } + + if (isLoading || notifications.size > 0) { + scrollableArea = ( + <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}> + {unread} + + <div> + {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)} + {loadMore} + </div> </div> - </div> - ); + ); + } else { + scrollableArea = ( + <div className='empty-column-indicator' ref={this.setRef}> + <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." /> + </div> + ); + } if (trackScroll) { return ( - <Column icon='bell' heading={intl.formatMessage(messages.title)}> + <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}> <ColumnSettingsContainer /> <ClearColumnButton onClick={this.handleClear} /> <ScrollContainer scrollKey='notifications'> @@ -103,7 +128,7 @@ const Notifications = React.createClass({ ); } else { return ( - <Column icon='bell' heading={intl.formatMessage(messages.title)}> + <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}> <ColumnSettingsContainer /> <ClearColumnButton onClick={this.handleClear} /> {scrollableArea} diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx index 36d68dbbb..6d766a83b 100644 --- a/app/assets/javascripts/components/features/public_timeline/index.jsx +++ b/app/assets/javascripts/components/features/public_timeline/index.jsx @@ -5,26 +5,32 @@ import Column from '../ui/components/column'; import { refreshTimeline, updateTimeline, - deleteFromTimelines + deleteFromTimelines, + connectTimeline, + disconnectTimeline } from '../../actions/timelines'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import createStream from '../../stream'; const messages = defineMessages({ - title: { id: 'column.public', defaultMessage: 'Public' } + title: { id: 'column.public', defaultMessage: 'Whole Known Network' } }); const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, accessToken: state.getIn(['meta', 'access_token']) }); +let subscription; + const PublicTimeline = React.createClass({ propTypes: { dispatch: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired, - accessToken: React.PropTypes.string.isRequired + accessToken: React.PropTypes.string.isRequired, + hasUnread: React.PropTypes.bool }, mixins: [PureRenderMixin], @@ -34,7 +40,23 @@ const PublicTimeline = React.createClass({ dispatch(refreshTimeline('public')); - this.subscription = createStream(accessToken, 'public', { + if (typeof subscription !== 'undefined') { + return; + } + + subscription = createStream(accessToken, 'public', { + + connected () { + dispatch(connectTimeline('public')); + }, + + reconnected () { + dispatch(connectTimeline('public')); + }, + + disconnected () { + dispatch(disconnectTimeline('public')); + }, received (data) { switch(data.event) { @@ -51,19 +73,19 @@ const PublicTimeline = React.createClass({ }, componentWillUnmount () { - if (typeof this.subscription !== 'undefined') { - this.subscription.close(); - this.subscription = null; - } + // if (typeof subscription !== 'undefined') { + // subscription.close(); + // subscription = null; + // } }, render () { - const { intl } = this.props; + const { intl, hasUnread } = this.props; return ( - <Column icon='globe' heading={intl.formatMessage(messages.title)}> + <Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}> <ColumnBackButtonSlim /> - <StatusListContainer type='public' /> + <StatusListContainer type='public' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} /> </Column> ); }, diff --git a/app/assets/javascripts/components/features/report/components/status_check_box.jsx b/app/assets/javascripts/components/features/report/components/status_check_box.jsx new file mode 100644 index 000000000..6d976582b --- /dev/null +++ b/app/assets/javascripts/components/features/report/components/status_check_box.jsx @@ -0,0 +1,42 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import emojify from '../../../emoji'; +import Toggle from 'react-toggle'; + +const StatusCheckBox = React.createClass({ + + propTypes: { + status: ImmutablePropTypes.map.isRequired, + checked: React.PropTypes.bool, + onToggle: React.PropTypes.func.isRequired, + disabled: React.PropTypes.bool + }, + + mixins: [PureRenderMixin], + + render () { + const { status, checked, onToggle, disabled } = this.props; + const content = { __html: emojify(status.get('content')) }; + + if (status.get('reblog')) { + return null; + } + + return ( + <div className='status-check-box' style={{ display: 'flex' }}> + <div + className='status__content' + style={{ flex: '1 1 auto', padding: '10px' }} + dangerouslySetInnerHTML={content} + /> + + <div style={{ flex: '0 0 auto', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center' }}> + <Toggle checked={checked} onChange={onToggle} disabled={disabled} /> + </div> + </div> + ); + } + +}); + +export default StatusCheckBox; diff --git a/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx b/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx new file mode 100644 index 000000000..67ce9d9f3 --- /dev/null +++ b/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import StatusCheckBox from '../components/status_check_box'; +import { toggleStatusReport } from '../../../actions/reports'; +import Immutable from 'immutable'; + +const mapStateToProps = (state, { id }) => ({ + status: state.getIn(['statuses', id]), + checked: state.getIn(['reports', 'new', 'status_ids'], Immutable.Set()).includes(id) +}); + +const mapDispatchToProps = (dispatch, { id }) => ({ + + onToggle (e) { + dispatch(toggleStatusReport(id, e.target.checked)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox); diff --git a/app/assets/javascripts/components/features/report/index.jsx b/app/assets/javascripts/components/features/report/index.jsx new file mode 100644 index 000000000..3177d28b1 --- /dev/null +++ b/app/assets/javascripts/components/features/report/index.jsx @@ -0,0 +1,130 @@ +import { connect } from 'react-redux'; +import { cancelReport, changeReportComment, submitReport } from '../../actions/reports'; +import { fetchAccountTimeline } from '../../actions/accounts'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from '../ui/components/column'; +import Button from '../../components/button'; +import { makeGetAccount } from '../../selectors'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import StatusCheckBox from './containers/status_check_box_container'; +import Immutable from 'immutable'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; + +const messages = defineMessages({ + heading: { id: 'report.heading', defaultMessage: 'New report' }, + placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, + submit: { id: 'report.submit', defaultMessage: 'Submit' } +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = state => { + const accountId = state.getIn(['reports', 'new', 'account_id']); + + return { + isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), + account: getAccount(state, accountId), + comment: state.getIn(['reports', 'new', 'comment']), + statusIds: Immutable.OrderedSet(state.getIn(['timelines', 'accounts_timelines', accountId, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])) + }; + }; + + return mapStateToProps; +}; + +const textareaStyle = { + marginBottom: '10px' +}; + +const Report = React.createClass({ + + contextTypes: { + router: React.PropTypes.object + }, + + propTypes: { + isSubmitting: React.PropTypes.bool, + account: ImmutablePropTypes.map, + statusIds: ImmutablePropTypes.list.isRequired, + comment: React.PropTypes.string.isRequired, + dispatch: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired + }, + + mixins: [PureRenderMixin], + + componentWillMount () { + if (!this.props.account) { + this.context.router.replace('/'); + } + }, + + componentDidMount () { + if (!this.props.account) { + return; + } + + this.props.dispatch(fetchAccountTimeline(this.props.account.get('id'))); + }, + + componentWillReceiveProps (nextProps) { + if (this.props.account !== nextProps.account && nextProps.account) { + this.props.dispatch(fetchAccountTimeline(nextProps.account.get('id'))); + } + }, + + handleCommentChange (e) { + this.props.dispatch(changeReportComment(e.target.value)); + }, + + handleSubmit () { + this.props.dispatch(submitReport()); + this.context.router.replace('/'); + }, + + render () { + const { account, comment, intl, statusIds, isSubmitting } = this.props; + + if (!account) { + return null; + } + + return ( + <Column heading={intl.formatMessage(messages.heading)} icon='flag'> + <ColumnBackButtonSlim /> + <div className='report' style={{ display: 'flex', flexDirection: 'column', maxHeight: '100%', boxSizing: 'border-box' }}> + <div className='report__target' style={{ flex: '0 0 auto', padding: '10px' }}> + <FormattedMessage id='report.target' defaultMessage='Reporting' /> + <strong>{account.get('acct')}</strong> + </div> + + <div style={{ flex: '1 1 auto' }} className='scrollable'> + <div> + {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)} + </div> + </div> + + <div style={{ flex: '0 0 160px', padding: '10px' }}> + <textarea + className='report__textarea' + placeholder={intl.formatMessage(messages.placeholder)} + value={comment} + onChange={this.handleCommentChange} + style={textareaStyle} + disabled={isSubmitting} + /> + + <div style={{ marginTop: '10px', overflow: 'hidden' }}> + <div style={{ float: 'right' }}><Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /></div> + </div> + </div> + </div> + </Column> + ); + } + +}); + +export default connect(makeMapStateToProps)(injectIntl(Report)); 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 0e92acf55..2aebcd709 100644 --- a/app/assets/javascripts/components/features/status/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx @@ -6,10 +6,11 @@ import { defineMessages, injectIntl } from 'react-intl'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, - mention: { id: 'status.mention', defaultMessage: 'Mention' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, reblog: { id: 'status.reblog', defaultMessage: 'Reblog' }, - favourite: { id: 'status.favourite', defaultMessage: 'Favourite' } + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' } }); const ActionBar = React.createClass({ @@ -25,6 +26,7 @@ const ActionBar = React.createClass({ onFavourite: React.PropTypes.func.isRequired, onDelete: React.PropTypes.func.isRequired, onMention: React.PropTypes.func.isRequired, + onReport: React.PropTypes.func, me: React.PropTypes.number.isRequired, intl: React.PropTypes.object.isRequired }, @@ -51,6 +53,11 @@ const ActionBar = React.createClass({ this.props.onMention(this.props.status.get('account'), this.context.router); }, + handleReport () { + this.props.onReport(this.props.status); + this.context.router.push('/report'); + }, + render () { const { status, me, intl } = this.props; @@ -59,13 +66,15 @@ const ActionBar = React.createClass({ if (me === status.getIn(['account', 'id'])) { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { - menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); } return ( <div className='detailed-status__action-bar'> <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 disabled={status.get('visibility') === 'direct' || status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'direct' ? 'envelope' : (status.get('visibility') === 'private' ? 'lock' : 'retweet')} onClick={this.handleReblogClick} /></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 index ccb06dfd5..d016212fd 100644 --- a/app/assets/javascripts/components/features/status/components/card.jsx +++ b/app/assets/javascripts/components/features/status/components/card.jsx @@ -1,18 +1,6 @@ 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', @@ -20,25 +8,6 @@ const contentStyle = { 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%', @@ -77,20 +46,20 @@ const Card = React.createClass({ if (card.get('image')) { image = ( - <div style={imageOuterStyle}> + <div className='status-card__image'> <img src={card.get('image')} alt={card.get('title')} style={imageStyle} /> </div> ); } return ( - <a style={outerStyle} href={card.get('url')} className='status-card'> + <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'> {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 className='status-card__content' style={contentStyle}> + <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong> + <p className='status-card__description'>{card.get('description').substring(0, 50)}</p> + <span className='status-card__host' style={hostStyle}>{getHostname(card.get('url'))}</span> </div> </a> ); 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 f2d6ae48a..caa46ff3c 100644 --- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx +++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx @@ -39,7 +39,7 @@ const DetailedStatus = React.createClass({ if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={317} height={178} />; + media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} autoplay />; } else { media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />; } @@ -52,7 +52,7 @@ const DetailedStatus = React.createClass({ } return ( - <div style={{ background: '#2f3441', padding: '14px 10px' }} className='detailed-status'> + <div style={{ padding: '14px 10px' }} className='detailed-status'> <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}> <div style={{ float: 'left', marginRight: '10px' }}><Avatar src={status.getIn(['account', 'avatar'])} size={48} /></div> <DisplayName account={status.get('account')} /> @@ -62,7 +62,7 @@ const DetailedStatus = React.createClass({ {media} - <div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}> + <div className='detailed-status__meta'> <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/index.jsx b/app/assets/javascripts/components/features/status/index.jsx index 894fa3176..f98fe1b01 100644 --- a/app/assets/javascripts/components/features/status/index.jsx +++ b/app/assets/javascripts/components/features/status/index.jsx @@ -1,28 +1,34 @@ -import { connect } from 'react-redux'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { fetchStatus } from '../../actions/statuses'; -import Immutable from 'immutable'; -import EmbeddedStatus from '../../components/status'; -import LoadingIndicator from '../../components/loading_indicator'; -import DetailedStatus from './components/detailed_status'; -import ActionBar from './components/action_bar'; -import Column from '../ui/components/column'; -import { favourite, reblog } from '../../actions/interactions'; +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchStatus } from '../../actions/statuses'; +import Immutable from 'immutable'; +import EmbeddedStatus from '../../components/status'; +import MissingIndicator from '../../components/missing_indicator'; +import DetailedStatus from './components/detailed_status'; +import ActionBar from './components/action_bar'; +import Column from '../ui/components/column'; +import { + favourite, + unfavourite, + reblog, + unreblog +} from '../../actions/interactions'; import { replyCompose, mentionCompose -} from '../../actions/compose'; -import { deleteStatus } from '../../actions/statuses'; +} from '../../actions/compose'; +import { deleteStatus } from '../../actions/statuses'; +import { initReport } from '../../actions/reports'; import { makeGetStatus, getStatusAncestors, getStatusDescendants -} from '../../selectors'; -import { ScrollContainer } from 'react-router-scroll'; -import ColumnBackButton from '../../components/column_back_button'; -import StatusContainer from '../../containers/status_container'; -import { openMedia } from '../../actions/modal'; +} from '../../selectors'; +import { ScrollContainer } from 'react-router-scroll'; +import ColumnBackButton from '../../components/column_back_button'; +import StatusContainer from '../../containers/status_container'; +import { openModal } from '../../actions/modal'; import { isMobile } from '../../is_mobile' const makeMapStateToProps = () => { @@ -65,7 +71,11 @@ const Status = React.createClass({ }, handleFavouriteClick (status) { - this.props.dispatch(favourite(status)); + if (status.get('favourited')) { + this.props.dispatch(unfavourite(status)); + } else { + this.props.dispatch(favourite(status)); + } }, handleReplyClick (status) { @@ -73,7 +83,11 @@ const Status = React.createClass({ }, handleReblogClick (status) { - this.props.dispatch(reblog(status)); + if (status.get('reblogged')) { + this.props.dispatch(unreblog(status)); + } else { + this.props.dispatch(reblog(status)); + } }, handleDeleteClick (status) { @@ -85,7 +99,11 @@ const Status = React.createClass({ }, handleOpenMedia (media, index) { - this.props.dispatch(openMedia(media, index)); + this.props.dispatch(openModal('MEDIA', { media, index })); + }, + + handleReport (status) { + this.props.dispatch(initReport(status.get('account'), status)); }, renderChildren (list) { @@ -99,7 +117,8 @@ const Status = React.createClass({ if (status === null) { return ( <Column> - <LoadingIndicator /> + <ColumnBackButton /> + <MissingIndicator /> </Column> ); } @@ -123,7 +142,7 @@ const Status = React.createClass({ {ancestors} <DetailedStatus status={status} me={me} onOpenMedia={this.handleOpenMedia} /> - <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} /> + <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} /> {descendants} </div> diff --git a/app/assets/javascripts/components/features/ui/components/column.jsx b/app/assets/javascripts/components/features/ui/components/column.jsx index 5b0603ee9..2b7e11bf1 100644 --- a/app/assets/javascripts/components/features/ui/components/column.jsx +++ b/app/assets/javascripts/components/features/ui/components/column.jsx @@ -34,7 +34,8 @@ const Column = React.createClass({ propTypes: { heading: React.PropTypes.string, icon: React.PropTypes.string, - children: React.PropTypes.node + children: React.PropTypes.node, + active: React.PropTypes.bool }, mixins: [PureRenderMixin], @@ -51,12 +52,12 @@ const Column = React.createClass({ }, render () { - const { heading, icon, children } = this.props; + const { heading, icon, children, active } = this.props; let header = ''; if (heading) { - header = <ColumnHeader icon={icon} type={heading} onClick={this.handleHeaderClick} />; + header = <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} />; } return ( diff --git a/app/assets/javascripts/components/features/ui/components/column_header.jsx b/app/assets/javascripts/components/features/ui/components/column_header.jsx index 8b072d723..de55fa748 100644 --- a/app/assets/javascripts/components/features/ui/components/column_header.jsx +++ b/app/assets/javascripts/components/features/ui/components/column_header.jsx @@ -5,6 +5,7 @@ const ColumnHeader = React.createClass({ propTypes: { icon: React.PropTypes.string, type: React.PropTypes.string, + active: React.PropTypes.bool, onClick: React.PropTypes.func }, @@ -15,6 +16,8 @@ const ColumnHeader = React.createClass({ }, render () { + const { type, active } = this.props; + let icon = ''; if (this.props.icon) { @@ -22,9 +25,9 @@ const ColumnHeader = React.createClass({ } return ( - <div className='column-header' onClick={this.handleClick}> + <div className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick}> {icon} - {this.props.type} + {type} </div> ); } 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 901a29f5c..2bd1e1017 100644 --- a/app/assets/javascripts/components/features/ui/components/column_link.jsx +++ b/app/assets/javascripts/components/features/ui/components/column_link.jsx @@ -4,7 +4,6 @@ const outerStyle = { display: 'block', padding: '15px', fontSize: '16px', - color: '#fff', textDecoration: 'none' }; diff --git a/app/assets/javascripts/components/features/ui/components/media_modal.jsx b/app/assets/javascripts/components/features/ui/components/media_modal.jsx new file mode 100644 index 000000000..35eb2cb0c --- /dev/null +++ b/app/assets/javascripts/components/features/ui/components/media_modal.jsx @@ -0,0 +1,133 @@ +import LoadingIndicator from '../../../components/loading_indicator'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ExtendedVideoPlayer from '../../../components/extended_video_player'; +import ImageLoader from 'react-imageloader'; +import { defineMessages, injectIntl } from 'react-intl'; +import IconButton from '../../../components/icon_button'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' } +}); + +const leftNavStyle = { + position: 'absolute', + background: 'rgba(0, 0, 0, 0.5)', + padding: '30px 15px', + cursor: 'pointer', + fontSize: '24px', + top: '0', + left: '-61px', + boxSizing: 'border-box', + height: '100%', + display: 'flex', + alignItems: 'center' +}; + +const rightNavStyle = { + position: 'absolute', + background: 'rgba(0, 0, 0, 0.5)', + padding: '30px 15px', + cursor: 'pointer', + fontSize: '24px', + top: '0', + right: '-61px', + boxSizing: 'border-box', + height: '100%', + display: 'flex', + alignItems: 'center' +}; + +const closeStyle = { + position: 'absolute', + top: '4px', + right: '4px' +}; + +const MediaModal = React.createClass({ + + propTypes: { + media: ImmutablePropTypes.list.isRequired, + index: React.PropTypes.number.isRequired, + onClose: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired + }, + + getInitialState () { + return { + index: null + }; + }, + + mixins: [PureRenderMixin], + + handleNextClick () { + this.setState({ index: (this.getIndex() + 1) % this.props.media.size}); + }, + + handlePrevClick () { + this.setState({ index: (this.getIndex() - 1) % this.props.media.size}); + }, + + handleKeyUp (e) { + switch(e.key) { + case 'ArrowLeft': + this.handlePrevClick(); + break; + case 'ArrowRight': + this.handleNextClick(); + break; + } + }, + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + }, + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + }, + + getIndex () { + return this.state.index !== null ? this.state.index : this.props.index; + }, + + render () { + const { media, intl, onClose } = this.props; + + const index = this.getIndex(); + const attachment = media.get(index); + const url = attachment.get('url'); + + let leftNav, rightNav, content; + + leftNav = rightNav = content = ''; + + if (media.size > 1) { + leftNav = <div style={leftNavStyle} className='modal-container__nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; + rightNav = <div style={rightNavStyle} className='modal-container__nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; + } + + if (attachment.get('type') === 'image') { + content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />; + } else if (attachment.get('type') === 'gifv') { + content = <ExtendedVideoPlayer src={url} />; + } + + return ( + <div className='modal-root__modal media-modal'> + {leftNav} + + <div> + <IconButton title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} style={closeStyle} /> + {content} + </div> + + {rightNav} + </div> + ); + } + +}); + +export default injectIntl(MediaModal); diff --git a/app/assets/javascripts/components/features/ui/components/modal_root.jsx b/app/assets/javascripts/components/features/ui/components/modal_root.jsx new file mode 100644 index 000000000..d2ae5e145 --- /dev/null +++ b/app/assets/javascripts/components/features/ui/components/modal_root.jsx @@ -0,0 +1,80 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import MediaModal from './media_modal'; +import { TransitionMotion, spring } from 'react-motion'; + +const MODAL_COMPONENTS = { + 'MEDIA': MediaModal +}; + +const ModalRoot = React.createClass({ + + propTypes: { + type: React.PropTypes.string, + props: React.PropTypes.object, + onClose: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + handleKeyUp (e) { + if (e.key === 'Escape' && !!this.props.type) { + this.props.onClose(); + } + }, + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + }, + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + }, + + willEnter () { + return { opacity: 0, scale: 0.98 }; + }, + + willLeave () { + return { opacity: spring(0), scale: spring(0.98) }; + }, + + render () { + const { type, props, onClose } = this.props; + const items = []; + + if (!!type) { + items.push({ + key: type, + data: { type, props }, + style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) } + }); + } + + return ( + <TransitionMotion + styles={items} + willEnter={this.willEnter} + willLeave={this.willLeave}> + {interpolatedStyles => + <div className='modal-root'> + {interpolatedStyles.map(({ key, data: { type, props }, style }) => { + const SpecificComponent = MODAL_COMPONENTS[type]; + + return ( + <div key={key}> + <div className='modal-root__overlay' style={{ opacity: style.opacity, transform: `translateZ(0px)` }} onClick={onClose} /> + <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> + <SpecificComponent {...props} onClose={onClose} /> + </div> + </div> + ); + })} + </div> + } + </TransitionMotion> + ); + } + +}); + +export default ModalRoot; 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 225a6a5fc..6cdb29dbf 100644 --- a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx +++ b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx @@ -1,15 +1,23 @@ import { Link } from 'react-router'; import { FormattedMessage } from 'react-intl'; -const TabsBar = () => { - return ( - <div className='tabs-bar'> - <Link className='tabs-bar__link' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link> - <Link className='tabs-bar__link' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link> - <Link className='tabs-bar__link' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link> - <Link className='tabs-bar__link' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link> - </div> - ); -}; +const TabsBar = React.createClass({ + + render () { + return ( + <div className='tabs-bar'> + <Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link> + <Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link> + <Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link> + + <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link> + <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link> + + <Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link> + </div> + ); + } + +}); export default TabsBar; diff --git a/app/assets/javascripts/components/features/ui/components/upload_area.jsx b/app/assets/javascripts/components/features/ui/components/upload_area.jsx new file mode 100644 index 000000000..70b687019 --- /dev/null +++ b/app/assets/javascripts/components/features/ui/components/upload_area.jsx @@ -0,0 +1,32 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { Motion, spring } from 'react-motion'; +import { FormattedMessage } from 'react-intl'; + +const UploadArea = React.createClass({ + + propTypes: { + active: React.PropTypes.bool + }, + + mixins: [PureRenderMixin], + + render () { + const { active } = this.props; + + return ( + <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}> + {({ backgroundOpacity, backgroundScale }) => + <div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}> + <div className='upload-area__drop'> + <div className='upload-area__background' style={{ transform: `translateZ(0) scale(${backgroundScale})` }} /> + <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div> + </div> + </div> + } + </Motion> + ); + } + +}); + +export default UploadArea; 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 334e5c199..26d77818c 100644 --- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx @@ -1,170 +1,16 @@ import { connect } from 'react-redux'; -import { - closeModal, - decreaseIndexInModal, - increaseIndexInModal -} 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'; -import ImmutablePropTypes from 'react-immutable-proptypes'; +import { closeModal } from '../../../actions/modal'; +import ModalRoot from '../components/modal_root'; const mapStateToProps = state => ({ - media: state.getIn(['modal', 'media']), - index: state.getIn(['modal', 'index']), - isVisible: state.getIn(['modal', 'open']) + type: state.get('modal').modalType, + props: state.get('modal').modalProps }); const mapDispatchToProps = dispatch => ({ - onCloseClicked () { + onClose () { dispatch(closeModal()); }, - - onOverlayClicked () { - dispatch(closeModal()); - }, - - onNextClicked () { - dispatch(increaseIndexInModal()); - }, - - onPrevClicked () { - dispatch(decreaseIndexInModal()); - } -}); - -const imageStyle = { - display: 'block', - maxWidth: '80vw', - maxHeight: '80vh' -}; - -const loadingStyle = { - background: '#373b4a', - width: '400px', - paddingBottom: '120px' -}; - -const preloader = () => ( - <div style={loadingStyle}> - <LoadingIndicator /> - </div> -); - -const leftNavStyle = { - position: 'absolute', - background: 'rgba(0, 0, 0, 0.5)', - padding: '30px 15px', - cursor: 'pointer', - color: '#fff', - fontSize: '24px', - top: '0', - left: '-61px', - boxSizing: 'border-box', - height: '100%', - display: 'flex', - alignItems: 'center' -}; - -const rightNavStyle = { - position: 'absolute', - background: 'rgba(0, 0, 0, 0.5)', - padding: '30px 15px', - cursor: 'pointer', - color: '#fff', - fontSize: '24px', - top: '0', - right: '-61px', - boxSizing: 'border-box', - height: '100%', - display: 'flex', - alignItems: 'center' -}; - -const Modal = React.createClass({ - - propTypes: { - media: ImmutablePropTypes.list, - index: React.PropTypes.number.isRequired, - isVisible: React.PropTypes.bool, - onCloseClicked: React.PropTypes.func, - onOverlayClicked: React.PropTypes.func, - onNextClicked: React.PropTypes.func, - onPrevClicked: React.PropTypes.func - }, - - mixins: [PureRenderMixin], - - handleNextClick () { - this.props.onNextClicked(); - }, - - handlePrevClick () { - this.props.onPrevClicked(); - }, - - componentDidMount () { - this._listener = e => { - if (!this.props.isVisible) { - return; - } - - switch(e.key) { - case 'ArrowLeft': - this.props.onPrevClicked(); - break; - case 'ArrowRight': - this.props.onNextClicked(); - break; - } - }; - - window.addEventListener('keyup', this._listener); - }, - - componentWillUnmount () { - window.removeEventListener('keyup', this._listener); - }, - - render () { - const { media, index, ...other } = this.props; - - if (!media) { - return null; - } - - const url = media.get(index).get('url'); - const hasLeft = index > 0; - const hasRight = index + 1 < media.size; - - let leftNav, rightNav; - - leftNav = rightNav = ''; - - if (hasLeft) { - leftNav = <div style={leftNavStyle} onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; - } - - if (hasRight) { - rightNav = <div style={rightNavStyle} onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; - } - - return ( - <Lightbox {...other}> - {leftNav} - - <ImageLoader - src={url} - preloader={preloader} - imgProps={{ style: imageStyle }} - /> - - {rightNav} - </Lightbox> - ); - } - }); -export default connect(mapStateToProps, mapDispatchToProps)(Modal); +export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot); 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 100989d22..f249240d8 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 @@ -3,8 +3,9 @@ import StatusList from '../../../components/status_list'; import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines'; import Immutable from 'immutable'; import { createSelector } from 'reselect'; +import { debounce } from 'react-decoration'; -const getStatusIds = createSelector([ +const makeGetStatusIds = () => createSelector([ (state, { type }) => state.getIn(['settings', type], Immutable.Map()), (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()), (state) => state.get('statuses'), @@ -33,26 +34,37 @@ const getStatusIds = createSelector([ return showStatus; })); -const mapStateToProps = (state, props) => ({ - statusIds: getStatusIds(state, props), - isLoading: state.getIn(['timelines', props.type, 'isLoading'], true) -}); +const makeMapStateToProps = () => { + const getStatusIds = makeGetStatusIds(); + + const mapStateToProps = (state, props) => ({ + statusIds: getStatusIds(state, props), + isLoading: state.getIn(['timelines', props.type, 'isLoading'], true), + isUnread: state.getIn(['timelines', props.type, 'unread']) > 0, + hasMore: !!state.getIn(['timelines', props.type, 'next']) + }); + + return mapStateToProps; +}; const mapDispatchToProps = (dispatch, { type, id }) => ({ + @debounce(300, true) onScrollToBottom () { dispatch(scrollTopTimeline(type, false)); dispatch(expandTimeline(type, id)); }, + @debounce(100) onScrollToTop () { dispatch(scrollTopTimeline(type, true)); }, + @debounce(100) onScroll () { dispatch(scrollTopTimeline(type, false)); } }); -export default connect(mapStateToProps, mapDispatchToProps)(StatusList); +export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx index 900d83dba..89fb82568 100644 --- a/app/assets/javascripts/components/features/ui/index.jsx +++ b/app/assets/javascripts/components/features/ui/index.jsx @@ -13,6 +13,7 @@ import { debounce } from 'react-decoration'; import { uploadCompose } from '../../actions/compose'; import { refreshTimeline } from '../../actions/timelines'; import { refreshNotifications } from '../../actions/notifications'; +import UploadArea from './components/upload_area'; const UI = React.createClass({ @@ -23,7 +24,8 @@ const UI = React.createClass({ getInitialState () { return { - width: window.innerWidth + width: window.innerWidth, + draggingOver: false }; }, @@ -34,29 +36,64 @@ const UI = React.createClass({ this.setState({ width: window.innerWidth }); }, + handleDragEnter (e) { + e.preventDefault(); + + if (!this.dragTargets) { + this.dragTargets = []; + } + + if (this.dragTargets.indexOf(e.target) === -1) { + this.dragTargets.push(e.target); + } + + if (e.dataTransfer && e.dataTransfer.files.length > 0) { + this.setState({ draggingOver: true }); + } + }, + handleDragOver (e) { e.preventDefault(); e.stopPropagation(); - e.dataTransfer.dropEffect = 'copy'; + try { + e.dataTransfer.dropEffect = 'copy'; + } catch (err) { - if (e.dataTransfer.effectAllowed === 'all' || e.dataTransfer.effectAllowed === 'uninitialized') { - // } + + return false; }, handleDrop (e) { e.preventDefault(); + this.setState({ draggingOver: false }); + if (e.dataTransfer && e.dataTransfer.files.length === 1) { this.props.dispatch(uploadCompose(e.dataTransfer.files)); } }, + handleDragLeave (e) { + e.preventDefault(); + e.stopPropagation(); + + this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el)); + + if (this.dragTargets.length > 0) { + return; + } + + this.setState({ draggingOver: false }); + }, + componentWillMount () { window.addEventListener('resize', this.handleResize, { passive: true }); - window.addEventListener('dragover', this.handleDragOver); - window.addEventListener('drop', this.handleDrop); + document.addEventListener('dragenter', this.handleDragEnter, false); + document.addEventListener('dragover', this.handleDragOver, false); + document.addEventListener('drop', this.handleDrop, false); + document.addEventListener('dragleave', this.handleDragLeave, false); this.props.dispatch(refreshTimeline('home')); this.props.dispatch(refreshNotifications()); @@ -64,17 +101,26 @@ const UI = React.createClass({ componentWillUnmount () { window.removeEventListener('resize', this.handleResize); - window.removeEventListener('dragover', this.handleDragOver); - window.removeEventListener('drop', this.handleDrop); + document.removeEventListener('dragenter', this.handleDragEnter); + document.removeEventListener('dragover', this.handleDragOver); + document.removeEventListener('drop', this.handleDrop); + document.removeEventListener('dragleave', this.handleDragLeave); + }, + + setRef (c) { + this.node = c; }, render () { + const { width, draggingOver } = this.state; + const { children } = this.props; + let mountedColumns; - if (isMobile(this.state.width)) { + if (isMobile(width)) { mountedColumns = ( <ColumnsArea> - {this.props.children} + {children} </ColumnsArea> ); } else { @@ -83,13 +129,13 @@ const UI = React.createClass({ <Compose withHeader={true} /> <HomeTimeline trackScroll={false} /> <Notifications trackScroll={false} /> - {this.props.children} + {children} </ColumnsArea> ); } return ( - <div className='ui'> + <div className='ui' ref={this.setRef}> <TabsBar /> {mountedColumns} @@ -97,6 +143,7 @@ const UI = React.createClass({ <NotificationsContainer /> <LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} /> <ModalContainer /> + <UploadArea active={draggingOver} /> </div> ); } diff --git a/app/assets/javascripts/components/is_mobile.jsx b/app/assets/javascripts/components/is_mobile.jsx index eaa6221e4..992e63727 100644 --- a/app/assets/javascripts/components/is_mobile.jsx +++ b/app/assets/javascripts/components/is_mobile.jsx @@ -3,3 +3,9 @@ const LAYOUT_BREAKPOINT = 1024; export function isMobile(width) { return width <= LAYOUT_BREAKPOINT; }; + +const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + +export function isIOS() { + return iOS; +}; diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx index 7d32824f1..882c31fa7 100644 --- a/app/assets/javascripts/components/locales/de.jsx +++ b/app/assets/javascripts/components/locales/de.jsx @@ -39,7 +39,7 @@ const en = { "tabs_bar.public": "Gesamtes Netz", "tabs_bar.notifications": "Mitteilungen", "compose_form.placeholder": "Worüber möchstest du schreiben?", - "compose_form.publish": "Veröffentlichen", + "compose_form.publish": "Tröt", "compose_form.sensitive": "Medien als sensitiv markieren", "compose_form.unlisted": "Öffentlich nicht auflisten", "compose_form.private": "Als privat markieren", diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx index ac1c1a7d5..53e2898eb 100644 --- a/app/assets/javascripts/components/locales/en.jsx +++ b/app/assets/javascripts/components/locales/en.jsx @@ -2,7 +2,7 @@ const en = { "column_back_button.label": "Back", "lightbox.close": "Close", "loading_indicator.label": "Loading...", - "status.mention": "Mention", + "status.mention": "Mention @{name}", "status.delete": "Delete", "status.reply": "Reply", "status.reblog": "Boost", @@ -11,11 +11,11 @@ const en = { "status.sensitive_warning": "Sensitive content", "status.sensitive_toggle": "Click to view", "video_player.toggle_sound": "Toggle sound", - "account.mention": "Mention", + "account.mention": "Mention @{name}", "account.edit_profile": "Edit profile", - "account.unblock": "Unblock", + "account.unblock": "Unblock @{name}", "account.unfollow": "Unfollow", - "account.block": "Block", + "account.block": "Block @{name}", "account.follow": "Follow", "account.posts": "Posts", "account.follows": "Follows", @@ -25,26 +25,27 @@ const en = { "getting_started.heading": "Getting started", "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}.", + "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.", "column.home": "Home", - "column.mentions": "Mentions", - "column.public": "Public", + "column.community": "Local timeline", + "column.public": "Federated timeline", "column.notifications": "Notifications", "tabs_bar.compose": "Compose", "tabs_bar.home": "Home", "tabs_bar.mentions": "Mentions", - "tabs_bar.public": "Public", + "tabs_bar.public": "Federated timeline", "tabs_bar.notifications": "Notifications", "compose_form.placeholder": "What is on your mind?", "compose_form.publish": "Toot", "compose_form.sensitive": "Mark media as sensitive", "compose_form.spoiler": "Hide text behind warning", "compose_form.private": "Mark as private", - "compose_form.unlisted": "Do not display in public timeline", + "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.", + "compose_form.unlisted": "Do not display on public timelines", "navigation_bar.edit_profile": "Edit profile", "navigation_bar.preferences": "Preferences", - "navigation_bar.public_timeline": "Public timeline", + "navigation_bar.community_timeline": "Local timeline", + "navigation_bar.public_timeline": "Federated timeline", "navigation_bar.logout": "Logout", "reply_indicator.cancel": "Cancel", "search.placeholder": "Search", diff --git a/app/assets/javascripts/components/locales/fi.jsx b/app/assets/javascripts/components/locales/fi.jsx new file mode 100644 index 000000000..5bef99923 --- /dev/null +++ b/app/assets/javascripts/components/locales/fi.jsx @@ -0,0 +1,68 @@ +const fi = { + "column_back_button.label": "Takaisin", + "lightbox.close": "Sulje", + "loading_indicator.label": "Ladataan...", + "status.mention": "Mainitse @{name}", + "status.delete": "Poista", + "status.reply": "Vastaa", + "status.reblog": "Boostaa", + "status.favourite": "Tykkää", + "status.reblogged_by": "{name} boostattu", + "status.sensitive_warning": "Arkaluontoista sisältöä", + "status.sensitive_toggle": "Klikkaa nähdäksesi", + "video_player.toggle_sound": "Äänet päälle/pois", + "account.mention": "Mainitse @{name}", + "account.edit_profile": "Muokkaa", + "account.unblock": "Salli @{name}", + "account.unfollow": "Lopeta seuraaminen", + "account.block": "Estä @{name}", + "account.follow": "Seuraa", + "account.posts": "Postit", + "account.follows": "Seuraa", + "account.followers": "Seuraajia", + "account.follows_you": "Seuraa sinua", + "account.requested": "Odottaa hyväksyntää", + "getting_started.heading": "Päästä alkuun", + "getting_started.about_addressing": "Voit seurata ihmisiä jos tiedät heidän käyttäjänimensä ja domainin missä he ovat syöttämällä e-mail-esque osoitteen Etsi kenttään.", + "getting_started.about_shortcuts": "Jos etsimäsi henkilö on samassa domainissa kuin sinä, pelkkä käyttäjänimi kelpaa. Sama pätee kun mainitset ihmisiä statuksessasi", + "getting_started.open_source_notice": "Mastodon Mastodon on avoimen lähdekoodin ohjelma. Voit avustaa tai raportoida ongelmia githubissa {github}. {apps}.", + "column.home": "Koti", + "column.community": "Paikallinen aikajana", + "column.public": "Yhdistetty aikajana", + "column.notifications": "Ilmoitukset", + "tabs_bar.compose": "Luo", + "tabs_bar.home": "Koti", + "tabs_bar.mentions": "Maininnat", + "tabs_bar.public": "Yleinen aikajana", + "tabs_bar.notifications": "Ilmoitukset", + "compose_form.placeholder": "Mitä sinulla on mielessä?", + "compose_form.publish": "Toot", + "compose_form.sensitive": "Merkitse media herkäksi", + "compose_form.spoiler": "Piiloita teksti varoituksen taakse", + "compose_form.private": "Merkitse yksityiseksi", + "compose_form.privacy_disclaimer": "Sinun yksityinen status toimitetaan mainitsemallesi käyttäjille domaineissa {domains}. Luotatko {domainsCount, plural, one {tähän palvelimeen} other {näihin palvelimiin}}? Postauksen yksityisyys toimii van Mastodon palvelimilla. Jos {domains} {domainsCount, plural, one {ei ole Mastodon palvelin} other {eivät ole Mastodon palvelin}}, viestiin ei tule Yksityinen-merkintää, ja sitä voidaan boostata tai muuten tehdä näkyväksi muille vastaanottajille.", + "compose_form.unlisted": "Älä näytä julkisilla aikajanoilla", + "navigation_bar.edit_profile": "Muokkaa profiilia", + "navigation_bar.preferences": "Ominaisuudet", + "navigation_bar.community_timeline": "Paikallinen aikajana", + "navigation_bar.public_timeline": "Yleinen aikajana", + "navigation_bar.logout": "Kirjaudu ulos", + "reply_indicator.cancel": "Peruuta", + "search.placeholder": "Hae", + "search.account": "Tili", + "search.hashtag": "Hashtag", + "upload_button.label": "Lisää mediaa", + "upload_form.undo": "Peru", + "notification.follow": "{name} seurasi sinua", + "notification.favourite": "{name} tykkäsi statuksestasi", + "notification.reblog": "{name} boostasi statustasi", + "notification.mention": "{name} mainitsi sinut", + "notifications.column_settings.alert": "Työpöytä ilmoitukset", + "notifications.column_settings.show": "Näytä sarakkeessa", + "notifications.column_settings.follow": "Uusia seuraajia:", + "notifications.column_settings.favourite": "Tykkäyksiä:", + "notifications.column_settings.mention": "Mainintoja:", + "notifications.column_settings.reblog": "Boosteja:", +}; + +export default fi; diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx index 183e5d5b5..23fa9349c 100644 --- a/app/assets/javascripts/components/locales/fr.jsx +++ b/app/assets/javascripts/components/locales/fr.jsx @@ -16,42 +16,76 @@ const fr = { "account.unblock": "Débloquer", "account.unfollow": "Ne plus suivre", "account.block": "Bloquer", + "account.mute": "Masquer", + "account.unmute": "Ne plus masquer", "account.follow": "Suivre", "account.posts": "Statuts", "account.follows": "Abonnements", "account.followers": "Abonnés", "account.follows_you": "Vous suit", + "account.requested": "Invitation envoyée", + "account.report": "Signaler", + "account.disclaimer": "Ce compte est situé sur une autre instance. Les nombres peuvent être plus grands.", "getting_started.heading": "Pour commencer", - "getting_started.about_addressing": "Vous pouvez vous suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.", + "getting_started.about_addressing": "Vous pouvez suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.", "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.", "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social", + "getting_started.open_source_notice": "Mastodon est un logiciel libre. Vous pouvez contribuer et envoyer vos commentaires et rapports de bogues via {github} sur GitHub.", "column.home": "Accueil", - "column.mentions": "Mentions", - "column.public": "Fil public", + "column.community": "Fil public local", + "column.public": "Fil public global", "column.notifications": "Notifications", + "column.public": "Fil public", + "column.blocks": "Utilisateurs bloqués", + "column.favourites": "Favoris", "tabs_bar.compose": "Composer", "tabs_bar.home": "Accueil", "tabs_bar.mentions": "Mentions", - "tabs_bar.public": "Public", + "tabs_bar.public": "Fil public global", "tabs_bar.notifications": "Notifications", "compose_form.placeholder": "Qu’avez-vous en tête ?", - "compose_form.publish": "Pouet", - "compose_form.sensitive": "Marquer le contenu comme délicat", - "compose_form.unlisted": "Ne pas apparaître dans le fil public", + "compose_form.publish": "Pouet ", + "compose_form.sensitive": "Marquer le média comme délicat", + "compose_form.spoiler": "Masquer le texte par un avertissement", + "compose_form.private": "Rendre privé", + "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodons. Si {domains} {domainsCount, plural, one {n'est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n'y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d'une autre manière à d'autres personnes imprévues", + "compose_form.unlisted": "Ne pas afficher dans les fils publics", + "emoji_button.label": "Insérer un emoji", "navigation_bar.edit_profile": "Modifier le profil", "navigation_bar.preferences": "Préférences", - "navigation_bar.public_timeline": "Public", + "navigation_bar.community_timeline": "Fil public local", + "navigation_bar.public_timeline": "Fil public global", + "navigation_bar.blocks": "Utilisateurs bloqués", + "navigation_bar.favourites": "Favoris", + "navigation_bar.info": "Plus d'informations", + "notification.favourite": "{name} a ajouté à ses favoris :", "navigation_bar.logout": "Déconnexion", "reply_indicator.cancel": "Annuler", "search.placeholder": "Chercher", "search.account": "Compte", "search.hashtag": "Mot-clé", + "search_results.total": "{count} {count, plural, one {résultat} other {résultats}}", "upload_button.label": "Joindre un média", "upload_form.undo": "Annuler", "notification.follow": "{name} vous suit.", "notification.favourite": "{name} a ajouté à ses favoris :", "notification.reblog": "{name} a partagé votre statut :", - "notification.mention": "{name} vous a mentionné⋅e :" + "notification.mention": "{name} vous a mentionné⋅e :", + "notifications.column_settings.alert": "Notifications locales", + "notifications.column_settings.show": "Afficher dans la colonne", + "notifications.column_settings.follow": "Nouveaux abonnés :", + "notifications.column_settings.favourite": "Favoris :", + "notifications.column_settings.mention": "Mentions :", + "notifications.column_settings.reblog": "Partages :", + "privacy.public.short": "Public", + "privacy.public.long": "Afficher dans les fils publics", + "privacy.unlisted.short": "Non-listé", + "privacy.unlisted.long": "Ne pas afficher dans les fils publics", + "privacy.private.short": "Privé", + "privacy.private.long": "N’afficher que pour vos abonné⋅e⋅s", + "privacy.direct.short": "Direct", + "privacy.direct.long": "N’afficher que pour les personnes mentionné⋅e⋅s", + "privacy.change": "Ajuster la confidentialité du message", }; export default fr; diff --git a/app/assets/javascripts/components/locales/index.jsx b/app/assets/javascripts/components/locales/index.jsx index 203929d66..72b8a5df5 100644 --- a/app/assets/javascripts/components/locales/index.jsx +++ b/app/assets/javascripts/components/locales/index.jsx @@ -5,6 +5,7 @@ import hu from './hu'; import fr from './fr'; import pt from './pt'; import uk from './uk'; +import fi from './fi'; const locales = { en, @@ -13,7 +14,8 @@ const locales = { hu, fr, pt, - uk + uk, + fi }; export default function getMessagesForLocale (locale) { diff --git a/app/assets/javascripts/components/middleware/errors.jsx b/app/assets/javascripts/components/middleware/errors.jsx index 74d77f0f9..4aca75f1e 100644 --- a/app/assets/javascripts/components/middleware/errors.jsx +++ b/app/assets/javascripts/components/middleware/errors.jsx @@ -5,7 +5,7 @@ const defaultFailSuffix = 'FAIL'; export default function errorsMiddleware() { return ({ dispatch }) => next => action => { - if (action.type) { + if (action.type && !action.skipAlert) { const isFail = new RegExp(`${defaultFailSuffix}$`, 'g'); const isSuccess = new RegExp(`${defaultSuccessSuffix}$`, 'g'); diff --git a/app/assets/javascripts/components/middleware/sounds.jsx b/app/assets/javascripts/components/middleware/sounds.jsx new file mode 100644 index 000000000..200efa3d7 --- /dev/null +++ b/app/assets/javascripts/components/middleware/sounds.jsx @@ -0,0 +1,22 @@ +const play = audio => { + if (!audio.paused) { + audio.pause(); + audio.fastSeek(0); + } + + audio.play(); +}; + +export default function soundsMiddleware() { + const soundCache = { + boop: new Audio(['/sounds/boop.mp3']) + }; + + return ({ dispatch }) => next => (action) => { + if (action.meta && action.meta.sound && soundCache[action.meta.sound]) { + play(soundCache[action.meta.sound]); + } + + return next(action); + }; +}; diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index f3938cee1..df9440093 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -33,7 +33,7 @@ import { STATUS_FETCH_SUCCESS, CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; -import { SEARCH_SUGGESTIONS_READY } from '../actions/search'; +import { SEARCH_FETCH_SUCCESS } from '../actions/search'; import { NOTIFICATIONS_UPDATE, NOTIFICATIONS_REFRESH_SUCCESS, @@ -90,7 +90,6 @@ export default function accounts(state = initialState, action) { case REBLOGS_FETCH_SUCCESS: case FAVOURITES_FETCH_SUCCESS: case COMPOSE_SUGGESTIONS_READY: - case SEARCH_SUGGESTIONS_READY: case FOLLOW_REQUESTS_FETCH_SUCCESS: case FOLLOW_REQUESTS_EXPAND_SUCCESS: case BLOCKS_FETCH_SUCCESS: @@ -98,6 +97,7 @@ export default function accounts(state = initialState, action) { return normalizeAccounts(state, action.accounts); case NOTIFICATIONS_REFRESH_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); case TIMELINE_REFRESH_SUCCESS: case TIMELINE_EXPAND_SUCCESS: diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index 042a2c67d..4470ad643 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -20,7 +20,8 @@ import { COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_VISIBILITY_CHANGE, - COMPOSE_LISTABILITY_CHANGE + COMPOSE_LISTABILITY_CHANGE, + COMPOSE_EMOJI_INSERT } from '../actions/compose'; import { TIMELINE_DELETE } from '../actions/timelines'; import { STORE_HYDRATE } from '../actions/store'; @@ -31,10 +32,10 @@ const initialState = Immutable.Map({ sensitive: false, spoiler: false, spoiler_text: '', - unlisted: false, - private: false, + privacy: null, text: '', - fileDropDate: null, + focusDate: null, + preselectDate: null, in_reply_to: null, is_submitting: false, is_uploading: false, @@ -65,8 +66,7 @@ function clearAll(state) { map.set('spoiler_text', ''); map.set('is_submitting', false); map.set('in_reply_to', null); - map.set('unlisted', state.get('default_privacy') === 'unlisted'); - map.set('private', state.get('default_privacy') === 'private'); + map.set('privacy', state.get('default_privacy')); map.update('media_attachments', list => list.clear()); }); }; @@ -89,7 +89,7 @@ function removeMedia(state, mediaId) { map.update('text', text => text.replace(media.get('text_url'), '').trim()); if (prevSize === 1) { - map.update('sensitive', false); + map.set('sensitive', false); } }); }; @@ -99,9 +99,31 @@ const insertSuggestion = (state, position, token, completion) => { 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()); + map.set('focusDate', new Date()); }); }; +const insertEmoji = (state, position, emojiData) => { + const emoji = emojiData.shortname; + + return state.withMutations(map => { + map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`); + map.set('focusDate', new Date()); + }); +}; + +const privacyPreference = (a, b) => { + if (a === 'direct' || b === 'direct') { + return 'direct'; + } else if (a === 'private' || b === 'private') { + return 'private'; + } else if (a === 'unlisted' || b === 'unlisted') { + return 'unlisted'; + } else { + return 'public'; + } +}; + export default function compose(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: @@ -111,28 +133,38 @@ export default function compose(state = initialState, action) { case COMPOSE_UNMOUNT: return state.set('mounted', false); case COMPOSE_SENSITIVITY_CHANGE: - return state.set('sensitive', action.checked); + return state.set('sensitive', !state.get('sensitive')); case COMPOSE_SPOILERNESS_CHANGE: - return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked); + return state.withMutations(map => { + map.set('spoiler_text', ''); + map.set('spoiler', !state.get('spoiler')); + }); 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); + return state.set('privacy', action.value); 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)); + map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.set('focusDate', new Date()); + map.set('preselectDate', new Date()); + + if (action.status.get('spoiler_text').length > 0) { + map.set('spoiler', true); + map.set('spoiler_text', action.status.get('spoiler_text')); + } }); case COMPOSE_REPLY_CANCEL: return state.withMutations(map => { map.set('in_reply_to', null); map.set('text', ''); - map.set('unlisted', state.get('default_privacy') === 'unlisted'); - map.set('private', state.get('default_privacy') === 'private'); + map.set('spoiler', false); + map.set('spoiler_text', ''); + map.set('privacy', state.get('default_privacy')); }); case COMPOSE_SUBMIT_REQUEST: return state.set('is_submitting', true); @@ -143,7 +175,6 @@ export default function compose(state = initialState, action) { 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)); @@ -154,7 +185,7 @@ export default function compose(state = initialState, action) { 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')} `); + return state.update('text', text => `${text}@${action.account.get('acct')} `).set('focusDate', new Date()); case COMPOSE_SUGGESTIONS_CLEAR: return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); case COMPOSE_SUGGESTIONS_READY: @@ -167,6 +198,8 @@ export default function compose(state = initialState, action) { } else { return state; } + case COMPOSE_EMOJI_INSERT: + return insertEmoji(state, action.position, action.emoji); default: return state; } diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx index 0798116c4..147030cca 100644 --- a/app/assets/javascripts/components/reducers/index.jsx +++ b/app/assets/javascripts/components/reducers/index.jsx @@ -14,6 +14,7 @@ import notifications from './notifications'; import settings from './settings'; import status_lists from './status_lists'; import cards from './cards'; +import reports from './reports'; export default combineReducers({ timelines, @@ -30,5 +31,6 @@ export default combineReducers({ search, notifications, settings, - cards + cards, + reports }); diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx index 07da65771..3566820ef 100644 --- a/app/assets/javascripts/components/reducers/modal.jsx +++ b/app/assets/javascripts/components/reducers/modal.jsx @@ -1,31 +1,17 @@ -import { - MEDIA_OPEN, - MODAL_CLOSE, - MODAL_INDEX_DECREASE, - MODAL_INDEX_INCREASE -} from '../actions/modal'; +import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal'; import Immutable from 'immutable'; -const initialState = Immutable.Map({ - media: null, - index: 0, - open: false -}); +const initialState = { + modalType: null, + modalProps: {} +}; export default function modal(state = initialState, action) { switch(action.type) { - case MEDIA_OPEN: - return state.withMutations(map => { - map.set('media', action.media); - map.set('index', action.index); - map.set('open', true); - }); + case MODAL_OPEN: + return { modalType: action.modalType, modalProps: action.modalProps }; case MODAL_CLOSE: - return state.set('open', false); - case MODAL_INDEX_DECREASE: - return state.update('index', index => Math.max(index - 1, 0)); - case MODAL_INDEX_INCREASE: - return state.update('index', index => Math.min(index + 1, state.get('media').size - 1)); + return initialState; default: return state; } diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx index 4a7af8856..1406a388a 100644 --- a/app/assets/javascripts/components/reducers/notifications.jsx +++ b/app/assets/javascripts/components/reducers/notifications.jsx @@ -6,7 +6,8 @@ import { NOTIFICATIONS_EXPAND_REQUEST, NOTIFICATIONS_REFRESH_FAIL, NOTIFICATIONS_EXPAND_FAIL, - NOTIFICATIONS_CLEAR + NOTIFICATIONS_CLEAR, + NOTIFICATIONS_SCROLL_TOP } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import Immutable from 'immutable'; @@ -14,6 +15,8 @@ import Immutable from 'immutable'; const initialState = Immutable.Map({ items: Immutable.List(), next: null, + top: true, + unread: 0, loaded: false, isLoading: true }); @@ -26,6 +29,10 @@ const notificationToMap = notification => Immutable.Map({ }); const normalizeNotification = (state, notification) => { + if (!state.get('top')) { + state = state.update('unread', unread => unread + 1); + } + return state.update('items', list => list.unshift(notificationToMap(notification))); }; @@ -37,9 +44,12 @@ const normalizeNotifications = (state, notifications, next) => { items = items.set(i, notificationToMap(n)); }); + if (state.get('next') === null) { + state = state.set('next', next); + } + return state .update('items', list => loaded ? list.unshift(...items) : list.push(...items)) - .set('next', next) .set('loaded', true) .set('isLoading', false); }; @@ -61,6 +71,14 @@ const filterNotifications = (state, relationship) => { return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id)); }; +const updateTop = (state, top) => { + if (top) { + state = state.set('unread', 0); + } + + return state.set('top', top); +}; + export default function notifications(state = initialState, action) { switch(action.type) { case NOTIFICATIONS_REFRESH_REQUEST: @@ -68,6 +86,8 @@ export default function notifications(state = initialState, action) { case NOTIFICATIONS_REFRESH_FAIL: case NOTIFICATIONS_EXPAND_FAIL: return state.set('isLoading', true); + case NOTIFICATIONS_SCROLL_TOP: + return updateTop(state, action.top); case NOTIFICATIONS_UPDATE: return normalizeNotification(state, action.notification); case NOTIFICATIONS_REFRESH_SUCCESS: diff --git a/app/assets/javascripts/components/reducers/relationships.jsx b/app/assets/javascripts/components/reducers/relationships.jsx index e4af1f028..c65c48b43 100644 --- a/app/assets/javascripts/components/reducers/relationships.jsx +++ b/app/assets/javascripts/components/reducers/relationships.jsx @@ -3,6 +3,8 @@ import { ACCOUNT_UNFOLLOW_SUCCESS, ACCOUNT_BLOCK_SUCCESS, ACCOUNT_UNBLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_UNMUTE_SUCCESS, RELATIONSHIPS_FETCH_SUCCESS } from '../actions/accounts'; import Immutable from 'immutable'; @@ -21,14 +23,16 @@ const initialState = Immutable.Map(); export default function relationships(state = initialState, action) { switch(action.type) { - case ACCOUNT_FOLLOW_SUCCESS: - case ACCOUNT_UNFOLLOW_SUCCESS: - case ACCOUNT_BLOCK_SUCCESS: - case ACCOUNT_UNBLOCK_SUCCESS: - return normalizeRelationship(state, action.relationship); - case RELATIONSHIPS_FETCH_SUCCESS: - return normalizeRelationships(state, action.relationships); - default: - return state; + case ACCOUNT_FOLLOW_SUCCESS: + case ACCOUNT_UNFOLLOW_SUCCESS: + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_UNBLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + case ACCOUNT_UNMUTE_SUCCESS: + return normalizeRelationship(state, action.relationship); + case RELATIONSHIPS_FETCH_SUCCESS: + return normalizeRelationships(state, action.relationships); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/reports.jsx b/app/assets/javascripts/components/reducers/reports.jsx new file mode 100644 index 000000000..e1cce1c5f --- /dev/null +++ b/app/assets/javascripts/components/reducers/reports.jsx @@ -0,0 +1,57 @@ +import { + REPORT_INIT, + REPORT_SUBMIT_REQUEST, + REPORT_SUBMIT_SUCCESS, + REPORT_SUBMIT_FAIL, + REPORT_CANCEL, + REPORT_STATUS_TOGGLE +} from '../actions/reports'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + isSubmitting: false, + account_id: null, + status_ids: Immutable.Set(), + comment: '' + }) +}); + +export default function reports(state = initialState, action) { + switch(action.type) { + case REPORT_INIT: + return state.withMutations(map => { + map.setIn(['new', 'isSubmitting'], false); + map.setIn(['new', 'account_id'], action.account.get('id')); + + if (state.getIn(['new', 'account_id']) !== action.account.get('id')) { + map.setIn(['new', 'status_ids'], action.status ? Immutable.Set([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : Immutable.Set()); + map.setIn(['new', 'comment'], ''); + } else { + map.updateIn(['new', 'status_ids'], Immutable.Set(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id')))); + } + }); + case REPORT_STATUS_TOGGLE: + return state.updateIn(['new', 'status_ids'], Immutable.Set(), set => { + if (action.checked) { + return set.add(action.statusId); + } + + return set.remove(action.statusId); + }); + case REPORT_SUBMIT_REQUEST: + return state.setIn(['new', 'isSubmitting'], true); + case REPORT_SUBMIT_FAIL: + return state.setIn(['new', 'isSubmitting'], false); + case REPORT_CANCEL: + case REPORT_SUBMIT_SUCCESS: + return state.withMutations(map => { + map.setIn(['new', 'account_id'], null); + map.setIn(['new', 'status_ids'], Immutable.Set()); + map.setIn(['new', 'comment'], ''); + map.setIn(['new', 'isSubmitting'], false); + }); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx index d835ef268..b3fe6c7be 100644 --- a/app/assets/javascripts/components/reducers/search.jsx +++ b/app/assets/javascripts/components/reducers/search.jsx @@ -1,38 +1,64 @@ import { SEARCH_CHANGE, - SEARCH_SUGGESTIONS_READY, - SEARCH_RESET + SEARCH_CLEAR, + SEARCH_FETCH_SUCCESS, + SEARCH_SHOW } from '../actions/search'; +import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose'; import Immutable from 'immutable'; const initialState = Immutable.Map({ value: '', - loaded_value: '', - suggestions: [] + submitted: false, + hidden: false, + results: Immutable.Map() }); -const normalizeSuggestions = (state, value, accounts) => { - let newSuggestions = [ - { +const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => { + let newSuggestions = []; + + if (accounts.length > 0) { + newSuggestions.push({ title: 'account', items: accounts.map(item => ({ type: 'account', id: item.id, value: item.acct })) + }); + } + + if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 || hashtags.length > 0) { + let hashtagItems = hashtags.map(item => ({ + type: 'hashtag', + id: item, + value: `#${item}` + })); + + if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 && !value.startsWith('http://') && !value.startsWith('https://') && hashtags.indexOf(value) === -1) { + hashtagItems.unshift({ + type: 'hashtag', + id: value, + value: `#${value}` + }); } - ]; - if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) { + if (hashtagItems.length > 0) { + newSuggestions.push({ + title: 'hashtag', + items: hashtagItems + }); + } + } + + if (statuses.length > 0) { newSuggestions.push({ - title: 'hashtag', - items: [ - { - type: 'hashtag', - id: value, - value: `#${value}` - } - ] + title: 'status', + items: statuses.map(item => ({ + type: 'status', + id: item.id, + value: item.id + })) }); } @@ -44,17 +70,27 @@ const normalizeSuggestions = (state, value, accounts) => { export default function search(state = initialState, action) { switch(action.type) { - case SEARCH_CHANGE: - return state.set('value', action.value); - case SEARCH_SUGGESTIONS_READY: - return normalizeSuggestions(state, action.value, action.accounts); - case SEARCH_RESET: - return state.withMutations(map => { - map.set('suggestions', []); - map.set('value', ''); - map.set('loaded_value', ''); - }); - default: - return state; + case SEARCH_CHANGE: + return state.set('value', action.value); + case SEARCH_CLEAR: + return state.withMutations(map => { + map.set('value', ''); + map.set('results', Immutable.Map()); + map.set('submitted', false); + map.set('hidden', false); + }); + case SEARCH_SHOW: + return state.set('hidden', false); + case COMPOSE_REPLY: + case COMPOSE_MENTION: + return state.set('hidden', true); + case SEARCH_FETCH_SUCCESS: + return state.set('results', Immutable.Map({ + accounts: Immutable.List(action.results.accounts.map(item => item.id)), + statuses: Immutable.List(action.results.statuses.map(item => item.id)), + hashtags: Immutable.List(action.results.hashtags) + })).set('submitted', true); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx index 6323e0fbe..ca8fa7a01 100644 --- a/app/assets/javascripts/components/reducers/statuses.jsx +++ b/app/assets/javascripts/components/reducers/statuses.jsx @@ -32,6 +32,7 @@ import { FAVOURITED_STATUSES_FETCH_SUCCESS, FAVOURITED_STATUSES_EXPAND_SUCCESS } from '../actions/favourites'; +import { SEARCH_FETCH_SUCCESS } from '../actions/search'; import Immutable from 'immutable'; const normalizeStatus = (state, status) => { @@ -39,14 +40,15 @@ const normalizeStatus = (state, status) => { return state; } - status.account = status.account.id; + const normalStatus = { ...status }; + normalStatus.account = status.account.id; if (status.reblog && status.reblog.id) { - state = normalizeStatus(state, status.reblog); - status.reblog = status.reblog.id; + state = normalizeStatus(state, status.reblog); + normalStatus.reblog = status.reblog.id; } - return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(status))); + return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(normalStatus))); }; const normalizeStatuses = (state, statuses) => { @@ -107,6 +109,7 @@ export default function statuses(state = initialState, action) { case NOTIFICATIONS_EXPAND_SUCCESS: case FAVOURITED_STATUSES_FETCH_SUCCESS: case FAVOURITED_STATUSES_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: return normalizeStatuses(state, action.statuses); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index 6f2d26dcb..675a52759 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -7,7 +7,9 @@ import { TIMELINE_EXPAND_SUCCESS, TIMELINE_EXPAND_REQUEST, TIMELINE_EXPAND_FAIL, - TIMELINE_SCROLL_TOP + TIMELINE_SCROLL_TOP, + TIMELINE_CONNECT, + TIMELINE_DISCONNECT } from '../actions/timelines'; import { REBLOG_SUCCESS, @@ -22,7 +24,8 @@ import { ACCOUNT_TIMELINE_EXPAND_REQUEST, ACCOUNT_TIMELINE_EXPAND_SUCCESS, ACCOUNT_TIMELINE_EXPAND_FAIL, - ACCOUNT_BLOCK_SUCCESS + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS } from '../actions/accounts'; import { CONTEXT_FETCH_SUCCESS @@ -31,31 +34,47 @@ import Immutable from 'immutable'; const initialState = Immutable.Map({ home: Immutable.Map({ + path: () => '/api/v1/timelines/home', + next: null, isLoading: false, + online: false, loaded: false, top: true, + unread: 0, items: Immutable.List() }), - mentions: Immutable.Map({ + public: Immutable.Map({ + path: () => '/api/v1/timelines/public', + next: null, isLoading: false, + online: false, loaded: false, top: true, + unread: 0, items: Immutable.List() }), - public: Immutable.Map({ + community: Immutable.Map({ + path: () => '/api/v1/timelines/public', + next: null, + params: { local: true }, isLoading: false, + online: false, loaded: false, top: true, + unread: 0, items: Immutable.List() }), tag: Immutable.Map({ + path: (id) => `/api/v1/timelines/tag/${id}`, + next: null, isLoading: false, id: null, loaded: false, top: true, + unread: 0, items: Immutable.List() }), @@ -81,7 +100,7 @@ const normalizeStatus = (state, status) => { return state; }; -const normalizeTimeline = (state, timeline, statuses, replace = false) => { +const normalizeTimeline = (state, timeline, statuses, next) => { let ids = Immutable.List(); const loaded = state.getIn([timeline, 'loaded']); @@ -93,10 +112,14 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => { state = state.setIn([timeline, 'loaded'], true); state = state.setIn([timeline, 'isLoading'], false); + if (state.getIn([timeline, 'next']) === null) { + state = state.setIn([timeline, 'next'], next); + } + return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids)); }; -const appendNormalizedTimeline = (state, timeline, statuses) => { +const appendNormalizedTimeline = (state, timeline, statuses, next) => { let moreIds = Immutable.List(); statuses.forEach((status, i) => { @@ -105,6 +128,7 @@ const appendNormalizedTimeline = (state, timeline, statuses) => { }); state = state.setIn([timeline, 'isLoading'], false); + state = state.setIn([timeline, 'next'], next); return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds)); }; @@ -141,6 +165,10 @@ const updateTimeline = (state, timeline, status, references) => { state = normalizeStatus(state, status); + if (!top) { + state = state.updateIn([timeline, 'unread'], unread => unread + 1); + } + state = state.updateIn([timeline, 'items'], Immutable.List(), list => { if (top && list.size > 40) { list = list.take(20); @@ -169,7 +197,7 @@ const deleteStatus = (state, id, accountId, references, reblogOf) => { } // Remove references from timelines - ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) { + ['home', 'public', 'community', 'tag'].forEach(function (timeline) { state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); }); @@ -221,11 +249,13 @@ const normalizeContext = (state, id, ancestors, descendants) => { }; const resetTimeline = (state, timeline, id) => { - if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) { + if (timeline === 'tag' && typeof id !== 'undefined' && state.getIn([timeline, 'id']) !== id) { state = state.update(timeline, map => map .set('id', id) .set('isLoading', true) .set('loaded', false) + .set('next', null) + .set('top', true) .update('items', list => list.clear())); } else { state = state.setIn([timeline, 'isLoading'], true); @@ -234,6 +264,14 @@ const resetTimeline = (state, timeline, id) => { return state; }; +const updateTop = (state, timeline, top) => { + if (top) { + state = state.setIn([timeline, 'unread'], 0); + } + + return state.setIn([timeline, 'top'], top); +}; + export default function timelines(state = initialState, action) { switch(action.type) { case TIMELINE_REFRESH_REQUEST: @@ -243,9 +281,9 @@ export default function timelines(state = initialState, action) { case TIMELINE_EXPAND_FAIL: return state.setIn([action.timeline, 'isLoading'], false); case TIMELINE_REFRESH_SUCCESS: - return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); + return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next); case TIMELINE_EXPAND_SUCCESS: - return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); + return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next); case TIMELINE_UPDATE: return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references); case TIMELINE_DELETE: @@ -263,9 +301,14 @@ export default function timelines(state = initialState, action) { case ACCOUNT_TIMELINE_EXPAND_SUCCESS: return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: return filterTimelines(state, action.relationship, action.statuses); case TIMELINE_SCROLL_TOP: - return state.setIn([action.timeline, 'top'], action.top); + return updateTop(state, action.timeline, action.top); + case TIMELINE_CONNECT: + return state.setIn([action.timeline, 'online'], true); + case TIMELINE_DISCONNECT: + return state.setIn([action.timeline, 'online'], false); default: return state; } diff --git a/app/assets/javascripts/components/rtl.jsx b/app/assets/javascripts/components/rtl.jsx new file mode 100644 index 000000000..8f14bb338 --- /dev/null +++ b/app/assets/javascripts/components/rtl.jsx @@ -0,0 +1,27 @@ +// U+0590 to U+05FF - Hebrew +// U+0600 to U+06FF - Arabic +// U+0700 to U+074F - Syriac +// U+0750 to U+077F - Arabic Supplement +// U+0780 to U+07BF - Thaana +// U+07C0 to U+07FF - N'Ko +// U+0800 to U+083F - Samaritan +// U+08A0 to U+08FF - Arabic Extended-A +// U+FB1D to U+FB4F - Hebrew presentation forms +// U+FB50 to U+FDFF - Arabic presentation forms A +// U+FE70 to U+FEFF - Arabic presentation forms B + +const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg; + +export function isRtl(text) { + if (text.length === 0) { + return false; + } + + const matches = text.match(rtlChars); + + if (!matches) { + return false; + } + + return matches.length / text.trim().length > 0.3; +}; diff --git a/app/assets/javascripts/components/selectors/index.jsx b/app/assets/javascripts/components/selectors/index.jsx index 20debe604..01a6cb264 100644 --- a/app/assets/javascripts/components/selectors/index.jsx +++ b/app/assets/javascripts/components/selectors/index.jsx @@ -1,11 +1,11 @@ -import { createSelector } from 'reselect' +import { createSelector } from 'reselect'; import Immutable from 'immutable'; const getStatuses = state => state.get('statuses'); const getAccounts = state => state.get('accounts'); const getAccountBase = (state, id) => state.getIn(['accounts', id], null); -const getAccountRelationship = (state, id) => state.getIn(['relationships', id]); +const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null); export const makeGetAccount = () => { return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => { @@ -17,37 +17,32 @@ export const makeGetAccount = () => { }); }; -const getStatusBase = (state, id) => state.getIn(['statuses', id], null); - export const makeGetStatus = () => { - return createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => { - if (base === null) { - return null; + return createSelector( + [ + (state, id) => state.getIn(['statuses', id]), + (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), + (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), + (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), + ], + + (statusBase, statusReblog, accountBase, accountReblog) => { + if (!statusBase) { + return null; + } + + if (statusReblog) { + statusReblog = statusReblog.set('account', accountReblog); + } else { + statusReblog = null; + } + + return statusBase.withMutations(map => { + map.set('reblog', statusReblog); + map.set('account', accountBase); + }); } - - return assembleStatus(base.get('id'), statuses, accounts); - }); -}; - -const assembleStatus = (id, statuses, accounts) => { - let status = statuses.get(id, null); - let reblog = null; - - if (status === null) { - return null; - } - - if (status.get('reblog', null) !== null) { - reblog = statuses.get(status.get('reblog'), null); - - if (reblog !== null) { - reblog = reblog.set('account', accounts.get(reblog.get('account'))); - } else { - return null; - } - } - - return status.set('reblog', reblog).set('account', accounts.get(status.get('account'))); + ); }; const getAlertsBase = state => state.get('alerts'); diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx index ad0427b52..a92d756f5 100644 --- a/app/assets/javascripts/components/store/configureStore.jsx +++ b/app/assets/javascripts/components/store/configureStore.jsx @@ -3,21 +3,14 @@ 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 soundsMiddleware from '../middleware/sounds'; import Immutable from 'immutable'; -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) + soundsMiddleware() ), window.devToolsExtension ? window.devToolsExtension() : f => f)); }; diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx index 5738863dd..c13feceff 100644 --- a/app/assets/javascripts/extras.jsx +++ b/app/assets/javascripts/extras.jsx @@ -24,4 +24,17 @@ $(() => { window.location.href = $(e.target).attr('href'); } }); + + $('.status__content__spoiler-link').on('click', e => { + e.preventDefault(); + const contentEl = $(e.target).parent().parent().find('div'); + + if (contentEl.is(':visible')) { + contentEl.hide(); + $(e.target).parent().attr('style', 'margin-bottom: 0'); + } else { + contentEl.show(); + $(e.target).parent().attr('style', null); + } + }); }); diff --git a/app/assets/stylesheets/about.scss b/app/assets/stylesheets/about.scss index f29090f1a..c9d9dc5d5 100644 --- a/app/assets/stylesheets/about.scss +++ b/app/assets/stylesheets/about.scss @@ -95,6 +95,7 @@ .actions { overflow: hidden; + margin-bottom: 20px; .info { float: right; @@ -108,10 +109,18 @@ } } - @media screen and (max-width: 360px) { + @media screen and (max-width: 625px) { .wrapper { padding: 20px; } + + .screenshot-with-signup .mascot { + display: none; + } + + .features-list { + display: block; + } } } @@ -273,3 +282,69 @@ } } } + +.features-list { + display: flex; + margin-bottom: 20px; + + .features-list__column { + flex: 1 1 0; + + ul { + list-style: none; + } + + li { + margin: 0; + } + } +} + +.screenshot-with-signup { + display: flex; + margin-bottom: 20px; + + .mascot { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + + img { + display: block; + margin: 0 auto; + max-width: 100%; + height: auto; + } + } + + .simple_form, .closed-registrations-message { + width: 300px; + flex: 0 0 auto; + background: rgba(darken($color1, 7%), 0.5); + padding: 14px; + border-radius: 4px; + box-shadow: 0 0 15px rgba($color8, 0.4); + + .actions { + margin-bottom: 0; + } + + .info { + text-align: center; + + a { + color: $color2; + } + } + } +} + +.closed-registrations-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} diff --git a/app/assets/stylesheets/accounts.scss b/app/assets/stylesheets/accounts.scss index 7c48c91f3..25e24a95a 100644 --- a/app/assets/stylesheets/accounts.scss +++ b/app/assets/stylesheets/accounts.scss @@ -311,6 +311,7 @@ padding: 10px; padding-top: 15px; color: $color3; + word-wrap: break-word; } } } diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss index d834096f4..e27b88e5f 100644 --- a/app/assets/stylesheets/admin.scss +++ b/app/assets/stylesheets/admin.scss @@ -76,6 +76,7 @@ .content-wrapper { flex: 2; + overflow: auto; } .content { @@ -92,7 +93,7 @@ margin-bottom: 40px; } - p { + & > p { font-size: 14px; line-height: 18px; color: $color2; @@ -103,6 +104,13 @@ font-weight: 500; } } + + hr { + margin: 20px 0; + border: 0; + background: transparent; + border-bottom: 1px solid $color1; + } } .simple_form { @@ -179,3 +187,45 @@ } } } + +.report-accounts { + display: flex; + margin-bottom: 20px; +} + +.report-accounts__item { + flex: 1 1 0; + display: flex; + flex-direction: column; + + & > strong { + display: block; + margin-bottom: 10px; + font-weight: 500; + font-size: 14px; + line-height: 18px; + color: $color2; + } + + &:first-child { + margin-right: 10px; + } + + .account-card { + flex: 1 1 auto; + } +} + +.report-status { + display: flex; + margin-bottom: 10px; + + .activity-stream { + flex: 2 0 0; + margin-right: 20px; + } +} + +.report-status__actions { + flex: 0 0 auto; +} diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index c4c876e30..ba16d4a21 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -256,6 +256,53 @@ button:focus { } } +.compact-header { + h1 { + font-size: 24px; + line-height: 28px; + color: $color3; + overflow: hidden; + font-weight: 500; + margin-bottom: 20px; + + a { + color: inherit; + text-decoration: none; + } + + small { + font-weight: 400; + color: $color2; + } + + img { + display: inline-block; + margin-bottom: -5px; + margin-right: 15px; + width: 36px; + height: 36px; + } + } +} + +.landing-strip { + background: rgba(darken($color1, 7%), 0.8); + color: $color3; + font-weight: 400; + padding: 14px; + border-radius: 4px; + margin-bottom: 20px; + + strong, a { + font-weight: 500; + } + + a { + color: inherit; + text-decoration: underline; + } +} + @import 'forms'; @import 'accounts'; @import 'stream_entries'; diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 6bb683f17..d233b3471 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -1,3 +1,5 @@ +@import 'variables'; + .button { background-color: darken($color4, 3%); font-family: inherit; @@ -17,9 +19,11 @@ line-height: 36px; border-radius: 4px; text-decoration: none; + transition: all 100ms ease-in; - &:hover { + &:hover, &:active, &:focus { background-color: lighten($color4, 7%); + transition: all 200ms ease-out; } &:disabled { @@ -34,6 +38,7 @@ .column-icon { color: $color3; + background: lighten($color1, 4%); &:hover { color: lighten($color3, 7%); @@ -41,13 +46,17 @@ } .icon-button { + display: inline-block; + padding: 0; color: lighten($color1, 26%); border: none; background: transparent; cursor: pointer; + transition: all 100ms ease-in; - &:hover { + &:hover, &:active, &:focus { color: lighten($color1, 33%); + transition: all 200ms ease-out; } &.disabled { @@ -58,6 +67,69 @@ &.active { color: $color4; } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, &:focus, &:active { + outline: 0 !important; + } + + &.inverted { + color: lighten($color1, 33%); + + &:hover, &:active, &:focus { + color: lighten($color1, 26%); + } + + &.active { + color: $color4; + } + + &.disabled { + color: $color3; + } + } +} + +.text-icon-button { + color: lighten($color1, 33%); + border: none; + background: transparent; + cursor: pointer; + font-weight: 600; + font-size: 11px; + padding: 0 3px; + line-height: 27px; + outline: 0; + transition: all 100ms ease-in; + + &:hover, &:active, &:focus { + color: lighten($color1, 26%); + transition: all 200ms ease-out; + } + + &.disabled { + color: lighten($color1, 13%); + cursor: default; + } + + &.active { + color: $color4; + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, &:focus, &:active { + outline: 0 !important; + } +} + +.dropdown--active .icon-button { + color: $color4; } .invisible { @@ -77,6 +149,42 @@ color: $color1; } +.compose-form__warning { + color: $color2; + margin-bottom: 15px; + border: 1px solid $color3; + padding: 8px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 400; + + strong { + color: $color5; + font-weight: 500; + } +} + +.compose-form__modifiers { + color: $color1; + font-family: inherit; + font-size: 14px; + background: $color5; + border-radius: 0 0 4px 0; +} + +.compose-form__buttons { + padding: 10px; + background: darken($color5, 8%); + box-shadow: inset 0 5px 5px rgba($color8, 0.05); + border-radius: 0 0 4px 4px; + display: flex; + + .icon-button { + box-sizing: content-box; + padding: 0 3px; + } +} + .compose-form__label { display: block; line-height: 24px; @@ -118,6 +226,9 @@ } .reply-indicator { + border-radius: 4px 4px 0 0; + position: relative; + bottom: -2px; background: $color3; padding: 10px; @@ -187,7 +298,7 @@ a.status__content__spoiler-link { display: inline-block; border-radius: 2px; - color: lighten($color1, 6%); + color: lighten($color1, 8%); font-weight: 500; font-size: 11px; padding: 0px 6px; @@ -200,7 +311,7 @@ a.status__content__spoiler-link { padding-left: 68px; position: relative; min-height: 48px; - border-bottom: 1px solid lighten($color1, 6%); + border-bottom: 1px solid lighten($color1, 8%); cursor: default; .status__relative-time { @@ -212,6 +323,14 @@ a.status__content__spoiler-link { } } +.status-check-box { + border-bottom: 1px solid lighten($color1, 8%); + + .status__content { + background: lighten($color1, 4%); + } +} + .status__prepend { margin-left: 68px; color: lighten($color1, 26%); @@ -226,6 +345,8 @@ a.status__content__spoiler-link { } .detailed-status { + background: lighten($color1, 4%); + .status__content { font-size: 19px; line-height: 24px; @@ -237,12 +358,19 @@ a.status__content__spoiler-link { } } +.detailed-status__meta { + margin-top: 15px; + color: lighten($color1, 26%); + font-size: 14px; + line-height: 18px; +} + .detailed-status__action-bar { background: lighten($color1, 4%); display: flex; flex-direction: row; - border-top: 1px solid lighten($color1, 6%); - border-bottom: 1px solid lighten($color1, 6%); + border-top: 1px solid lighten($color1, 8%); + border-bottom: 1px solid lighten($color1, 8%); padding: 10px 0; } @@ -257,7 +385,7 @@ a.status__content__spoiler-link { .account { padding: 10px; - border-bottom: 1px solid lighten($color1, 6%); + border-bottom: 1px solid lighten($color1, 8%); .account__display-name { flex: 1 1 auto; @@ -296,8 +424,10 @@ a.status__content__spoiler-link { .account__header__content { word-wrap: break-word; + word-break: normal; font-weight: 400; overflow: hidden; + color: $color3; p { margin-bottom: 20px; @@ -325,8 +455,8 @@ a.status__content__spoiler-link { } .account__action-bar { - border-top: 1px solid lighten($color1, 6%); - border-bottom: 1px solid lighten($color1, 6%); + border-top: 1px solid lighten($color1, 8%); + border-bottom: 1px solid lighten($color1, 8%); line-height: 36px; overflow: hidden; flex: 0 0 auto; @@ -337,7 +467,7 @@ a.status__content__spoiler-link { text-decoration: none; overflow: hidden; width: 80px; - border-left: 1px solid lighten($color1, 6%); + border-left: 1px solid lighten($color1, 8%); padding: 10px 5px; & > span { @@ -353,6 +483,10 @@ a.status__content__spoiler-link { font-weight: 500; color: $color5; } + + abbr { + color: lighten($color1, 26%); + } } .status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .detailed-status__application, .account__display-name { @@ -412,8 +546,9 @@ a.status__content__spoiler-link { opacity: 0.5; } - .status__content__spoiler-link { + a.status__content__spoiler-link { background: lighten($color1, 26%); + color: lighten($color1, 4%); &:hover { background: lighten($color1, 29%); @@ -422,6 +557,20 @@ a.status__content__spoiler-link { } } +.notification__message { + margin-left: 68px; + padding: 8px 0; + padding-bottom: 0; + cursor: default; + color: $color3; + font-size: 15px; + position: relative; + + .fa { + color: $color4; + } +} + .notification__display-name { color: inherit; text-decoration: none; @@ -467,6 +616,12 @@ a.status__content__spoiler-link { position: absolute; } +.dropdown__sep { + border-bottom: 1px solid darken($color2, 8%); + margin: 5px 7px 6px; + padding-top: 1px; +} + .dropdown--active .dropdown__content { display: block; z-index: 9999; @@ -484,23 +639,44 @@ a.status__content__spoiler-link { left: 8px; } - ul { + & > ul { list-style: none; background: $color2; padding: 4px 0; border-radius: 4px; box-shadow: 0 0 15px rgba($color8, 0.4); - min-width: 100px; + min-width: 140px; + position: relative; + left: -10px; } - a { + &.dropdown__left { + & > ul { + left: -98px; + } + + & > .emoji-dialog { + left: -249px; + } + } + + & > ul > li > a { font-size: 13px; + line-height: 18px; display: block; - padding: 6px 16px; - width: 100px; + padding: 4px 14px; + box-sizing: border-box; + width: 140px; text-decoration: none; background: $color2; color: $color1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:focus { + outline: 0; + } &:hover { background: $color4; @@ -557,7 +733,7 @@ a.status__content__spoiler-link { } .drawer { - width: 280px; + width: 300px; box-sizing: border-box; display: flex; flex-direction: column; @@ -589,14 +765,32 @@ a.status__content__spoiler-link { } } +.drawer__pager { + box-sizing: border-box; + padding: 0; + flex-grow: 1; + position: relative; + overflow: hidden; + display: flex; +} + .drawer__inner { - background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65)); + position: absolute; + top: 0; + left: 0; + background: lighten($color1, 13%); box-sizing: border-box; padding: 0; display: flex; flex-direction: column; + overflow: hidden; overflow-y: auto; - flex-grow: 1; + width: 100%; + height: 100%; + + &.darker { + background: $color1; + } } .drawer__header { @@ -642,11 +836,15 @@ a.status__content__spoiler-link { .columns-area { flex-direction: column; } + + .search__input, .autosuggest-textarea__textarea { + font-size: 16px; + } } .tabs-bar { display: flex; - background: lighten($color1, 6%); + background: lighten($color1, 8%); flex: 0 0 auto; overflow-y: auto; } @@ -660,12 +858,26 @@ a.status__content__spoiler-link { text-align: center; font-size:12px; font-weight: 500; - border-bottom: 2px solid lighten($color1, 6%); + border-bottom: 2px solid lighten($color1, 8%); + transition: all 200ms linear; + + .fa { + font-weight: 400; + } &.active { border-bottom: 2px solid $color4; color: $color4; } + + &:hover, &:focus, &:active { + background: lighten($color1, 14%); + transition: all 100ms linear; + } + + span { + display: none; + } } @media screen and (min-width: 360px) { @@ -673,6 +885,22 @@ a.status__content__spoiler-link { margin: 10px; margin-bottom: 0; } + + .search { + margin-bottom: 10px; + } +} + +@media screen and (min-width: 600px) { + .tabs-bar__link { + .fa { + margin-right: 5px; + } + + span { + display: inline; + } + } } @media screen and (min-width: 1025px) { @@ -737,6 +965,7 @@ a.status__content__spoiler-link { flex: 0 0 auto; cursor: pointer; color: $color4; + z-index: 3; &:hover { text-decoration: underline; @@ -850,7 +1079,8 @@ a.status__content__spoiler-link { } .column-link { - background: lighten($color1, 6%); + background: lighten($color1, 8%); + color: $color5; &:hover { background: lighten($color1, 11%); @@ -868,21 +1098,28 @@ a.status__content__spoiler-link { resize: none; margin: 0; color: $color1; - padding: 7px; + padding: 10px; font-family: inherit; font-size: 14px; resize: vertical; + border: 0; + outline: 0; - border: 3px dashed transparent; - transition: border-color 0.3s ease; - - &.file-drop { - border-color: darken($color5, 33%); + &:focus { + outline: 0; } } +.spoiler-input__input { + border-radius: 4px; +} + .autosuggest-textarea__textarea { height: 100px; + background: $color5; + border-radius: 4px 4px 0 0; + padding-bottom: 0; + padding-right: 10px + 22px; } .autosuggest-textarea__suggestions { @@ -912,11 +1149,9 @@ a.status__content__spoiler-link { .getting-started { box-sizing: border-box; - overflow-y: auto; padding-bottom: 235px; - background: image-url('mastodon-getting-started.png') no-repeat bottom left; - height: auto; - min-height: 100%; + background: image-url('mastodon-getting-started.png') no-repeat 0 100% local; + flex: 1 0 auto; p { color: $color2; @@ -927,15 +1162,6 @@ a.status__content__spoiler-link { } } -.dropdown__content.dropdown__left { - transform: translateX(-108px); - - &::before { - right: 8px !important; - left: initial !important; - } -} - .setting-text { color: $color3; background: transparent; @@ -968,11 +1194,40 @@ button.active i.fa-retweet { } .status-card { + display: flex; + cursor: pointer; + font-size: 14px; + border: 1px solid lighten($color1, 8%); + border-radius: 4px; + color: lighten($color1, 26%); + margin-top: 14px; + text-decoration: none; + overflow: hidden; + &:hover { - background: lighten($color1, 6%); + background: lighten($color1, 8%); } } +.status-card__title { + display: block; + font-weight: 500; + margin-bottom: 5px; + color: $color3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-card__description { + color: $color3; +} + +.status-card__image { + flex: 0 0 100px; + background: lighten($color1, 8%); +} + .load-more { display: block; color: lighten($color1, 26%); @@ -981,7 +1236,7 @@ button.active i.fa-retweet { text-decoration: none; &:hover { - background: lighten($color1, 6%); + background: lighten($color1, 2%); } } @@ -989,8 +1244,10 @@ button.active i.fa-retweet { text-align: center; font-size: 16px; font-weight: 500; - color: lighten($color1, 26%); - padding-top: 120px; + color: lighten($color1, 16%); + padding-top: 210px; + background: image-url('mastodon-not-found.png') no-repeat center -50px; + cursor: default; } .column-header { @@ -999,15 +1256,507 @@ button.active i.fa-retweet { background: lighten($color1, 4%); flex: 0 0 auto; cursor: pointer; + position: relative; + z-index: 2; + + &.active { + box-shadow: 0 1px 0 rgba($color4, 0.3); + } + + &.active .fa { + color: $color4; + text-shadow: 0 0 10px rgba($color4, 0.4); + } } -.search { +.loading-indicator { + color: $color2; +} + +.collapsable-collapsed { + color: $color3; + background: lighten($color1, 4%); +} + +.collapsable { + color: $color5; + background: lighten($color1, 8%); + + &:hover { + color: $color5; + background: lighten($color1, 8%); + } +} + +.media-spoiler { + background: $color8; + color: $color5; +} + +.modal-container--preloader { + background: lighten($color1, 8%); +} + +.account--panel { + background: lighten($color1, 4%); + border-top: 1px solid lighten($color1, 8%); + border-bottom: 1px solid lighten($color1, 8%); +} + +.column-settings--outer { + background: lighten($color1, 8%); +} + +.column-settings--section { + color: $color3; +} + +.modal-container__nav { + color: $color5; +} + +.account--follows-info { + color: $color5; +} + +.setting-toggle { + color: $color3; +} + +.report__target { + border-bottom: 1px solid lighten($color1, 4%); + color: $color2; + padding-bottom: 10px; + + strong { + display: block; + color: $color5; + font-weight: 500; + } +} + +.report__textarea { + background: transparent; + box-sizing: border-box; + border: 0; + border-bottom: 2px solid $color3; + border-radius: 2px 2px 0 0; + padding: 7px 4px; + font-size: 14px; + color: $color5; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + resize: vertical; + + &:active, &:focus { + border-bottom-color: $color4; + background: rgba($color8, 0.1); + } +} + +.empty-column-indicator { + color: lighten($color1, 20%); + text-align: center; + padding: 20px; + padding-top: 100px; + font-size: 15px; + font-weight: 400; + cursor: default; + + a { + color: $color4; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.status-list__unread-indicator, .notifications__unread-indicator { + position: absolute; + top: 35px; + left: 0; + right: 0; + margin: 0 auto; + width: 60%; + pointer-events: none; + height: 28px; + z-index: 1; + background: radial-gradient(ellipse, rgba($color4, 0.23) 0%, rgba($color4, 0) 60%); +} + +.emoji-dialog { + width: 280px; + height: 220px; + background: $color2; + box-sizing: border-box; + border-radius: 2px; + overflow: hidden; + position: relative; + box-shadow: 0 0 15px rgba($color8, 0.4); + + .emojione { + margin: 0; + } + + .emoji-dialog-header { + padding: 0 10px; + background-color: $color3; + + ul { + padding: 0; + margin: 0; + list-style: none; + } + + li { + display: inline-block; + box-sizing: border-box; + height: 42px; + padding: 9px 5px; + cursor: pointer; + + img, svg { + width: 22px; + height: 22px; + filter: grayscale(100%); + } + + &.active { + background: lighten($color3, 6%); + + img, svg { + filter: grayscale(0); + } + } + } + } + + .emoji-row { + box-sizing: border-box; + overflow-y: hidden; + padding-left: 10px; + + .emoji { + display: inline-block; + padding: 5px; + border-radius: 4px; + } + } + + .emoji-category-header { + box-sizing: border-box; + overflow-y: hidden; + padding: 8px 16px 0; + display: table; + + > * { + display: table-cell; + vertical-align: middle; + } + } + + .emoji-category-title { + font-size: 14px; + font-family: sans-serif; + font-weight: normal; + color: $color1; + cursor: default; + } + + .emoji-category-heading-decoration { + text-align: right; + } + + .modifiers { + list-style: none; + padding: 0; + margin: 0; + vertical-align: middle; + white-space: nowrap; + margin-top: 4px; + + li { + display: inline-block; + padding: 0 2px; + + &:last-of-type { + padding-right: 0; + } + } + + .modifier { + display: inline-block; + border-radius: 10px; + width: 15px; + height: 15px; + position: relative; + cursor: pointer; + + &.active:after { + content: ""; + display: block; + position: absolute; + width: 7px; + height: 7px; + border-radius: 10px; + border: 2px solid $color1; + top: 2px; + left: 2px; + } + } + } + + .emoji-search-wrapper { + padding: 6px 16px; + } + + .emoji-search { + font-size: 12px; + padding: 6px 4px; + width: 100%; + border: 1px solid #ddd; + border-radius: 4px; + } + + .emoji-categories-wrapper { + position: absolute; + top: 42px; + bottom: 0; + left: 0; + right: 0; + } + + .emoji-search-wrapper + .emoji-categories-wrapper { + top: 83px; + } + + .emoji-row .emoji:hover { + background: lighten($color2, 3%); + } + + .emoji { + width: 22px; + height: 22px; + cursor: pointer; + + &:focus { + outline: 0; + } + } +} + +.autosuggest-status { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + strong { + font-weight: 500; + } +} + +.upload-area { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + visibility: hidden; + background: rgba($color8, 0.8); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + z-index: 2000; + + * { + pointer-events: none; + } +} + +.upload-area__drop { + width: 320px; + height: 160px; + display: flex; + box-sizing: border-box; + position: relative; + padding: 8px; +} + +.upload-area__background { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: -1; + border-radius: 4px; + background: $color1; + box-shadow: 0 0 5px rgba($color8, 0.2); +} + +.upload-area__content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: $color2; + font-size: 18px; + font-weight: 500; + border: 2px dashed lighten($color1, 26%); + border-radius: 4px; +} + +.upload-progress { + padding: 10px; + color: lighten($color1, 26%); + overflow: hidden; + display: flex; + .fa { - color: $color3; + font-size: 34px; + margin-right: 10px; + } + + span { + font-size: 12px; + text-transform: uppercase; + font-weight: 500; + display: block; + } +} + +.upload-progress__backdrop { + width: 100%; + height: 6px; + border-radius: 6px; + background: lighten($color1, 26%); + position: relative; + margin-top: 5px; +} + +.upload-progress__tracker { + position: absolute; + left: 0; + top: 0; + height: 6px; + background: $color4; + border-radius: 6px; +} + +.emoji-button { + outline: 0; + + &:active, &:focus { + outline: 0 !important; + } + + img { + filter: grayscale(100%); + opacity: 0.8; + display: block; + margin: 0; + width: 22px; + height: 22px; + margin-top: 2px; } + + &:hover, &:active, &:focus { + img { + opacity: 1; + filter: none; + } + } +} + +.dropdown--active .emoji-button img { + opacity: 1; + filter: none; +} + +.privacy-dropdown { + position: relative; +} + +.privacy-dropdown__dropdown { + display: none; + position: absolute; + left: 0; + top: 27px; + width: 230px; + background: $color5; + border-radius: 0 4px 4px 4px; + z-index: 2; + overflow: hidden; +} + +.privacy-dropdown__option { + color: $color1; + padding: 10px; + cursor: pointer; + display: flex; + + &:hover, &.active { + background: $color4; + color: $color5; + + .privacy-dropdown__option__content { + color: $color5; + + strong { + color: $color5; + } + } + } + + &.active:hover { + background: lighten($color4, 4%); + } +} + +.privacy-dropdown__option__icon { + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; +} + +.privacy-dropdown__option__content { + flex: 1 1 auto; + color: darken($color3, 24%); + + strong { + font-weight: 500; + display: block; + color: $color1; + } +} + +.privacy-dropdown.active { + .privacy-dropdown__value { + background: $color5; + border-radius: 4px 4px 0 0; + box-shadow: 0 -4px 4px rgba($color8, 0.1); + } + + .privacy-dropdown__dropdown { + display: block; + box-shadow: 2px 4px 6px rgba($color8, 0.1); + } +} + +.search { + position: relative; } .search__input { + padding-right: 30px; + color: $color2; + outline: 0; box-sizing: border-box; display: block; width: 100%; @@ -1019,4 +1768,127 @@ button.active i.fa-retweet { color: $color3; font-size: 14px; margin: 0; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, &:focus, &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($color1, 4%); + } +} + +.search__icon { + .fa { + position: absolute; + top: 10px; + right: 10px; + z-index: 2; + display: inline-block; + opacity: 0; + transition: all 100ms linear; + font-size: 18px; + width: 18px; + height: 18px; + color: $color2; + cursor: default; + pointer-events: none; + + &.active { + pointer-events: auto; + opacity: 0.3; + } + } + + .fa-search { + transform: translateZ(0) rotate(90deg); + + &.active { + pointer-events: none; + transform: translateZ(0) rotate(0deg); + } + } + + .fa-times-circle { + top: 11px; + transform: translateZ(0) rotate(0deg); + cursor: pointer; + + &.active { + transform: translateZ(0) rotate(90deg); + } + + &:hover { + color: $color5; + } + } +} + +.search-results__header { + color: lighten($color1, 26%); + background: lighten($color1, 2%); + border-bottom: 1px solid darken($color1, 4%); + padding: 15px 10px; + font-size: 14px; + font-weight: 500; +} + +.search-results__hashtag { + display: block; + padding: 10px; + color: $color2; + text-decoration: none; + + &:hover, &:active, &:focus { + color: lighten($color2, 4%); + text-decoration: underline; + } +} + +.modal-root__overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + opacity: 0; + background: rgba($color8, 0.7); +} + +.modal-root__container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + align-content: space-around; + z-index: 9999; + opacity: 0; + pointer-events: none; + user-select: none; +} + +.modal-root__modal { + pointer-events: auto; + display: flex; +} + +.media-modal { + max-width: 80vw; + max-height: 80vh; + position: relative; + + img, video { + max-width: 80vw; + max-height: 80vh; + } } diff --git a/app/assets/stylesheets/fonts/roboto-mono.scss b/app/assets/stylesheets/fonts/roboto-mono.scss index ca64649de..319ecb08e 100644 --- a/app/assets/stylesheets/fonts/roboto-mono.scss +++ b/app/assets/stylesheets/fonts/roboto-mono.scss @@ -1,3 +1,4 @@ +/* @font-face { font-family: 'Roboto Mono'; src: font-url('roboto-mono/robotomono-bold-webfont.eot'); @@ -105,7 +106,7 @@ } - +*/ @font-face { font-family: 'Roboto Mono'; @@ -121,7 +122,7 @@ } - +/* @font-face { font-family: 'Roboto Mono'; @@ -150,4 +151,4 @@ font-weight: 200; font-style: italic; -} \ No newline at end of file +}*/ diff --git a/app/assets/stylesheets/fonts/roboto.scss b/app/assets/stylesheets/fonts/roboto.scss index aa91efe6d..5c0d14043 100644 --- a/app/assets/stylesheets/fonts/roboto.scss +++ b/app/assets/stylesheets/fonts/roboto.scss @@ -1,3 +1,4 @@ +/* @font-face { font-family: 'Roboto'; src: font-url('roboto/roboto-lightitalic-webfont.eot'); @@ -8,7 +9,7 @@ font-url('roboto/roboto-lightitalic-webfont.svg#roboto-lightitalic-webfont') format('svg'); font-weight: 300; font-style: italic; -} +}*/ @font-face { font-family: 'Roboto'; @@ -46,7 +47,7 @@ font-weight: 500; font-style: normal; } - +/* @font-face { font-family: 'Roboto'; src: font-url('roboto/roboto-thin-webfont.eot'); @@ -57,7 +58,7 @@ font-url('roboto/roboto-thin-webfont.svg#roboto-thin-webfont') format('svg'); font-weight: 100; font-style: normal; -} +}*/ @font-face { font-family: 'Roboto'; @@ -70,7 +71,7 @@ font-weight: normal; font-style: normal; } - +/* @font-face { font-family: 'Roboto'; src: font-url('roboto/roboto-mediumitalic-webfont.eot'); @@ -141,4 +142,4 @@ font-url('roboto/roboto-black-webfont.svg#roboto-black-webfont') format('svg'); font-weight: 900; font-style: normal; -} +}*/ diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index a97a767e0..ceccc14cd 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -14,13 +14,17 @@ code { margin-bottom: 15px; } - .hint { + span.hint { display: block; color: $color3; font-size: 12px; margin-top: 4px; } + p.hint { + margin-bottom: 15px; + } + .label_input { display: flex; @@ -93,6 +97,7 @@ code { width: 100%; outline: 0; font-family: inherit; + resize: vertical; &:invalid { box-shadow: none; diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss index 595275527..4a6dc6aa4 100644 --- a/app/assets/stylesheets/stream_entries.scss +++ b/app/assets/stylesheets/stream_entries.scss @@ -3,26 +3,26 @@ box-shadow: 0 0 15px rgba($color8, 0.2); .entry { - background: lighten($color2, 8%); + background: $color5; - &, .detailed-status.light { + .detailed-status.light, .status.light { border-bottom: 1px solid $color2; } &:last-child { - &, .detailed-status.light { + &, .detailed-status.light, .status.light { border-bottom: 0; border-radius: 0 0 4px 4px; } } &:first-child { - &, .detailed-status.light { + &, .detailed-status.light, .status.light { border-radius: 4px 4px 0 0; } &:last-child { - &, .detailed-status.light { + &, .detailed-status.light, .status.light { border-radius: 4px; } } @@ -97,6 +97,15 @@ a { color: $color4; } + + a.status__content__spoiler-link { + color: $color5; + background: $color3; + + &:hover { + background: lighten($color3, 8%); + } + } } .status__attachments { @@ -104,8 +113,12 @@ overflow: hidden; width: 100%; box-sizing: border-box; - height: 110px; - display: flex; + position: relative; + + .status__attachments__inner { + display: flex; + height: 214px; + } } } @@ -159,6 +172,15 @@ a { color: $color4; } + + a.status__content__spoiler-link { + color: $color5; + background: $color3; + + &:hover { + background: lighten($color3, 8%); + } + } } .detailed-status__meta { @@ -184,8 +206,12 @@ overflow: hidden; width: 100%; box-sizing: border-box; - height: 300px; - display: flex; + position: relative; + + .status__attachments__inner { + display: flex; + height: 360px; + } } .video-player { @@ -231,11 +257,19 @@ text-decoration: none; cursor: zoom-in; } + + video { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + object-fit: cover; + top: 50%; + transform: translateY(-50%); + } } .video-item { - max-width: 196px; - a { cursor: pointer; } @@ -258,6 +292,9 @@ width: 100%; height: 100%; cursor: pointer; + position: absolute; + top: 0; + left: 0; display: flex; align-items: center; justify-content: center; diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 491036db2..7fd43489f 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -4,7 +4,12 @@ class AboutController < ApplicationController before_action :set_body_classes def index - @description = Setting.site_description + @description = Setting.site_description + @open_registrations = Setting.open_registrations + @closed_registrations_message = Setting.closed_registrations_message + + @user = User.new + @user.build_account end def more diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index b837f006e..dc1aeb5ea 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -16,7 +16,7 @@ class AccountsController < ApplicationController end format.atom do - @entries = @account.stream_entries.order('id desc').where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @entries = @account.stream_entries.order('id desc').where(activity_type: 'Status').where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) end format.activitystreams2 @@ -56,6 +56,6 @@ class AccountsController < ApplicationController end def check_account_suspension - head 410 if @account.suspended? + gone if @account.suspended? end end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 95107b3dc..df2c7bebf 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -19,19 +19,26 @@ class Admin::AccountsController < ApplicationController def show; end - def update - if @account.update(account_params) - redirect_to admin_accounts_path - else - render :show - end - end - def suspend Admin::SuspensionWorker.perform_async(@account.id) redirect_to admin_accounts_path end + def unsuspend + @account.update(suspended: false) + redirect_to admin_accounts_path + end + + def silence + @account.update(silenced: true) + redirect_to admin_accounts_path + end + + def unsilence + @account.update(silenced: false) + redirect_to admin_accounts_path + end + private def set_account diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index e362957e7..1f4432847 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -9,6 +9,24 @@ class Admin::DomainBlocksController < ApplicationController @blocks = DomainBlock.paginate(page: params[:page], per_page: 40) end + def new + @domain_block = DomainBlock.new + end + def create + @domain_block = DomainBlock.new(resource_params) + + if @domain_block.save + DomainBlockWorker.perform_async(@domain_block.id) + redirect_to admin_domain_blocks_path, notice: 'Domain block is now being processed' + else + render action: :new + end + end + + private + + def resource_params + params.require(:domain_block).permit(:domain, :severity) end end diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb new file mode 100644 index 000000000..2b3b1809f --- /dev/null +++ b/app/controllers/admin/reports_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Admin::ReportsController < ApplicationController + before_action :require_admin! + before_action :set_report, except: [:index] + + layout 'admin' + + def index + @reports = Report.includes(:account, :target_account).order('id desc').paginate(page: params[:page], per_page: 40) + @reports = params[:action_taken].present? ? @reports.resolved : @reports.unresolved + end + + def show + @statuses = Status.where(id: @report.status_ids) + end + + def resolve + @report.update(action_taken: true, action_taken_by_account_id: current_account.id) + redirect_to admin_report_path(@report) + end + + def suspend + Admin::SuspensionWorker.perform_async(@report.target_account.id) + Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id) + redirect_to admin_report_path(@report) + end + + def silence + @report.target_account.update(silenced: true) + Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id) + redirect_to admin_report_path(@report) + end + + def remove + RemovalWorker.perform_async(params[:status_id]) + redirect_to admin_report_path(@report) + end + + private + + def set_report + @report = Report.find(params[:id]) + end +end diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index af0be8823..7615c781d 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -11,9 +11,13 @@ class Admin::SettingsController < ApplicationController def update @setting = Setting.where(var: params[:id]).first_or_initialize(var: params[:id]) + value = settings_params[:value] - if @setting.value != params[:setting][:value] - @setting.value = params[:setting][:value] + # Special cases + value = value == 'true' if @setting.var == 'open_registrations' + + if @setting.value != value + @setting.value = value @setting.save end @@ -22,4 +26,10 @@ class Admin::SettingsController < ApplicationController format.json { respond_with_bip(@setting) } end end + + private + + def settings_params + params.require(:setting).permit(:value) + end end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index d97010c0e..da18474cb 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class Api::V1::AccountsController < ApiController - before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock] - before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock] + before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock, :mute, :unmute] + before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock, :mute, :unmute] before_action :require_user!, except: [:show, :following, :followers, :statuses] before_action :set_account, except: [:verify_credentials, :suggestions, :search] @@ -20,7 +20,7 @@ class Api::V1::AccountsController < ApiController 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) + # set_account_counters_maps(@accounts) 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? @@ -35,7 +35,7 @@ class Api::V1::AccountsController < ApiController 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) + # set_account_counters_maps(@accounts) 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? @@ -47,12 +47,15 @@ class Api::V1::AccountsController < ApiController def statuses @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) + @statuses = @statuses.where(id: MediaAttachment.where(account: @account).where.not(status_id: nil).reorder('').select('distinct status_id')) if params[:only_media] + @statuses = @statuses.without_replies if params[:exclude_replies] @statuses = cache_collection(@statuses, Status) set_maps(@statuses) - set_counters_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 = statuses_api_v1_account_url(max_id: @statuses.last.id) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + next_path = statuses_api_v1_account_url(max_id: @statuses.last.id) unless @statuses.empty? prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) @@ -71,10 +74,17 @@ class Api::V1::AccountsController < ApiController @followed_by = { @account.id => false } @blocking = { @account.id => true } @requested = { @account.id => false } + @muting = { @account.id => current_user.account.muting?(@account.id) } render action: :relationship end + def mute + MuteService.new.call(current_user.account, @account) + set_relationship + render action: :relationship + end + def unfollow UnfollowService.new.call(current_user.account, @account) set_relationship @@ -87,6 +97,12 @@ class Api::V1::AccountsController < ApiController render action: :relationship end + def unmute + UnmuteService.new.call(current_user.account, @account) + set_relationship + render action: :relationship + end + def relationships ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i] @@ -94,13 +110,14 @@ class Api::V1::AccountsController < ApiController @following = Account.following_map(ids, current_user.account_id) @followed_by = Account.followed_by_map(ids, current_user.account_id) @blocking = Account.blocking_map(ids, current_user.account_id) + @muting = Account.muting_map(ids, current_user.account_id) @requested = Account.requested_map(ids, current_user.account_id) end def search - @accounts = SearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true') + @accounts = AccountSearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true', current_account) - set_account_counters_maps(@accounts) unless @accounts.nil? + # set_account_counters_maps(@accounts) unless @accounts.nil? render action: :index end @@ -115,6 +132,7 @@ class Api::V1::AccountsController < ApiController @following = Account.following_map([@account.id], current_user.account_id) @followed_by = Account.followed_by_map([@account.id], current_user.account_id) @blocking = Account.blocking_map([@account.id], current_user.account_id) + @muting = Account.muting_map([@account.id], current_user.account_id) @requested = Account.requested_map([@account.id], current_user.account_id) end end diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index ca9dd0b7e..2ec7280af 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -4,6 +4,12 @@ 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), website: params[:website]) + @app = Doorkeeper::Application.create!(name: app_params[:client_name], redirect_uri: app_params[:redirect_uris], scopes: (app_params[:scopes] || Doorkeeper.configuration.default_scopes), website: app_params[:website]) + end + + private + + def app_params + params.permit(:client_name, :redirect_uris, :scopes, :website) end end diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index 08aefc175..dadf21265 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -11,7 +11,7 @@ class Api::V1::BlocksController < ApiController 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] }.compact - set_account_counters_maps(@accounts) + # set_account_counters_maps(@accounts) 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? diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index ef0a4854a..8a5b81e63 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -11,7 +11,7 @@ class Api::V1::FavouritesController < ApiController @statuses = cache_collection(Status.where(id: results.map(&:status_id)), Status) set_maps(@statuses) - set_counters_maps(@statuses) + # set_counters_maps(@statuses) 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? diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index a30e97e71..3b8e8c078 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -9,7 +9,7 @@ class Api::V1::FollowRequestsController < ApiController 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) + # set_account_counters_maps(@accounts) next_path = api_v1_follow_requests_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT prev_path = api_v1_follow_requests_url(since_id: results.first.id) unless results.empty? @@ -18,12 +18,12 @@ class Api::V1::FollowRequestsController < ApiController end def authorize - FollowRequest.find_by!(account_id: params[:id], target_account: current_account).authorize! + AuthorizeFollowService.new.call(Account.find(params[:id]), current_account) render_empty end def reject - FollowRequest.find_by!(account_id: params[:id], target_account: current_account).reject! + RejectFollowService.new.call(Account.find(params[:id]), current_account) render_empty end end diff --git a/app/controllers/api/v1/follows_controller.rb b/app/controllers/api/v1/follows_controller.rb index c22dacbaa..7c0f44f03 100644 --- a/app/controllers/api/v1/follows_controller.rb +++ b/app/controllers/api/v1/follows_controller.rb @@ -7,7 +7,7 @@ class Api::V1::FollowsController < ApiController respond_to :json def create - raise ActiveRecord::RecordNotFound if params[:uri].blank? + raise ActiveRecord::RecordNotFound if follow_params[:uri].blank? @account = FollowService.new.call(current_user.account, target_uri).try(:target_account) render action: :show @@ -16,6 +16,10 @@ class Api::V1::FollowsController < ApiController private def target_uri - params[:uri].strip.gsub(/\A@/, '') + follow_params[:uri].strip.gsub(/\A@/, '') + end + + def follow_params + params.permit(:uri) end end diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb new file mode 100644 index 000000000..51d92838a --- /dev/null +++ b/app/controllers/api/v1/instances_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Api::V1::InstancesController < ApiController + respond_to :json + + def show; end +end diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index f8139ade7..aed3578d7 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -10,10 +10,16 @@ class Api::V1::MediaController < ApiController respond_to :json def create - @media = MediaAttachment.create!(account: current_user.account, file: params[:file]) + @media = MediaAttachment.create!(account: current_user.account, file: media_params[:file]) rescue Paperclip::Errors::NotIdentifiedByImageMagickError render json: { error: 'File type of uploaded media could not be verified' }, status: 422 rescue Paperclip::Error render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500 end + + private + + def media_params + params.permit(:file) + end end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb new file mode 100644 index 000000000..6f48de040 --- /dev/null +++ b/app/controllers/api/v1/mutes_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Api::V1::MutesController < ApiController + before_action -> { doorkeeper_authorize! :follow } + before_action :require_user! + + respond_to :json + + def index + results = Mute.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_mutes_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + prev_path = api_v1_mutes_url(since_id: results.first.id) unless results.empty? + + set_pagination_headers(next_path, prev_path) + end +end diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 877356a75..7bbc5419c 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -14,10 +14,10 @@ class Api::V1::NotificationsController < ApiController statuses = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status) set_maps(statuses) - set_counters_maps(statuses) - set_account_counters_maps(@notifications.map(&:from_account)) + # 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 == limit_param(DEFAULT_NOTIFICATIONS_LIMIT) + next_path = api_v1_notifications_url(max_id: @notifications.last.id) unless @notifications.empty? prev_path = api_v1_notifications_url(since_id: @notifications.first.id) unless @notifications.empty? set_pagination_headers(next_path, prev_path) diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb new file mode 100644 index 000000000..f83c573cb --- /dev/null +++ b/app/controllers/api/v1/reports_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::ReportsController < ApiController + before_action -> { doorkeeper_authorize! :read }, except: [:create] + before_action -> { doorkeeper_authorize! :write }, only: [:create] + before_action :require_user! + + respond_to :json + + def index + @reports = Report.where(account: current_account) + end + + def create + status_ids = report_params[:status_ids].is_a?(Enumerable) ? report_params[:status_ids] : [report_params[:status_ids]] + + @report = Report.create!(account: current_account, + target_account: Account.find(report_params[:account_id]), + status_ids: Status.find(status_ids).pluck(:id), + comment: report_params[:comment]) + + render :show + end + + private + + def report_params + params.permit(:account_id, :comment, status_ids: []) + end +end diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb new file mode 100644 index 000000000..6b1292458 --- /dev/null +++ b/app/controllers/api/v1/search_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Api::V1::SearchController < ApiController + respond_to :json + + def index + @search = OpenStruct.new(SearchService.new.call(params[:q], 5, params[:resolve] == 'true', current_account)) + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 69cbdce5d..4ece7e702 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -23,7 +23,7 @@ class Api::V1::StatusesController < ApiController statuses = [@status] + @context[:ancestors] + @context[:descendants] set_maps(statuses) - set_counters_maps(statuses) + # set_counters_maps(statuses) end def card @@ -36,7 +36,7 @@ class Api::V1::StatusesController < ApiController 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) + # set_account_counters_maps(@accounts) 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? @@ -51,7 +51,7 @@ class Api::V1::StatusesController < ApiController 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) + # set_account_counters_maps(@accounts) 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? @@ -62,12 +62,11 @@ 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], - spoiler_text: params[:spoiler_text], - visibility: params[:visibility], - application: doorkeeper_token.application) - + @status = PostStatusService.new.call(current_user.account, status_params[:status], status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]), media_ids: status_params[:media_ids], + sensitive: status_params[:sensitive], + spoiler_text: status_params[:spoiler_text], + visibility: status_params[:visibility], + application: doorkeeper_token.application) render action: :show end @@ -112,4 +111,8 @@ class Api::V1::StatusesController < ApiController @status = Status.find(params[:id]) raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account) end + + def status_params + params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, media_ids: []) + end end diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb index a8cc2b288..0446b9e4d 100644 --- a/app/controllers/api/v1/timelines_controller.rb +++ b/app/controllers/api/v1/timelines_controller.rb @@ -11,10 +11,10 @@ class Api::V1::TimelinesController < ApiController @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) + # 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 == limit_param(DEFAULT_STATUSES_LIMIT) + next_path = api_v1_home_timeline_url(max_id: @statuses.last.id) unless @statuses.empty? prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) @@ -27,10 +27,10 @@ class Api::V1::TimelinesController < ApiController @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) + # 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 == limit_param(DEFAULT_STATUSES_LIMIT) + next_path = api_v1_public_timeline_url(max_id: @statuses.last.id) unless @statuses.empty? prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) @@ -44,10 +44,10 @@ class Api::V1::TimelinesController < ApiController @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) + # 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 == limit_param(DEFAULT_STATUSES_LIMIT) + next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id) unless @statuses.empty? 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_controller.rb b/app/controllers/api_controller.rb index 5d2bd9a22..db16f82e5 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -10,7 +10,7 @@ class ApiController < ApplicationController before_action :set_rate_limit_headers - rescue_from ActiveRecord::RecordInvalid do |e| + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| render json: { error: e.to_s }, status: 422 end @@ -30,7 +30,7 @@ class ApiController < ApplicationController render json: { error: 'Remote SSL certificate could not be verified' }, status: 503 end - rescue_from Mastodon::NotPermitted do + rescue_from Mastodon::NotPermittedError do render json: { error: 'This action is not allowed' }, status: 403 end @@ -79,6 +79,7 @@ class ApiController < ApplicationController def require_user! current_resource_owner + set_user_activity rescue ActiveRecord::RecordNotFound render json: { error: 'This method requires an authenticated user' }, status: 422 end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e4b6d0faf..c06142fd4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -39,7 +39,14 @@ class ApplicationController < ActionController::Base end def set_user_activity - current_user.touch(:current_sign_in_at) if !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago) + return unless !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago) + + # Mark user as signed-in today + current_user.update_tracked_fields(request) + + # If the sign in is after a two week break, we need to regenerate their feed + RegenerationWorker.perform_async(current_user.account_id) if current_user.last_sign_in_at < 14.days.ago + return end def check_suspension @@ -51,21 +58,21 @@ class ApplicationController < ActionController::Base def not_found respond_to do |format| format.any { head 404 } - format.html { render 'errors/404', layout: 'error' } + format.html { render 'errors/404', layout: 'error', status: 404 } end end def gone respond_to do |format| format.any { head 410 } - format.html { render 'errors/410', layout: 'error' } + format.html { render 'errors/410', layout: 'error', status: 410 } end end def unprocessable_entity respond_to do |format| format.any { head 422 } - format.html { render 'errors/422', layout: 'error' } + format.html { render 'errors/422', layout: 'error', status: 422 } end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 501e66807..4881c074a 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -3,7 +3,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController layout :determine_layout - before_action :check_single_user_mode + before_action :check_enabled_registrations, only: [:new, :create] before_action :configure_sign_up_params, only: [:create] protected @@ -27,12 +27,12 @@ class Auth::RegistrationsController < Devise::RegistrationsController new_user_session_path end - def check_single_user_mode - redirect_to root_path if Rails.configuration.x.single_user_mode + def check_enabled_registrations + redirect_to root_path if Rails.configuration.x.single_user_mode || !Setting.open_registrations end - + private - + def determine_layout %w(edit update).include?(action_name) ? 'admin' : 'auth' end diff --git a/app/controllers/authorize_follow_controller.rb b/app/controllers/authorize_follow_controller.rb index e866b5599..c98a5f45f 100644 --- a/app/controllers/authorize_follow_controller.rb +++ b/app/controllers/authorize_follow_controller.rb @@ -25,7 +25,7 @@ class AuthorizeFollowController < ApplicationController else redirect_to web_url("accounts/#{@account.id}") end - rescue ActiveRecord::RecordNotFound, Mastodon::NotPermitted + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError render :error end diff --git a/app/controllers/concerns/obfuscate_filename.rb b/app/controllers/concerns/obfuscate_filename.rb index 9f12cb7e9..9c896fb09 100644 --- a/app/controllers/concerns/obfuscate_filename.rb +++ b/app/controllers/concerns/obfuscate_filename.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module ObfuscateFilename extend ActiveSupport::Concern @@ -12,6 +13,10 @@ module ObfuscateFilename file = params.dig(*path) return if file.nil? - file.original_filename = 'media' + File.extname(file.original_filename) + file.original_filename = secure_token + File.extname(file.original_filename) + end + + def secure_token(length = 16) + SecureRandom.hex(length / 2) end end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index feaad04f6..7c25266d8 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -3,6 +3,7 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController skip_before_action :authenticate_resource_owner! + before_action :set_locale before_action :store_current_location before_action :authenticate_resource_owner! @@ -11,4 +12,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController def store_current_location store_location_for(:user, request.url) end + + def set_locale + I18n.locale = current_user.try(:locale) || I18n.default_locale + rescue I18n::InvalidLocale + I18n.locale = I18n.default_locale + end end diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb index 7d4bfe6ce..1e3f786ec 100644 --- a/app/controllers/remote_follow_controller.rb +++ b/app/controllers/remote_follow_controller.rb @@ -8,6 +8,7 @@ class RemoteFollowController < ApplicationController def new @remote_follow = RemoteFollow.new + @remote_follow.acct = session[:remote_follow] if session.key?(:remote_follow) end def create @@ -22,6 +23,8 @@ class RemoteFollowController < ApplicationController render(:new) && return end + session[:remote_follow] = @remote_follow.acct + redirect_to Addressable::Template.new(redirect_url_link.template).expand(uri: "#{@account.username}@#{Rails.configuration.x.local_domain}").to_s else render :new diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb new file mode 100644 index 000000000..4fcec5322 --- /dev/null +++ b/app/controllers/settings/exports_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'csv' + +class Settings::ExportsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :set_account + + def show + @total_storage = current_account.media_attachments.sum(:file_file_size) + @total_follows = current_account.following.count + @total_blocks = current_account.blocking.count + end + + def download_following_list + @accounts = current_account.following + + respond_to do |format| + format.csv { render text: accounts_list_to_csv(@accounts) } + end + end + + def download_blocking_list + @accounts = current_account.blocking + + respond_to do |format| + format.csv { render text: accounts_list_to_csv(@accounts) } + end + end + + private + + def set_account + @account = current_user.account + end + + def accounts_list_to_csv(list) + CSV.generate do |csv| + list.each do |account| + csv << [(account.local? ? "#{account.username}@#{Rails.configuration.x.local_domain}" : account.acct)] + end + end + end +end diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb new file mode 100644 index 000000000..cbb5e65da --- /dev/null +++ b/app/controllers/settings/imports_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Settings::ImportsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :set_account + + def show + @import = Import.new + end + + def create + @import = Import.new(import_params) + @import.account = @account + + if @import.save + ImportWorker.perform_async(@import.id) + redirect_to settings_import_path, notice: I18n.t('imports.success') + else + render action: :show + end + end + + private + + def set_account + @account = current_user.account + end + + def import_params + params.require(:import).permit(:data, :type) + end +end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index b7479bf8c..60400e465 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -14,6 +14,7 @@ class Settings::PreferencesController < ApplicationController reblog: user_params[:notification_emails][:reblog] == '1', favourite: user_params[:notification_emails][:favourite] == '1', mention: user_params[:notification_emails][:mention] == '1', + digest: user_params[:notification_emails][:digest] == '1', } current_user.settings['interactions'] = { @@ -33,6 +34,6 @@ class Settings::PreferencesController < ApplicationController private def user_params - params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following]) + params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention, :digest], interactions: [:must_be_follower, :must_be_following]) end end diff --git a/app/controllers/settings/two_factor_auths_controller.rb b/app/controllers/settings/two_factor_auths_controller.rb index f34295cb9..cfee92391 100644 --- a/app/controllers/settings/two_factor_auths_controller.rb +++ b/app/controllers/settings/two_factor_auths_controller.rb @@ -8,7 +8,8 @@ class Settings::TwoFactorAuthsController < ApplicationController def show return unless current_user.otp_required_for_login - @qrcode = RQRCode::QRCode.new(current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain)) + @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain) + @qrcode = RQRCode::QRCode.new(@provision_url) end def enable diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb new file mode 100644 index 000000000..696bb4f52 --- /dev/null +++ b/app/controllers/statuses_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class StatusesController < ApplicationController + layout 'public' + + before_action :set_account + before_action :set_status + before_action :set_link_headers + before_action :check_account_suspension + + def show + @ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : [] + @descendants = cache_collection(@status.descendants(current_account), Status) + + render 'stream_entries/show' + end + + private + + def set_account + @account = Account.find_local!(params[:account_username]) + end + + def set_link_headers + response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) + end + + def set_status + @status = @account.statuses.find(params[:id]) + @stream_entry = @status.stream_entry + @type = @stream_entry.activity_type.downcase + + raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account) + end + + def check_account_suspension + gone if @account.suspended? + end +end diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index da284d80e..de38b3602 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -43,13 +43,13 @@ class StreamEntriesController < ApplicationController end def set_stream_entry - @stream_entry = @account.stream_entries.find(params[:id]) + @stream_entry = @account.stream_entries.where(activity_type: 'Status').find(params[:id]) @type = @stream_entry.activity_type.downcase 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 - head 410 if @account.suspended? + gone if @account.suspended? end end diff --git a/app/controllers/xrd_controller.rb b/app/controllers/xrd_controller.rb index 9e0277860..6db87cefc 100644 --- a/app/controllers/xrd_controller.rb +++ b/app/controllers/xrd_controller.rb @@ -36,11 +36,14 @@ class XrdController < ApplicationController end def username_from_resource - if resource_param.start_with?('acct:') || resource_param.include?('@') - resource_param.split('@').first.gsub('acct:', '') + if resource_param =~ /\Ahttps?:\/\// + path_params = Rails.application.routes.recognize_path(resource_param) + raise ActiveRecord::RecordNotFound unless path_params[:controller] == 'users' && path_params[:action] == 'show' + path_params[:username] else - url = Addressable::URI.parse(resource_param) - url.path.gsub('/users/', '') + username, domain = resource_param.gsub(/\Aacct:/, '').split('@') + raise ActiveRecord::RecordNotFound unless TagManager.instance.local_domain?(domain) + username end end diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb index fb8f0976c..b750eeb07 100644 --- a/app/helpers/atom_builder_helper.rb +++ b/app/helpers/atom_builder_helper.rb @@ -90,6 +90,10 @@ module AtomBuilderHelper xml.link(rel: 'self', type: 'application/atom+xml', href: url) end + def link_next(xml, url) + xml.link(rel: 'next', type: 'application/atom+xml', href: url) + end + def link_hub(xml, url) xml.link(rel: 'hub', href: url) end @@ -120,6 +124,10 @@ module AtomBuilderHelper single_link_avatar(xml, account, :original, 120) end + def link_header(xml, account) + xml.link('rel' => 'header', 'type' => account.header_content_type, 'media:width' => 700, 'media:height' => 335, 'href' => full_asset_url(account.header.url(:original))) + end + def logo(xml, url) xml.logo url end @@ -143,7 +151,12 @@ module AtomBuilderHelper xml.link(:rel => 'mentioned', :href => TagManager::COLLECTIONS[:public], 'ostatus:object-type' => TagManager::TYPES[:collection]) end + def privacy_scope(xml, level) + xml['mastodon'].scope(level) + end + def include_author(xml, account) + simple_id xml, TagManager.instance.uri_for(account) object_type xml, :person uri xml, TagManager.instance.uri_for(account) name xml, account.username @@ -151,7 +164,9 @@ module AtomBuilderHelper summary xml, account.note link_alternate xml, TagManager.instance.url_for(account) link_avatar xml, account + link_header xml, account portable_contact xml, account + privacy_scope xml, account.locked? ? :private : :public end def rich_content(xml, activity) @@ -162,6 +177,52 @@ module AtomBuilderHelper end end + def include_target(xml, target) + simple_id xml, TagManager.instance.uri_for(target) + + if target.object_type == :person + include_author xml, target + else + object_type xml, target.object_type + verb xml, target.verb + title xml, target.title + link_alternate xml, TagManager.instance.url_for(target) + end + + # Statuses have content and author + return unless target.is_a?(Status) + + rich_content xml, target + verb xml, target.verb + published_at xml, target.created_at + updated_at xml, target.updated_at + + author(xml) do + include_author xml, target.account + end + + if target.reply? + in_reply_to xml, TagManager.instance.uri_for(target.thread), TagManager.instance.url_for(target.thread) + end + + link_visibility xml, target + + target.mentions.each do |mention| + link_mention xml, mention.account + end + + target.media_attachments.each do |media| + link_enclosure xml, media + end + + target.tags.each do |tag| + category xml, tag.name + end + + category(xml, 'nsfw') if target.sensitive? + privacy_scope(xml, target.visibility) + 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 @@ -180,43 +241,7 @@ module AtomBuilderHelper if stream_entry.targeted? target(xml) do - simple_id xml, TagManager.instance.uri_for(stream_entry.target) - - if stream_entry.target.object_type == :person - include_author xml, stream_entry.target - else - object_type xml, stream_entry.target.object_type - title xml, stream_entry.target.title - link_alternate xml, TagManager.instance.url_for(stream_entry.target) - end - - # Statuses have content and author - if stream_entry.target.is_a?(Status) - rich_content xml, stream_entry.target - verb xml, stream_entry.target.verb - published_at xml, stream_entry.target.created_at - updated_at xml, stream_entry.target.updated_at - - author(xml) do - include_author xml, stream_entry.target.account - end - - link_visibility xml, stream_entry.target - - stream_entry.target.mentions.each do |mention| - link_mention xml, mention.account - end - - stream_entry.target.media_attachments.each do |media| - link_enclosure xml, media - end - - stream_entry.target.tags.each do |tag| - category xml, tag.name - end - - category(xml, 'nsfw') if stream_entry.target.sensitive? - end + include_target(xml, stream_entry.target) end end @@ -237,6 +262,7 @@ module AtomBuilderHelper end category(xml, 'nsfw') if stream_entry.activity.sensitive? + privacy_scope(xml, stream_entry.activity.visibility) end private @@ -249,10 +275,11 @@ module AtomBuilderHelper 'xmlns:poco' => TagManager::POCO_XMLNS, 'xmlns:media' => TagManager::MEDIA_XMLNS, 'xmlns:ostatus' => TagManager::OS_XMLNS, + 'xmlns:mastodon' => TagManager::MTDN_XMLNS, }, &block) end def single_link_avatar(xml, account, size, px) - xml.link('rel' => 'avatar', 'type' => account.avatar_content_type, 'media:width' => px, 'media:height' => px, 'href' => full_asset_url(account.avatar.url(size, false))) + xml.link('rel' => 'avatar', 'type' => account.avatar_content_type, 'media:width' => px, 'media:height' => px, 'href' => full_asset_url(account.avatar.url(size))) end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index aed8770c8..e01f7d0cc 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -9,6 +9,8 @@ module SettingsHelper fr: 'Français', hu: 'Magyar', uk: 'Українська', + 'zh-CN': '简体中文', + fi: 'Suomi', }.freeze def human_locale(locale) diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index 15601a079..a26e912a3 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -37,4 +37,17 @@ module StreamEntriesHelper def proper_status(status) status.reblog? ? status.reblog : status end + + def rtl?(text) + return false if text.empty? + + matches = /[\p{Hebrew}|\p{Arabic}|\p{Syriac}|\p{Thaana}|\p{Nko}]+/m.match(text) + + return false unless matches + + rtl_size = matches.to_a.reduce(0) { |acc, elem| acc + elem.size }.to_f + ltr_size = text.strip.size.to_f + + rtl_size / ltr_size > 0.3 + end end diff --git a/app/lib/exceptions.rb b/app/lib/exceptions.rb index 359228c29..9bc802c12 100644 --- a/app/lib/exceptions.rb +++ b/app/lib/exceptions.rb @@ -2,5 +2,7 @@ module Mastodon class Error < StandardError; end - class NotPermitted < Error; end + class NotPermittedError < Error; end + class ValidationError < Error; end + class RaceConditionError < Error; end end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index f0928a945..2cca1cefe 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -5,25 +5,35 @@ require 'singleton' class FeedManager include Singleton - MAX_ITEMS = 800 + MAX_ITEMS = 400 def key(type, id) "feed:#{type}:#{id}" end - def filter?(timeline_type, status, receiver) + def filter?(timeline_type, status, receiver_id) if timeline_type == :home - filter_from_home?(status, receiver) + filter_from_home?(status, receiver_id) elsif timeline_type == :mentions - filter_from_mentions?(status, receiver) + filter_from_mentions?(status, receiver_id) else false end end def push(timeline_type, account, status) - redis.zadd(key(timeline_type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id) - trim(timeline_type, account.id) + timeline_key = key(timeline_type, account.id) + + if status.reblog? + # If the original status is within 40 statuses from top, do not re-insert it into the feed + rank = redis.zrevrank(timeline_key, status.reblog_of_id) + return if !rank.nil? && rank < 40 + redis.zadd(timeline_key, status.id, status.reblog_of_id) + else + redis.zadd(timeline_key, status.id, status.id) + trim(timeline_type, account.id) + end + broadcast(account.id, event: 'update', payload: inline_render(account, 'api/v1/statuses/show', status)) end @@ -40,10 +50,18 @@ class FeedManager def merge_into_timeline(from_account, into_account) timeline_key = key(:home, into_account.id) + query = from_account.statuses.limit(FeedManager::MAX_ITEMS / 4) - from_account.statuses.limit(MAX_ITEMS).each do |status| - next if filter?(:home, status, into_account) - redis.zadd(timeline_key, status.id, status.id) + if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4 + oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 + query = query.where('id > ?', oldest_home_score) + end + + redis.pipelined do + query.each do |status| + next if status.direct_visibility? || filter?(:home, status, into_account) + redis.zadd(timeline_key, status.id, status.id) + end end trim(:home, into_account.id) @@ -51,31 +69,20 @@ class FeedManager 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) + oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 + + from_account.statuses.select('id').where('id > ?', oldest_home_score).find_in_batches do |statuses| + redis.pipelined do + statuses.each do |status| + redis.zrem(timeline_key, status.id) + redis.zremrangebyscore(timeline_key, status.id, status.id) + end + end end end def inline_render(target_account, template, object) - rabl_scope = Class.new do - include RoutingHelper - - def initialize(account) - @account = account - end - - def current_user - @account.try(:user) - end - - def current_account - @account - end - end - - Rabl::Renderer.new(template, object, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render + Rabl::Renderer.new(template, object, view_path: 'app/views', format: :json, scope: InlineRablScope.new(target_account)).render end private @@ -84,35 +91,40 @@ class FeedManager Redis.current end - def filter_from_home?(status, receiver) - should_filter = false - - if status.reply? && status.in_reply_to_id.nil? - should_filter = true - elsif status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply - should_filter = !receiver.following?(status.in_reply_to_account) # and I'm not following the person it's a reply to - should_filter &&= !(receiver.id == status.in_reply_to_account_id) # and it's not a reply to me - should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply - elsif status.reblog? # Filter out a reblog - should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person - end + def filter_from_home?(status, receiver_id) + return true if status.reply? && status.in_reply_to_id.nil? - should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked + check_for_mutes = [status.account_id] + check_for_mutes.concat([status.reblog.account_id]) if status.reblog? - should_filter - end + return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any? + + check_for_blocks = status.mentions.map(&:account_id) + check_for_blocks.concat([status.reblog.account_id]) if status.reblog? - def filter_from_mentions?(status, receiver) - should_filter = receiver.id == status.account_id # Filter if I'm mentioning myself - should_filter ||= receiver.blocking?(status.account) # or it's from someone I blocked - should_filter ||= receiver.blocking?(status.mentions.includes(:account).map(&:account)) # or if it mentions someone I blocked - should_filter ||= (status.account.silenced? && !receiver.following?(status.account)) # of if the account is silenced and I'm not following them - should_filter ||= (status.private_visibility? && !receiver.following?(status.account)) # or if the mentioned account is not permitted to see the private status + return true if Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? - if status.reply? && !status.in_reply_to_account_id.nil? # or it's a reply - should_filter ||= receiver.blocking?(status.in_reply_to_account) # to a user I blocked + if status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply + should_filter = !Follow.where(account_id: receiver_id, target_account_id: status.in_reply_to_account_id).exists? # and I'm not following the person it's a reply to + should_filter &&= !(receiver_id == status.in_reply_to_account_id) # and it's not a reply to me + should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply + return should_filter + elsif status.reblog? # Filter out a reblog + return Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me end + false + end + + def filter_from_mentions?(status, receiver_id) + check_for_blocks = [status.account_id] + check_for_blocks.concat(status.mentions.pluck(:account_id)) + check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil? + + should_filter = receiver_id == status.account_id # Filter if I'm mentioning myself + should_filter ||= Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # or it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked + should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them + should_filter end end diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 044407a6c..da7ad2027 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -24,7 +24,12 @@ class Formatter end def reformat(html) - sanitize(html, tags: %w(a br p), attributes: %w(href rel)) + sanitize(html, tags: %w(a br p span), attributes: %w(href rel class)) + end + + def plaintext(status) + return status.text if status.local? + strip_tags(status.text) end def simplified_format(account) @@ -32,6 +37,7 @@ class Formatter html = encode(account.note) html = link_urls(html) + html = link_accounts(html) html = link_hashtags(html) html.html_safe # rubocop:disable Rails/OutputSafety @@ -44,20 +50,31 @@ class Formatter end def link_urls(html) - html.gsub(URI.regexp(%w(http https))) do |match| - link_html(match) - end + Twitter::Autolink.auto_link_urls(html, url_target: '_blank', + link_attribute_block: lambda { |_, a| a[:rel] << ' noopener' }, + link_text_block: lambda { |_, text| link_html(text) }) end def link_mentions(html, mentions) html.gsub(Account::MENTION_RE) do |match| acct = Account::MENTION_RE.match(match)[1] - mention = mentions.find { |item| item.account.acct.casecmp(acct).zero? } + mention = mentions.find { |item| TagManager.instance.same_acct?(item.account.acct, acct) } mention.nil? ? match : mention_html(match, mention.account) end end + def link_accounts(html) + html.gsub(Account::MENTION_RE) do |match| + acct = Account::MENTION_RE.match(match)[1] + username, domain = acct.split('@') + domain = nil if TagManager.instance.local_domain?(domain) + account = Account.find_remote(username, domain) + + account.nil? ? match : mention_html(match, account) + end + end + def link_hashtags(html) html.gsub(Tag::HASHTAG_RE) do |match| hashtag_html(match) @@ -70,7 +87,7 @@ class Formatter suffix = url[prefix.length + 30..-1] cutoff = url[prefix.length..-1].length > 30 - "<a rel=\"nofollow noopener\" target=\"_blank\" href=\"#{url}\"><span class=\"invisible\">#{prefix}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{text}</span><span class=\"invisible\">#{suffix}</span></a>" + "<span class=\"invisible\">#{prefix}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{text}</span><span class=\"invisible\">#{suffix}</span>" end def hashtag_html(match) diff --git a/app/lib/inline_rabl_scope.rb b/app/lib/inline_rabl_scope.rb new file mode 100644 index 000000000..26adcb03a --- /dev/null +++ b/app/lib/inline_rabl_scope.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class InlineRablScope + include RoutingHelper + + def initialize(account) + @account = account + end + + def current_user + @account.try(:user) + end + + def current_account + @account + end +end diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb index 2508eea97..2a5e7a409 100644 --- a/app/lib/tag_manager.rb +++ b/app/lib/tag_manager.rb @@ -7,15 +7,18 @@ class TagManager include RoutingHelper VERBS = { - post: 'http://activitystrea.ms/schema/1.0/post', - share: 'http://activitystrea.ms/schema/1.0/share', - favorite: 'http://activitystrea.ms/schema/1.0/favorite', - unfavorite: 'http://activitystrea.ms/schema/1.0/unfavorite', - delete: 'http://activitystrea.ms/schema/1.0/delete', - follow: 'http://activitystrea.ms/schema/1.0/follow', - unfollow: 'http://ostatus.org/schema/1.0/unfollow', - block: 'http://mastodon.social/schema/1.0/block', - unblock: 'http://mastodon.social/schema/1.0/unblock', + post: 'http://activitystrea.ms/schema/1.0/post', + share: 'http://activitystrea.ms/schema/1.0/share', + favorite: 'http://activitystrea.ms/schema/1.0/favorite', + unfavorite: 'http://activitystrea.ms/schema/1.0/unfavorite', + delete: 'http://activitystrea.ms/schema/1.0/delete', + follow: 'http://activitystrea.ms/schema/1.0/follow', + request_friend: 'http://activitystrea.ms/schema/1.0/request-friend', + authorize: 'http://activitystrea.ms/schema/1.0/authorize', + reject: 'http://activitystrea.ms/schema/1.0/reject', + unfollow: 'http://ostatus.org/schema/1.0/unfollow', + block: 'http://mastodon.social/schema/1.0/block', + unblock: 'http://mastodon.social/schema/1.0/unblock', }.freeze TYPES = { @@ -38,6 +41,7 @@ class TagManager POCO_XMLNS = 'http://portablecontacts.net/spec/1.0' DFRN_XMLNS = 'http://purl.org/macgirvin/dfrn/1.0' OS_XMLNS = 'http://ostatus.org/schema/1.0' + MTDN_XMLNS = 'http://mastodon.social/schema/1.0' def unique_tag(date, id, type) "tag:#{Rails.configuration.x.local_domain},#{date.strftime('%Y-%m-%d')}:objectId=#{id}:objectType=#{type}" @@ -56,6 +60,12 @@ class TagManager domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.local_domain).zero? end + def same_acct?(canonical, needle) + return true if canonical.casecmp(needle).zero? + username, domain = needle.split('@') + local_domain?(domain) && canonical.casecmp(username).zero? + end + def local_url?(url) uri = Addressable::URI.parse(url) domain = uri.host + (uri.port ? ":#{uri.port}" : '') @@ -78,7 +88,9 @@ class TagManager case target.object_type when :person - account_url(target) + short_account_url(target) + when :note, :comment, :activity + short_account_status_url(target.account, target) else account_stream_entry_url(target.account, target.stream_entry) end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index a1b084682..bf4c16e43 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -49,4 +49,17 @@ class NotificationMailer < ApplicationMailer mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct) end end + + def digest(recipient, opts = {}) + @me = recipient + @since = opts[:since] || @me.user.last_emailed_at || @me.user.current_sign_in_at + @notifications = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since) + @follows_since = Notification.where(account: @me, activity_type: 'Follow').where('created_at > ?', @since).count + + return if @notifications.empty? + + I18n.with_locale(@me.user.locale || I18n.default_locale) do + mail to: @me.user.email, subject: I18n.t('notification_mailer.digest.subject', count: @notifications.size) + end + end end diff --git a/app/models/account.rb b/app/models/account.rb index c2a41c4c6..6968607a2 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -2,9 +2,8 @@ class Account < ApplicationRecord include Targetable - include PgSearch - MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i + MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze # Local users @@ -46,15 +45,16 @@ class Account < ApplicationRecord has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account + # Mute relationships + has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy + has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account + # Media has_many :media_attachments, dependent: :destroy # PuSH subscriptions has_many :subscriptions, dependent: :destroy - pg_search_scope :search_for, against: { username: 'A', domain: 'B' }, - using: { tsearch: { prefix: true } } - scope :remote, -> { where.not(domain: nil) } scope :local, -> { where(domain: nil) } scope :without_followers, -> { where('(select count(f.id) from follows as f where f.target_account_id = accounts.id) = 0') } @@ -73,6 +73,10 @@ class Account < ApplicationRecord block_relationships.where(target_account: other_account).first_or_create!(target_account: other_account) end + def mute!(other_account) + mute_relationships.where(target_account: other_account).first_or_create!(target_account: other_account) + end + def unfollow!(other_account) follow = active_relationships.find_by(target_account: other_account) follow&.destroy @@ -83,6 +87,11 @@ class Account < ApplicationRecord block&.destroy end + def unmute!(other_account) + mute = mute_relationships.find_by(target_account: other_account) + mute&.destroy + end + def following?(other_account) following.include?(other_account) end @@ -91,10 +100,18 @@ class Account < ApplicationRecord blocking.include?(other_account) end + def muting?(other_account) + muting.include?(other_account) + end + def requested?(other_account) follow_requests.where(target_account: other_account).exists? end + def followers_domains + followers.reorder('').select('DISTINCT accounts.domain').map(&:domain) + end + def local? domain.nil? end @@ -127,14 +144,16 @@ class Account < ApplicationRecord save! rescue ActiveRecord::RecordInvalid self.avatar = nil + self.header = nil self[:avatar_remote_url] = '' + self[:header_remote_url] = '' save! end def avatar_remote_url=(url) parsed_url = URI.parse(url) - return if !%w(http https).include?(parsed_url.scheme) || self[:avatar_remote_url] == url + return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[:avatar_remote_url] == url self.avatar = parsed_url self[:avatar_remote_url] = url @@ -142,6 +161,17 @@ class Account < ApplicationRecord Rails.logger.debug "Error fetching remote avatar: #{e}" end + def header_remote_url=(url) + parsed_url = URI.parse(url) + + return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[:header_remote_url] == url + + self.header = parsed_url + self[:header_remote_url] = url + rescue OpenURI::HTTPError => e + Rails.logger.debug "Error fetching remote header: #{e}" + end + def object_type :person end @@ -157,7 +187,7 @@ class Account < ApplicationRecord 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! + where('lower(accounts.username) = ?', username.downcase).where(domain.nil? ? { domain: nil } : 'lower(accounts.domain) = ?', domain&.downcase).take! end def find_local(username) @@ -172,6 +202,63 @@ class Account < ApplicationRecord nil end + def triadic_closures(account, limit = 5) + sql = <<SQL + WITH first_degree AS ( + SELECT target_account_id + FROM follows + WHERE account_id = ? + ) + SELECT accounts.* + FROM follows + INNER JOIN accounts ON follows.target_account_id = accounts.id + WHERE account_id IN (SELECT * FROM first_degree) AND target_account_id NOT IN (SELECT * FROM first_degree) AND target_account_id <> ? + GROUP BY target_account_id, accounts.id + ORDER BY count(account_id) DESC + LIMIT ? +SQL + + Account.find_by_sql([sql, account.id, account.id, limit]) + end + + def search_for(terms, limit = 10) + terms = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' '))) + textsearch = '(setweight(to_tsvector(\'simple\', accounts.display_name), \'A\') || setweight(to_tsvector(\'simple\', accounts.username), \'B\') || setweight(to_tsvector(\'simple\', coalesce(accounts.domain, \'\')), \'C\'))' + query = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')' + + sql = <<SQL + SELECT + accounts.*, + ts_rank_cd(#{textsearch}, #{query}, 32) AS rank + FROM accounts + WHERE #{query} @@ #{textsearch} + ORDER BY rank DESC + LIMIT ? +SQL + + Account.find_by_sql([sql, limit]) + end + + def advanced_search_for(terms, account, limit = 10) + terms = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' '))) + textsearch = '(setweight(to_tsvector(\'simple\', accounts.display_name), \'A\') || setweight(to_tsvector(\'simple\', accounts.username), \'B\') || setweight(to_tsvector(\'simple\', coalesce(accounts.domain, \'\')), \'C\'))' + query = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')' + + sql = <<SQL + SELECT + accounts.*, + (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank + FROM accounts + LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?) + WHERE #{query} @@ #{textsearch} + GROUP BY accounts.id + ORDER BY rank DESC + LIMIT ? +SQL + + Account.find_by_sql([sql, account.id, account.id, limit]) + end + def following_map(target_account_ids, account_id) follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) end @@ -184,6 +271,10 @@ class Account < ApplicationRecord follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) end + def muting_map(target_account_ids, account_id) + follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + end + def requested_map(target_account_ids, account_id) follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) end diff --git a/app/models/block.rb b/app/models/block.rb index c2067c5b8..9c55703c9 100644 --- a/app/models/block.rb +++ b/app/models/block.rb @@ -2,31 +2,10 @@ class Block < ApplicationRecord include Paginable - include Streamable belongs_to :account belongs_to :target_account, class_name: 'Account' validates :account, :target_account, presence: true validates :account_id, uniqueness: { scope: :target_account_id } - - def verb - destroyed? ? :unblock : :block - end - - def target - target_account - end - - def object_type - :person - end - - def hidden? - true - end - - def title - destroyed? ? "#{account.acct} is no longer blocking #{target_account.acct}" : "#{account.acct} blocked #{target_account.acct}" - end end diff --git a/app/models/concerns/streamable.rb b/app/models/concerns/streamable.rb index 58c15cfbc..910736dac 100644 --- a/app/models/concerns/streamable.rb +++ b/app/models/concerns/streamable.rb @@ -30,8 +30,12 @@ module Streamable false end + def needs_stream_entry? + account.local? + end + after_create do - account.stream_entries.create!(activity: self, hidden: hidden?) if account.local? + account.stream_entries.create!(activity: self, hidden: hidden?) if needs_stream_entry? end end end diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index b4606da60..3548ccd69 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -6,6 +6,6 @@ class DomainBlock < ApplicationRecord validates :domain, presence: true, uniqueness: true def self.blocked?(domain) - where(domain: domain).exists? + where(domain: domain, severity: :suspend).exists? end end diff --git a/app/models/favourite.rb b/app/models/favourite.rb index 3f3616dce..41d06e734 100644 --- a/app/models/favourite.rb +++ b/app/models/favourite.rb @@ -2,37 +2,14 @@ class Favourite < ApplicationRecord include Paginable - include Streamable belongs_to :account, inverse_of: :favourites - belongs_to :status, inverse_of: :favourites + belongs_to :status, inverse_of: :favourites, counter_cache: true has_one :notification, as: :activity, dependent: :destroy validates :status_id, uniqueness: { scope: :account_id } - def verb - :favorite - end - - def title - "#{account.acct} favourited a status by #{status.account.acct}" - end - - delegate :object_type, to: :target - - def thread - status - end - - def target - thread - end - - def hidden? - status.private_visibility? - end - before_validation do self.status = status.reblog if status.reblog? end diff --git a/app/models/feed.rb b/app/models/feed.rb index 5e1905e15..3cbc160a0 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -10,17 +10,9 @@ class Feed max_id = '+inf' if max_id.blank? since_id = '-inf' if since_id.blank? unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i) + status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h - # If we're after most recent items and none are there, we need to precompute the feed - if unhydrated.empty? && max_id == '+inf' && since_id == '-inf' - RegenerationWorker.perform_async(@account.id, @type) - @statuses = Status.send("as_#{@type}_timeline", @account).cache_ids.paginate_by_max_id(limit, nil, nil) - else - status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h - @statuses = unhydrated.map { |id| status_map[id] }.compact - end - - @statuses + unhydrated.map { |id| status_map[id] }.compact end private diff --git a/app/models/follow.rb b/app/models/follow.rb index f83490caa..8bfe8b2f6 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -2,29 +2,12 @@ class Follow < ApplicationRecord include Paginable - include Streamable - belongs_to :account - belongs_to :target_account, class_name: 'Account' + belongs_to :account, counter_cache: :following_count + belongs_to :target_account, class_name: 'Account', counter_cache: :followers_count has_one :notification, as: :activity, dependent: :destroy validates :account, :target_account, presence: true validates :account_id, uniqueness: { scope: :target_account_id } - - def verb - destroyed? ? :unfollow : :follow - end - - def target - target_account - end - - def object_type - :person - end - - def title - destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}" - end end diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index 936ad0691..4224ab15d 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -14,6 +14,7 @@ class FollowRequest < ApplicationRecord def authorize! account.follow!(target_account) MergeWorker.perform_async(target_account.id, account.id) + destroy! end diff --git a/app/models/import.rb b/app/models/import.rb new file mode 100644 index 000000000..5384986d8 --- /dev/null +++ b/app/models/import.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Import < ApplicationRecord + self.inheritance_column = false + + enum type: [:following, :blocking] + + belongs_to :account + + FILE_TYPES = ['text/plain', 'text/csv'].freeze + + has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET'] + validates_attachment_content_type :data, content_type: FILE_TYPES +end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 6925f9b0d..818190214 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -1,15 +1,32 @@ # frozen_string_literal: true class MediaAttachment < ApplicationRecord + self.inheritance_column = nil + + enum type: [:image, :gifv, :video] + IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze + IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze + VIDEO_STYLES = { + small: { + convert_options: { + output: { + vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease', + }, + }, + format: 'png', + time: 0, + }, + }.freeze + belongs_to :account, inverse_of: :media_attachments belongs_to :status, inverse_of: :media_attachments has_attached_file :file, - styles: -> (f) { file_styles f }, - processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] }, + styles: ->(f) { file_styles f }, + processors: ->(f) { file_processors f }, convert_options: { all: '-quality 90 -strip' } validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES validates_attachment_size :file, less_than: 8.megabytes @@ -27,45 +44,49 @@ class MediaAttachment < ApplicationRecord self.file = URI.parse(url) end - def image? - IMAGE_MIME_TYPES.include? file_content_type - end - - def video? - VIDEO_MIME_TYPES.include? file_content_type - end - - def type - image? ? 'image' : 'video' - end - def to_param shortcode end before_create :set_shortcode + before_post_process :set_type class << self private def file_styles(f) - if f.instance.image? + if f.instance.file_content_type == 'image/gif' { - original: '1280x1280>', - small: '400x400>', - } - else - { - small: { + small: IMAGE_STYLES[:small], + original: { + format: 'mp4', convert_options: { output: { - vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease', + 'movflags' => 'faststart', + 'pix_fmt' => 'yuv420p', + 'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'', + 'vsync' => 'cfr', + 'b:v' => '1300K', + 'maxrate' => '500K', + 'crf' => 6, }, }, - format: 'png', - time: 1, }, } + elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type + IMAGE_STYLES + else + VIDEO_STYLES + end + end + + def file_processors(f) + if f.file_content_type == 'image/gif' + [:gif_transcoder] + elsif VIDEO_MIME_TYPES.include? f.file_content_type + [:video_transcoder] + else + [:thumbnail] end end end @@ -80,4 +101,8 @@ class MediaAttachment < ApplicationRecord break if MediaAttachment.find_by(shortcode: shortcode).nil? end end + + def set_type + self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image + end end diff --git a/app/models/mute.rb b/app/models/mute.rb new file mode 100644 index 000000000..a5b334c85 --- /dev/null +++ b/app/models/mute.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Mute < ApplicationRecord + include Paginable + + belongs_to :account + belongs_to :target_account, class_name: 'Account' + + validates :account, :target_account, presence: true + validates :account_id, uniqueness: { scope: :target_account_id } +end diff --git a/app/models/report.rb b/app/models/report.rb new file mode 100644 index 000000000..fd8e46aac --- /dev/null +++ b/app/models/report.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Report < ApplicationRecord + belongs_to :account + belongs_to :target_account, class_name: 'Account' + belongs_to :action_taken_by_account, class_name: 'Account' + + scope :unresolved, -> { where(action_taken: false) } + scope :resolved, -> { where(action_taken: true) } +end diff --git a/app/models/setting.rb b/app/models/setting.rb index 3796253d4..31e1ee198 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -2,7 +2,6 @@ class Setting < RailsSettings::Base source Rails.root.join('config/settings.yml') - namespace Rails.env def to_param var diff --git a/app/models/status.rb b/app/models/status.rb index d2be72308..daf128572 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -6,15 +6,15 @@ class Status < ApplicationRecord include Streamable include Cacheable - enum visibility: [:public, :unlisted, :private], _suffix: :visibility + enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility belongs_to :application, class_name: 'Doorkeeper::Application' - belongs_to :account, inverse_of: :statuses + belongs_to :account, inverse_of: :statuses, counter_cache: true belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account' belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies - belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs + belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, counter_cache: :reblogs_count has_many :favourites, inverse_of: :status, dependent: :destroy has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy @@ -37,6 +37,9 @@ class Status < ApplicationRecord scope :remote, -> { where.not(uri: nil) } scope :local, -> { where(uri: nil) } + scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') } + scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') } + cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account def reply? @@ -72,11 +75,17 @@ class Status < ApplicationRecord end def hidden? - private_visibility? + private_visibility? || direct_visibility? end def permitted?(other_account = nil) - private_visibility? ? (account.id == other_account&.id || other_account&.following?(account)) : other_account.nil? || !account.blocking?(other_account) + if direct_visibility? + account.id == other_account&.id || mentions.where(account: other_account).exists? + elsif private_visibility? + account.id == other_account&.id || other_account&.following?(account) || mentions.where(account: other_account).exists? + else + other_account.nil? || !account.blocking?(other_account) + end end def ancestors(account = nil) @@ -105,8 +114,8 @@ class Status < ApplicationRecord def as_public_timeline(account = nil, local_only = false) query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id') .where(visibility: :public) - .where('(statuses.reply = false OR statuses.in_reply_to_account_id = statuses.account_id)') - .where('statuses.reblog_of_id IS NULL') + .without_replies + .without_reblogs query = query.where('accounts.domain IS NULL') if local_only @@ -117,7 +126,7 @@ class Status < ApplicationRecord query = tag.statuses .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id') .where(visibility: :public) - .where('statuses.reblog_of_id IS NULL') + .without_reblogs query = query.where('accounts.domain IS NULL') if local_only @@ -149,21 +158,27 @@ class Status < ApplicationRecord end def permitted_for(target_account, account) - if account&.id == target_account.id || account&.following?(target_account) - where('1 = 1') - elsif !account.nil? && target_account.blocking?(account) + return where.not(visibility: [:private, :direct]) if account.nil? + + if target_account.blocking?(account) # get rid of blocked peeps where('1 = 0') - else - where.not(visibility: :private) + elsif account.id == target_account.id # author can see own stuff + where('1 = 1') + elsif account.following?(target_account) # followers can see followers-only stuff, but also things they are mentioned in + joins('LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = ' + account.id.to_s) + .where('statuses.visibility != ? OR mentions.id IS NOT NULL', Status.visibilities[:direct]) + else # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in + joins('LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = ' + account.id.to_s) + .where('statuses.visibility NOT IN (?) OR mentions.id IS NOT NULL', [Status.visibilities[:direct], Status.visibilities[:private]]) end end private def filter_timeline(query, account) - blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id) - query = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty? - query = query.where('accounts.silenced = TRUE') if account.silenced? + blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id) + Mute.where(account: account).pluck(:target_account_id) + query = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty? # Only give us statuses from people we haven't blocked, or muted, or that have blocked us + query = query.where('accounts.silenced = TRUE') if account.silenced? # and if we're hellbanned, only people who are also hellbanned query end @@ -173,7 +188,7 @@ class Status < ApplicationRecord end before_validation do - text.strip! + text&.strip! spoiler_text&.strip! self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply @@ -185,6 +200,6 @@ class Status < ApplicationRecord private def filter_from_context?(status, account) - account&.blocking?(status.account_id) || !status.permitted?(account) + account&.blocking?(status.account_id) || account&.muting?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account) end end diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb index fcc691bef..ae7ae446e 100644 --- a/app/models/stream_entry.rb +++ b/app/models/stream_entry.rb @@ -6,16 +6,13 @@ class StreamEntry < ApplicationRecord belongs_to :account, inverse_of: :stream_entries belongs_to :activity, polymorphic: true - belongs_to :status, foreign_type: 'Status', foreign_key: 'activity_id' - belongs_to :follow, foreign_type: 'Follow', foreign_key: 'activity_id' - belongs_to :favourite, foreign_type: 'Favourite', foreign_key: 'activity_id' - belongs_to :block, foreign_type: 'Block', foreign_key: 'activity_id' + belongs_to :status, foreign_type: 'Status', foreign_key: 'activity_id', inverse_of: :stream_entry validates :account, :activity, presence: true STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]].freeze - scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES, favourite: [:account, :stream_entry, status: STATUS_INCLUDES], follow: [:target_account, :stream_entry]) } + scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) } def object_type if orphaned? @@ -30,7 +27,7 @@ class StreamEntry < ApplicationRecord end def targeted? - [:follow, :unfollow, :block, :unblock, :share, :favorite].include? verb + [:follow, :request_friend, :authorize, :reject, :unfollow, :block, :unblock, :share, :favorite].include? verb end def target @@ -58,7 +55,7 @@ class StreamEntry < ApplicationRecord end def activity - !new_record? ? send(activity_type.downcase) : super + !new_record? ? send(activity_type.underscore) || super : super end private diff --git a/app/models/tag.rb b/app/models/tag.rb index 77a73cce8..15625ca43 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -3,11 +3,31 @@ class Tag < ApplicationRecord has_and_belongs_to_many :statuses - HASHTAG_RE = /(?:^|[^\/\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i + HASHTAG_RE = /(?:^|[^\/\)\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i validates :name, presence: true, uniqueness: true def to_param name end + + class << self + def search_for(terms, limit = 5) + terms = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' '))) + textsearch = 'to_tsvector(\'simple\', tags.name)' + query = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')' + + sql = <<SQL + SELECT + tags.*, + ts_rank_cd(#{textsearch}, #{query}) AS rank + FROM tags + WHERE #{query} @@ #{textsearch} + ORDER BY rank DESC + LIMIT ? +SQL + + Tag.find_by_sql([sql, limit]) + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 08aac2679..bf2916d90 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,9 +14,10 @@ class User < ApplicationRecord validates :locale, inclusion: I18n.available_locales.map(&:to_s), unless: 'locale.nil?' validates :email, email: true - scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') } - scope :recent, -> { order('id desc') } - scope :admins, -> { where(admin: true) } + scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') } + scope :recent, -> { order('id desc') } + scope :admins, -> { where(admin: true) } + scope :confirmed, -> { where.not(confirmed_at: nil) } def send_devise_notification(notification, *args) devise_mailer.send(notification, self, *args).deliver_later diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb new file mode 100644 index 000000000..f55439dcb --- /dev/null +++ b/app/services/account_search_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class AccountSearchService < BaseService + def call(query, limit, resolve = false, account = nil) + return [] if query.blank? || query.start_with?('#') + + username, domain = query.gsub(/\A@/, '').split('@') + domain = nil if TagManager.instance.local_domain?(domain) + + if domain.nil? + exact_match = Account.find_local(username) + results = account.nil? ? Account.search_for(username, limit) : Account.advanced_search_for(username, account, limit) + else + exact_match = Account.find_remote(username, domain) + results = account.nil? ? Account.search_for("#{username} #{domain}", limit) : Account.advanced_search_for("#{username} #{domain}", account, limit) + end + + results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match + + if resolve && !exact_match && !domain.nil? + results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")] + end + + results + end +end diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb new file mode 100644 index 000000000..ac465bdb2 --- /dev/null +++ b/app/services/authorize_follow_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class AuthorizeFollowService < BaseService + def call(source_account, target_account) + follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) + follow_request.authorize! + NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local? + end + + private + + def build_xml(follow_request) + Nokogiri::XML::Builder.new do |xml| + entry(xml, true) do + unique_id xml, Time.now.utc, follow_request.id, 'FollowRequest' + title xml, "#{follow_request.target_account.acct} authorizes follow request by #{follow_request.account.acct}" + + author(xml) do + include_author xml, follow_request.target_account + end + + object_type xml, :activity + verb xml, :authorize + + target(xml) do + author(xml) do + include_author xml, follow_request.account + end + + object_type xml, :activity + verb xml, :request_friend + + target(xml) do + include_author xml, follow_request.target_account + end + end + end + end.to_xml + end +end diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index 9518b1fcf..6c131bd34 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -1,13 +1,11 @@ # frozen_string_literal: true class BlockDomainService < BaseService - def call(domain, severity) - DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity) - - if severity == :silence - Account.where(domain: domain).update_all(silenced: true) + def call(domain_block) + if domain_block.silence? + Account.where(domain: domain_block.domain).update_all(silenced: true) else - Account.where(domain: domain).find_each do |account| + Account.where(domain: domain_block.domain).find_each do |account| account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed? SuspendAccountService.new.call(account) end diff --git a/app/services/block_service.rb b/app/services/block_service.rb index e04b6cc39..bd914d8be 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class BlockService < BaseService + include StreamEntryRenderer + def call(account, target_account) return if account.id == target_account.id @@ -10,6 +12,28 @@ class BlockService < BaseService block = account.block!(target_account) BlockWorker.perform_async(account.id, target_account.id) - NotificationWorker.perform_async(block.stream_entry.id, target_account.id) unless target_account.local? + NotificationWorker.perform_async(build_xml(block), account.id, target_account.id) unless target_account.local? + end + + private + + def build_xml(block) + Nokogiri::XML::Builder.new do |xml| + entry(xml, true) do + unique_id xml, block.created_at, block.id, 'Block' + title xml, "#{block.account.acct} no longer wishes to interact with #{block.target_account.acct}" + + author(xml) do + include_author xml, block.account + end + + object_type xml, :activity + verb xml, :block + + target(xml) do + include_author xml, block.target_account + end + end + end.to_xml end end diff --git a/app/services/concerns/stream_entry_renderer.rb b/app/services/concerns/stream_entry_renderer.rb new file mode 100644 index 000000000..a4255daea --- /dev/null +++ b/app/services/concerns/stream_entry_renderer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module StreamEntryRenderer + def stream_entry_to_xml(stream_entry) + renderer = StreamEntriesController.renderer.new(method: 'get', http_host: Rails.configuration.x.local_domain, https: Rails.configuration.x.use_https) + renderer.render(:show, assigns: { stream_entry: stream_entry }, formats: [:atom]) + end +end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 71f6cbca1..42222c25b 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -4,8 +4,15 @@ class FanOutOnWriteService < BaseService # Push a status into home and mentions feeds # @param [Status] status def call(status) + raise Mastodon::RaceConditionError if status.visibility.nil? + deliver_to_self(status) if status.account.local? - deliver_to_followers(status) + + if status.direct_visibility? + deliver_to_mentioned_followers(status) + else + deliver_to_followers(status) + end return if status.account.silenced? || !status.public_visibility? || status.reblog? @@ -26,9 +33,18 @@ class FanOutOnWriteService < BaseService def deliver_to_followers(status) Rails.logger.debug "Delivering status #{status.id} to followers" - status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).find_each do |follower| - next if FeedManager.instance.filter?(:home, status, follower) - FeedManager.instance.push(:home, follower, status) + status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).find_each do |follower| + FeedInsertWorker.perform_async(status.id, follower.id) + end + end + + def deliver_to_mentioned_followers(status) + Rails.logger.debug "Delivering status #{status.id} to mentioned followers" + + status.mentions.includes(:account).each do |mention| + mentioned_account = mention.account + next if !mentioned_account.local? || !mentioned_account.following?(status.account) || FeedManager.instance.filter?(:home, status, mention.account_id) + FeedManager.instance.push(:home, mentioned_account, status) end end @@ -37,9 +53,9 @@ class FanOutOnWriteService < BaseService payload = FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status) - status.tags.find_each do |tag| - FeedManager.instance.broadcast("hashtag:#{tag.name}", event: 'update', payload: payload) - FeedManager.instance.broadcast("hashtag:#{tag.name}:local", event: 'update', payload: payload) if status.account.local? + status.tags.pluck(:name).each do |hashtag| + FeedManager.instance.broadcast("hashtag:#{hashtag}", event: 'update', payload: payload) + FeedManager.instance.broadcast("hashtag:#{hashtag}:local", event: 'update', payload: payload) if status.account.local? end end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index d5fbd29e9..5cc96403c 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -6,18 +6,42 @@ class FavouriteService < BaseService # @param [Status] status # @return [Favourite] def call(account, status) - raise Mastodon::NotPermitted unless status.permitted?(account) + raise Mastodon::NotPermittedError unless status.permitted?(account) favourite = Favourite.create!(account: account, status: status) - Pubsubhubbub::DistributionWorker.perform_async(favourite.stream_entry.id) - if status.local? NotifyService.new.call(favourite.status.account, favourite) else - NotificationWorker.perform_async(favourite.stream_entry.id, status.account_id) + NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) end favourite end + + private + + def build_xml(favourite) + description = "#{favourite.account.acct} favourited a status by #{favourite.status.account.acct}" + + Nokogiri::XML::Builder.new do |xml| + entry(xml, true) do + unique_id xml, favourite.created_at, favourite.id, 'Favourite' + title xml, description + content xml, description + + author(xml) do + include_author xml, favourite.account + end + + object_type xml, :activity + verb xml, :favorite + in_reply_to xml, TagManager.instance.uri_for(favourite.status), TagManager.instance.url_for(favourite.status) + + target(xml) do + include_target xml, favourite.status + end + end + end.to_xml + end end diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index 98ee1db84..c3dad1eb9 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -2,6 +2,8 @@ class FetchAtomService < BaseService def call(url) + return if url.blank? + response = http_client.head(url) Rails.logger.debug "Remote status HEAD request returned code #{response.code}" @@ -45,6 +47,6 @@ class FetchAtomService < BaseService end def http_client - HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50).follow + HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow end end diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb index 3c3694a65..6a6a696d6 100644 --- a/app/services/fetch_remote_account_service.rb +++ b/app/services/fetch_remote_account_service.rb @@ -1,8 +1,13 @@ # frozen_string_literal: true class FetchRemoteAccountService < BaseService - def call(url) - atom_url, body = FetchAtomService.new.call(url) + def call(url, prefetched_body = nil) + if prefetched_body.nil? + atom_url, body = FetchAtomService.new.call(url) + else + atom_url = url + body = prefetched_body + end return nil if atom_url.nil? process_atom(atom_url, body) @@ -22,7 +27,9 @@ class FetchRemoteAccountService < BaseService Rails.logger.debug "Going to webfinger #{username}@#{domain}" - return FollowRemoteAccountService.new.call("#{username}@#{domain}") + account = FollowRemoteAccountService.new.call("#{username}@#{domain}") + UpdateRemoteProfileService.new.call(xml, account) unless account.nil? + account rescue TypeError Rails.logger.debug "Unparseable URL given: #{url}" nil diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb new file mode 100644 index 000000000..2185ceb20 --- /dev/null +++ b/app/services/fetch_remote_resource_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class FetchRemoteResourceService < BaseService + def call(url) + atom_url, body = FetchAtomService.new.call(url) + + return nil if atom_url.nil? + + xml = Nokogiri::XML(body) + xml.encoding = 'utf-8' + + if xml.root.name == 'feed' + FetchRemoteAccountService.new.call(atom_url, body) + elsif xml.root.name == 'entry' + FetchRemoteStatusService.new.call(atom_url, body) + end + end +end diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb index 7063231e4..e2d185723 100644 --- a/app/services/fetch_remote_status_service.rb +++ b/app/services/fetch_remote_status_service.rb @@ -1,8 +1,13 @@ # frozen_string_literal: true class FetchRemoteStatusService < BaseService - def call(url) - atom_url, body = FetchAtomService.new.call(url) + def call(url, prefetched_body = nil) + if prefetched_body.nil? + atom_url, body = FetchAtomService.new.call(url) + else + atom_url = url + body = prefetched_body + end return nil if atom_url.nil? process_atom(atom_url, body) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 9f34cb6ac..17b3b2542 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -1,14 +1,16 @@ # frozen_string_literal: true class FollowService < BaseService + include StreamEntryRenderer + # Follow a remote user, notify remote user about the follow # @param [Account] source_account From which to follow # @param [String] uri User URI to follow in the form of username@domain def call(source_account, uri) - target_account = follow_remote_account_service.call(uri) + target_account = FollowRemoteAccountService.new.call(uri) raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended? - raise Mastodon::NotPermitted if target_account.blocking?(source_account) || source_account.blocking?(target_account) + raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) if target_account.locked? request_follow(source_account, target_account) @@ -20,10 +22,14 @@ class FollowService < BaseService private def request_follow(source_account, target_account) - return unless target_account.local? - follow_request = FollowRequest.create!(account: source_account, target_account: target_account) - NotifyService.new.call(target_account, follow_request) + + if target_account.local? + NotifyService.new.call(target_account, follow_request) + else + NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id) + AfterRemoteFollowRequestWorker.perform_async(follow_request.id) + end follow_request end @@ -34,12 +40,12 @@ class FollowService < BaseService if target_account.local? NotifyService.new.call(target_account, follow) else - subscribe_service.call(target_account) - NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) + SubscribeService.new.call(target_account) unless target_account.subscribed? + NotificationWorker.perform_async(build_follow_xml(follow), source_account.id, target_account.id) + AfterRemoteFollowWorker.perform_async(follow.id) end MergeWorker.perform_async(target_account.id, source_account.id) - Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id) follow end @@ -48,11 +54,49 @@ class FollowService < BaseService Redis.current end - def follow_remote_account_service - @follow_remote_account_service ||= FollowRemoteAccountService.new + def build_follow_request_xml(follow_request) + description = "#{follow_request.account.acct} requested to follow #{follow_request.target_account.acct}" + + Nokogiri::XML::Builder.new do |xml| + entry(xml, true) do + unique_id xml, follow_request.created_at, follow_request.id, 'FollowRequest' + title xml, description + content xml, description + + author(xml) do + include_author xml, follow_request.account + end + + object_type xml, :activity + verb xml, :request_friend + + target(xml) do + include_author xml, follow_request.target_account + end + end + end.to_xml end - def subscribe_service - @subscribe_service ||= SubscribeService.new + def build_follow_xml(follow) + description = "#{follow.account.acct} started following #{follow.target_account.acct}" + + Nokogiri::XML::Builder.new do |xml| + entry(xml, true) do + unique_id xml, follow.created_at, follow.id, 'Follow' + title xml, description + content xml, description + + author(xml) do + include_author xml, follow.account + end + + object_type xml, :activity + verb xml, :follow + + target(xml) do + include_author xml, follow.target_account + end + end + end.to_xml end end diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb new file mode 100644 index 000000000..0050cfc8d --- /dev/null +++ b/app/services/mute_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class MuteService < BaseService + def call(account, target_account) + return if account.id == target_account.id + clear_home_timeline(account, target_account) + account.mute!(target_account) + end + + private + + def clear_home_timeline(account, target_account) + home_key = FeedManager.instance.key(:home, account.id) + + target_account.statuses.select('id').find_each do |status| + redis.zrem(home_key, status.id) + end + end + + def redis + Redis.current + end +end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 942cd9d21..24486f220 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -17,7 +17,7 @@ class NotifyService < BaseService private def blocked_mention? - FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient) + FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient.id) end def blocked_favourite? diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 979941c84..b8179f7dc 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -13,6 +13,7 @@ class PostStatusService < BaseService # @option [Doorkeeper::Application] :application # @return [Status] def call(account, text, in_reply_to = nil, options = {}) + media = validate_media!(options[:media_ids]) status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive], @@ -20,7 +21,7 @@ class PostStatusService < BaseService visibility: options[:visibility], application: options[:application]) - attach_media(status, options[:media_ids]) + attach_media(status, media) process_mentions_service.call(status) process_hashtags_service.call(status) @@ -33,10 +34,20 @@ class PostStatusService < BaseService private - def attach_media(status, media_ids) + def validate_media!(media_ids) return if media_ids.nil? || !media_ids.is_a?(Enumerable) + raise Mastodon::ValidationError, 'Cannot attach more than 4 files' if media_ids.size > 4 + media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(4).map(&:to_i)) + + raise Mastodon::ValidationError, 'Cannot attach a video to a toot that already contains images' if media.size > 1 && media.find(&:video?) + + media + end + + def attach_media(status, media) + return if media.nil? media.update(status_id: status.id) end diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb index 54d11b631..07dcb81da 100644 --- a/app/services/precompute_feed_service.rb +++ b/app/services/precompute_feed_service.rb @@ -4,10 +4,12 @@ class PrecomputeFeedService < BaseService # Fill up a user's home/mentions feed from DB and return a subset # @param [Symbol] type :home or :mentions # @param [Account] account - def call(type, account) - Status.send("as_#{type}_timeline", account).limit(FeedManager::MAX_ITEMS).each do |status| - next if FeedManager.instance.filter?(type, status, account) - redis.zadd(FeedManager.instance.key(type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id) + def call(_, account) + redis.pipelined do + Status.as_home_timeline(account).limit(FeedManager::MAX_ITEMS / 4).each do |status| + next if status.direct_visibility? || FeedManager.instance.filter?(:home, status, account.id) + redis.zadd(FeedManager.instance.key(:home, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id) + end end end diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb index c411e3e82..69911abc5 100644 --- a/app/services/process_feed_service.rb +++ b/app/services/process_feed_service.rb @@ -61,12 +61,25 @@ class ProcessFeedService < BaseService status.save! - NotifyService.new.call(status.reblog.account, status) if status.reblog? && status.reblog.account.local? + notify_about_mentions!(status) unless status.reblog? + notify_about_reblog!(status) if status.reblog? && status.reblog.account.local? Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution" DistributionWorker.perform_async(status.id) status end + def notify_about_mentions!(status) + status.mentions.includes(:account).each do |mention| + mentioned_account = mention.account + next unless mentioned_account.local? + NotifyService.new.call(mentioned_account, mention) + end + end + + def notify_about_reblog!(status) + NotifyService.new.call(status.reblog.account, status) + end + def delete_status Rails.logger.debug "Deleting remote status #{id}" status = Status.find_by(uri: id) @@ -106,7 +119,8 @@ class ProcessFeedService < BaseService text: content(entry), spoiler_text: content_warning(entry), created_at: published(entry), - reply: thread?(entry) + reply: thread?(entry), + visibility: visibility_scope(entry) ) if thread?(entry) @@ -144,15 +158,9 @@ class ProcessFeedService < BaseService def mentions_from_xml(parent, xml) processed_account_ids = [] - public_visibility = false xml.xpath('./xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each do |link| - if link['ostatus:object-type'] == TagManager::TYPES[:collection] && link['href'] == TagManager::COLLECTIONS[:public] - public_visibility = true - next - elsif link['ostatus:object-type'] == TagManager::TYPES[:group] - next - end + next if [TagManager::TYPES[:group], TagManager::TYPES[:collection]].include? link['ostatus:object-type'] url = Addressable::URI.parse(link['href']) @@ -164,17 +172,11 @@ class ProcessFeedService < BaseService next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id) - mention = mentioned_account.mentions.where(status: parent).first_or_create(status: parent) - - # Notify local user - NotifyService.new.call(mentioned_account, mention) if mentioned_account.local? + mentioned_account.mentions.where(status: parent).first_or_create(status: parent) # So we can skip duplicate mentions processed_account_ids << mentioned_account.id end - - parent.visibility = public_visibility ? :public : :unlisted - parent.save! end def hashtags_from_xml(parent, xml) @@ -189,6 +191,9 @@ class ProcessFeedService < BaseService next unless link['href'] media = MediaAttachment.where(status: parent, remote_url: link['href']).first_or_initialize(account: parent.account, status: parent, remote_url: link['href']) + parsed_url = URI.parse(link['href']) + + next if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? begin media.file_remote_url = link['href'] @@ -230,6 +235,10 @@ class ProcessFeedService < BaseService xml.at_xpath('./xmlns:summary', xmlns: TagManager::XMLNS)&.content || '' end + def visibility_scope(xml = @xml) + xml.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content&.to_sym || :public + end + def published(xml = @xml) xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content end diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb index 5f91e3127..d5f7b4b3c 100644 --- a/app/services/process_interaction_service.rb +++ b/app/services/process_interaction_service.rb @@ -29,10 +29,18 @@ class ProcessInteractionService < BaseService case verb(xml) when :follow follow!(account, target_account) unless target_account.locked? || target_account.blocking?(account) + when :request_friend + follow_request!(account, target_account) unless !target_account.locked? || target_account.blocking?(account) + when :authorize + authorize_follow_request!(account, target_account) + when :reject + reject_follow_request!(account, target_account) when :unfollow unfollow!(account, target_account) when :favorite favourite!(xml, account) + when :unfavorite + unfavourite!(xml, account) when :post add_post!(body, account) if mentions_account?(xml, target_account) when :share @@ -56,7 +64,7 @@ class ProcessInteractionService < BaseService end def mentions_account?(xml, account) - xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each { |mention_link| return true if mention_link.attribute('href').value == TagManager.instance.url_for(account) } + xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each { |mention_link| return true if [TagManager.instance.uri_for(account), TagManager.instance.url_for(account)].include?(mention_link.attribute('href').value) } false end @@ -72,6 +80,22 @@ class ProcessInteractionService < BaseService NotifyService.new.call(target_account, follow) end + def follow_request!(account, target_account) + follow_request = FollowRequest.create!(account: account, target_account: target_account) + NotifyService.new.call(target_account, follow_request) + end + + def authorize_follow_request!(account, target_account) + follow_request = FollowRequest.find_by(account: target_account, target_account: account) + follow_request&.authorize! + SubscribeService.new.call(account) unless account.subscribed? + end + + def reject_follow_request!(account, target_account) + follow_request = FollowRequest.find_by(account: target_account, target_account: account) + follow_request&.reject! + end + def unfollow!(account, target_account) account.unfollow!(target_account) end @@ -99,6 +123,12 @@ class ProcessInteractionService < BaseService NotifyService.new.call(current_status.account, favourite) end + def unfavourite!(xml, from_account) + current_status = status(xml) + favourite = current_status.favourites.where(account: from_account).first + favourite&.destroy + end + def add_post!(body, account) process_feed_service.call(body, account) end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 72568e702..aa0a4d71b 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ProcessMentionsService < BaseService + include StreamEntryRenderer + # Scan status for mentions and fetch remote mentioned users, create # local mention pointers, send Salmon notifications to mentioned # remote users @@ -25,15 +27,13 @@ class ProcessMentionsService < BaseService mentioned_account.mentions.where(status: status).first_or_create(status: status) end - status.mentions.each do |mention| + status.mentions.includes(:account).each do |mention| mentioned_account = mention.account - next if status.private_visibility? && (!mentioned_account.following?(status.account) || !mentioned_account.local?) - if mentioned_account.local? NotifyService.new.call(mentioned_account, mention) else - NotificationWorker.perform_async(status.stream_entry.id, mentioned_account.id) + NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id) end end end diff --git a/app/services/pubsubhubbub/subscribe_service.rb b/app/services/pubsubhubbub/subscribe_service.rb index 343376d77..bf36e3fa6 100644 --- a/app/services/pubsubhubbub/subscribe_service.rb +++ b/app/services/pubsubhubbub/subscribe_service.rb @@ -2,8 +2,9 @@ class Pubsubhubbub::SubscribeService < BaseService def call(account, callback, secret, lease_seconds) - return ['Invalid topic URL', 422] if account.nil? - return ['Invalid callback URL', 422] unless !callback.blank? && callback =~ /\A#{URI.regexp(%w(http https))}\z/ + return ['Invalid topic URL', 422] if account.nil? + return ['Invalid callback URL', 422] unless !callback.blank? && callback =~ /\A#{URI.regexp(%w(http https))}\z/ + return ['Callback URL not allowed', 403] if DomainBlock.blocked?(Addressable::URI.parse(callback).host) subscription = Subscription.where(account: account, callback_url: callback).first_or_create!(account: account, callback_url: callback) Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'subscribe', secret, lease_seconds) diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 4ea0dbf6c..11446ce28 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ReblogService < BaseService + include StreamEntryRenderer + # Reblog a status and notify its remote author # @param [Account] account Account to reblog from # @param [Status] reblogged_status Status to be reblogged @@ -8,7 +10,7 @@ class ReblogService < BaseService def call(account, reblogged_status) reblogged_status = reblogged_status.reblog if reblogged_status.reblog? - raise Mastodon::NotPermitted if reblogged_status.private_visibility? || !reblogged_status.permitted?(account) + raise Mastodon::NotPermittedError if reblogged_status.direct_visibility? || reblogged_status.private_visibility? || !reblogged_status.permitted?(account) reblog = account.statuses.create!(reblog: reblogged_status, text: '') @@ -18,15 +20,9 @@ class ReblogService < BaseService if reblogged_status.local? NotifyService.new.call(reblog.reblog.account, reblog) else - NotificationWorker.perform_async(reblog.stream_entry.id, reblog.reblog.account_id) + NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), account.id, reblog.reblog.account_id) end reblog end - - private - - def send_interaction_service - @send_interaction_service ||= SendInteractionService.new - end end diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb new file mode 100644 index 000000000..1b03d62e6 --- /dev/null +++ b/app/services/reject_follow_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class RejectFollowService < BaseService + def call(source_account, target_account) + follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) + follow_request.reject! + NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local? + end + + private + + def build_xml(follow_request) + Nokogiri::XML::Builder.new do |xml| + entry(xml, true) do + unique_id xml, Time.now.utc, follow_request.id, 'FollowRequest' + title xml, "#{follow_request.target_account.acct} rejects follow request by #{follow_request.account.acct}" + + author(xml) do + include_author xml, follow_request.target_account + end + + object_type xml, :activity + verb xml, :reject + + target(xml) do + author(xml) do + include_author xml, follow_request.account + end + + object_type xml, :activity + verb xml, :request_friend + + target(xml) do + include_author xml, follow_request.target_account + end + end + end + end.to_xml + end +end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 48e8dd3b8..cf1f432e4 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class RemoveStatusService < BaseService + include StreamEntryRenderer + def call(status) remove_from_self(status) if status.account.local? remove_from_followers(status) @@ -30,12 +32,16 @@ class RemoveStatusService < BaseService end def remove_from_mentioned(status) + notified_domains = [] + status.mentions.each do |mention| mentioned_account = mention.account if mentioned_account.local? unpush(:mentions, mentioned_account, status) else + next if notified_domains.include?(mentioned_account.domain) + notified_domains << mentioned_account.domain send_delete_salmon(mentioned_account, status) end end @@ -43,7 +49,7 @@ class RemoveStatusService < BaseService def send_delete_salmon(account, status) return unless status.local? - NotificationWorker.perform_async(status.stream_entry.id, account.id) + NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, account.id) end def remove_reblogs(status) @@ -53,7 +59,7 @@ class RemoveStatusService < BaseService end def unpush(type, receiver, status) - if status.reblog? + if status.reblog? && !redis.zscore(FeedManager.instance.key(type, receiver.id), status.reblog_of_id).nil? 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) diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 04de8a134..e9745010b 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -1,24 +1,19 @@ # frozen_string_literal: true class SearchService < BaseService - def call(query, limit, resolve = false) - return if query.blank? || query.start_with?('#') + def call(query, limit, resolve = false, account = nil) + results = { accounts: [], hashtags: [], statuses: [] } - username, domain = query.gsub(/\A@/, '').split('@') + return results if query.blank? - if domain.nil? - exact_match = Account.find_local(username) - results = Account.search_for(username) - else - exact_match = Account.find_remote(username, domain) - results = Account.search_for("#{username} #{domain}") - end + if query =~ /\Ahttps?:\/\// + resource = FetchRemoteResourceService.new.call(query) - results = results.limit(limit).to_a - results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match - - if resolve && !exact_match && !domain.nil? - results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")] + results[:accounts] << resource if resource.is_a?(Account) + results[:statuses] << resource if resource.is_a?(Status) + else + results[:accounts] = AccountSearchService.new.call(query, limit, resolve, account) + results[:hashtags] = Tag.search_for(query.gsub(/\A#/, ''), limit) unless query.start_with?('@') end results diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb index 05a1e77e3..99113eeca 100644 --- a/app/services/send_interaction_service.rb +++ b/app/services/send_interaction_service.rb @@ -2,27 +2,16 @@ class SendInteractionService < BaseService # Send an Atom representation of an interaction to a remote Salmon endpoint - # @param [StreamEntry] stream_entry + # @param [String] Entry XML + # @param [Account] source_account # @param [Account] target_account - def call(stream_entry, target_account) - envelope = salmon.pack(entry_xml(stream_entry), stream_entry.account.keypair) + def call(xml, source_account, target_account) + envelope = salmon.pack(xml, source_account.keypair) salmon.post(target_account.salmon_url, envelope) end private - def entry_xml(stream_entry) - Nokogiri::XML::Builder.new do |xml| - entry(xml, true) do - author(xml) do - include_author xml, stream_entry.account - end - - include_entry xml, stream_entry - end - end.to_xml - end - def salmon @salmon ||= OStatus2::Salmon.new end diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb index f389364f9..c4f789f74 100644 --- a/app/services/unblock_service.rb +++ b/app/services/unblock_service.rb @@ -5,6 +5,28 @@ class UnblockService < BaseService return unless account.blocking?(target_account) unblock = account.unblock!(target_account) - NotificationWorker.perform_async(unblock.stream_entry.id, target_account.id) unless target_account.local? + NotificationWorker.perform_async(build_xml(unblock), account.id, target_account.id) unless target_account.local? + end + + private + + def build_xml(block) + Nokogiri::XML::Builder.new do |xml| + entry(xml, true) do + unique_id xml, Time.now.utc, block.id, 'Block' + title xml, "#{block.account.acct} no longer blocks #{block.target_account.acct}" + + author(xml) do + include_author xml, block.account + end + + object_type xml, :activity + verb xml, :unblock + + target(xml) do + include_author xml, block.target_account + end + end + end.to_xml end end diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb index de6e84e7d..5f0ba4254 100644 --- a/app/services/unfavourite_service.rb +++ b/app/services/unfavourite_service.rb @@ -5,10 +5,34 @@ class UnfavouriteService < BaseService favourite = Favourite.find_by!(account: account, status: status) favourite.destroy! - unless status.local? - NotificationWorker.perform_async(favourite.stream_entry.id, status.account_id) - end + NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) unless status.local? favourite end + + private + + def build_xml(favourite) + description = "#{favourite.account.acct} no longer favourites a status by #{favourite.status.account.acct}" + + Nokogiri::XML::Builder.new do |xml| + entry(xml, true) do + unique_id xml, Time.now.utc, favourite.id, 'Favourite' + title xml, description + content xml, description + + author(xml) do + include_author xml, favourite.account + end + + object_type xml, :activity + verb xml, :unfavorite + in_reply_to xml, TagManager.instance.uri_for(favourite.status), TagManager.instance.url_for(favourite.status) + + target(xml) do + include_target xml, favourite.status + end + end + end.to_xml + end end diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index f469793c1..3440da364 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -6,7 +6,32 @@ class UnfollowService < BaseService # @param [Account] target_account Which to unfollow 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? + NotificationWorker.perform_async(build_xml(follow), source_account.id, target_account.id) unless target_account.local? UnmergeWorker.perform_async(target_account.id, source_account.id) end + + private + + def build_xml(follow) + description = "#{follow.account.acct} is no longer following #{follow.target_account.acct}" + + Nokogiri::XML::Builder.new do |xml| + entry(xml, true) do + unique_id xml, Time.now.utc, follow.id, 'Follow' + title xml, description + content xml, description + + author(xml) do + include_author xml, follow.account + end + + object_type xml, :activity + verb xml, :unfollow + + target(xml) do + include_author xml, follow.target_account + end + end + end.to_xml + end end diff --git a/app/services/unmute_service.rb b/app/services/unmute_service.rb new file mode 100644 index 000000000..6aeea358f --- /dev/null +++ b/app/services/unmute_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class UnmuteService < BaseService + def call(account, target_account) + return unless account.muting?(target_account) + + account.unmute!(target_account) + + MergeWorker.perform_async(target_account.id, account.id) if account.following?(target_account) + end +end diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb index ad9c56540..74baa1cc5 100644 --- a/app/services/update_remote_profile_service.rb +++ b/app/services/update_remote_profile_service.rb @@ -10,9 +10,11 @@ class UpdateRemoteProfileService < BaseService 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.locked = author_xml.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content == 'private' 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? + account.header_remote_url = author_xml.at_xpath('./xmlns:link[@rel="header"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="header"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="header"]', xmlns: TagManager::XMLNS)['href'].blank? end end diff --git a/app/views/about/index.html.haml b/app/views/about/index.html.haml index 88bfe3d61..ebca4213a 100644 --- a/app/views/about/index.html.haml +++ b/app/views/about/index.html.haml @@ -20,18 +20,81 @@ Mastodon %p= t('about.about_mastodon').html_safe - %p= t('about.about_instance', instance: Rails.configuration.x.local_domain).html_safe - .screenshot= image_tag 'screenshot.png' + .screenshot-with-signup + .mascot= image_tag 'fluffy-elephant-friend.png' + + - if @open_registrations + = simple_form_for(@user, url: user_registration_path) do |f| + = f.simple_fields_for :account do |ff| + = ff.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username') } + + = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } + = f.input :password, autocomplete: "off", placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password') } + = f.input :password_confirmation, autocomplete: "off", placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password') } + + .actions + = f.button :button, t('about.get_started'), type: :submit + + .info + = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn' + · + = link_to t('about.about_this'), about_more_path + - else + .closed-registrations-message + - if @closed_registrations_message.blank? + %p= t('about.closed_registrations') + - else + = @closed_registrations_message.html_safe + .info + = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn' + · + = link_to t('about.other_instances'), 'https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/List-of-Mastodon-instances.md' + · + = link_to t('about.about_this'), about_more_path + + %h3= t('about.features_headline') + + .features-list + .features-list__column + %ul.fa-ul + %li + = fa_icon('li check-square') + = t 'about.features.chronology' + %li + = fa_icon('li check-square') + = t 'about.features.public' + %li + = fa_icon('li check-square') + = t 'about.features.characters' + %li + = fa_icon('li check-square') + = t 'about.features.gifv' + .features-list__column + %ul.fa-ul + %li + = fa_icon('li check-square') + = t 'about.features.privacy' + %li + = fa_icon('li check-square') + = t 'about.features.blocks' + %li + = fa_icon('li check-square') + = t 'about.features.ethics' + %li + = fa_icon('li check-square') + = t 'about.features.api' - unless @description.blank? + %h3= t('about.description_headline', domain: Rails.configuration.x.local_domain) %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.apps'), 'https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md' + · = 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' + · + = link_to t('about.other_instances'), 'https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/List-of-Mastodon-instances.md' diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index f575e855e..0d43fba30 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -20,15 +20,15 @@ .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), class: 'u-url u-uid' do + .counter{ class: active_nav_class(short_account_url(@account)) } + = link_to short_account_url(@account), class: 'u-url u-uid' do %span.counter-label= t('accounts.posts') - %span.counter-number= number_with_delimiter @account.statuses.count + %span.counter-number= number_with_delimiter @account.statuses_count .counter{ class: active_nav_class(following_account_url(@account)) } = link_to following_account_url(@account) do %span.counter-label= t('accounts.following') - %span.counter-number= number_with_delimiter @account.following.count + %span.counter-number= number_with_delimiter @account.following_count .counter{ class: active_nav_class(followers_account_url(@account)) } = link_to followers_account_url(@account) do %span.counter-label= t('accounts.followers') - %span.counter-number= number_with_delimiter @account.followers.count + %span.counter-number= number_with_delimiter @account.followers_count diff --git a/app/views/accounts/show.atom.ruby b/app/views/accounts/show.atom.ruby index a22568396..e15021178 100644 --- a/app/views/accounts/show.atom.ruby +++ b/app/views/accounts/show.atom.ruby @@ -6,7 +6,7 @@ Nokogiri::XML::Builder.new do |xml| title xml, @account.display_name subtitle xml, @account.note updated_at xml, stream_updated_at - logo xml, full_asset_url(@account.avatar.url( :original)) + logo xml, full_asset_url(@account.avatar.url(:original)) author(xml) do include_author xml, @account @@ -14,6 +14,7 @@ Nokogiri::XML::Builder.new do |xml| link_alternate xml, TagManager.instance.url_for(@account) link_self xml, account_url(@account, format: 'atom') + link_next xml, account_url(@account, format: 'atom', max_id: @entries.last.id) if @entries.size == 20 link_hub xml, api_push_url link_salmon xml, api_salmon_url(@account.id) diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index c194ce33d..3b8c67b45 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -14,6 +14,9 @@ %meta{ property: 'og:image:height', content: '120' }/ %meta{ property: 'twitter:card', content: 'summary' }/ +- if !user_signed_in? && !Rails.configuration.x.single_user_mode + = render partial: 'shared/landing_strip', locals: { account: @account } + .h-feed %data.p-name{ value: "#{@account.username} on #{Rails.configuration.x.local_domain}" }/ @@ -28,4 +31,4 @@ .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' + = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), short_account_url(@account, max_id: @statuses.last.id), class: 'next_page', rel: 'next' diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml index a93aa9143..f8ed4ef97 100644 --- a/app/views/admin/accounts/index.html.haml +++ b/app/views/admin/accounts/index.html.haml @@ -25,9 +25,7 @@ %tr %th Username %th Domain - %th Subscribed - %th Silenced - %th Suspended + %th= fa_icon 'paper-plane-o' %th %tbody - @accounts.each do |account| @@ -44,16 +42,6 @@ - else %i.fa.fa-times %td - - if account.silenced? - %i.fa.fa-check - - else - %i.fa.fa-times - %td - - if account.suspended? - %i.fa.fa-check - - else - %i.fa.fa-times - %td = table_link_to 'circle', 'Web', web_path("accounts/#{account.id}") = table_link_to 'globe', 'Public', TagManager.instance.url_for(account) = table_link_to 'pencil', 'Edit', admin_account_path(account.id) diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 7d3f449e5..ba1c3bae7 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -18,8 +18,15 @@ %th E-mail %td= @account.user.email %tr - %th Current IP + %th Most recent IP %td= @account.user.current_sign_in_ip + %tr + %th Most recent activity + %td + - if @account.user.current_sign_in_at + = l @account.user.current_sign_in_at + - else + Never - else %tr %th Profile URL @@ -27,14 +34,39 @@ %tr %th Feed URL %td= link_to @account.remote_url + %tr + %th PuSH subscription expires + %td + - if @account.subscribed? + = l @account.subscription_expires_at + - else + Not subscribed + %tr + %th Salmon URL + %td= link_to @account.salmon_url -= simple_form_for @account, url: admin_account_path(@account.id) do |f| - = render 'shared/error_messages', object: @account - - = f.input :silenced, as: :boolean, wrapper: :with_label - = f.input :suspended, as: :boolean, wrapper: :with_label + %tr + %th Follows + %td= @account.following_count + %tr + %th Followers + %td= @account.followers_count + %tr + %th Statuses + %td= @account.statuses_count + %tr + %th Media attachments + %td + = @account.media_attachments.count + = surround '(', ')' do + = number_to_human_size @account.media_attachments.sum('file_file_size') - .actions - = f.button :button, t('generic.save_changes'), type: :submit +- if @account.silenced? + = link_to 'Undo silence', unsilence_admin_account_path(@account.id), method: :post, class: 'button' +- else + = link_to 'Silence', silence_admin_account_path(@account.id), method: :post, class: 'button' -= link_to 'Perform full suspension', suspend_admin_account_path(@account.id), method: :post, data: { confirm: 'Are you sure?' }, class: 'button' +- if @account.suspended? + = link_to 'Undo suspension', unsuspend_admin_account_path(@account.id), method: :post, class: 'button' +- else + = link_to 'Perform full suspension', suspend_admin_account_path(@account.id), method: :post, data: { confirm: 'Are you sure?' }, class: 'button' diff --git a/app/views/admin/domain_blocks/index.html.haml b/app/views/admin/domain_blocks/index.html.haml index dbaeb4716..eb7894b86 100644 --- a/app/views/admin/domain_blocks/index.html.haml +++ b/app/views/admin/domain_blocks/index.html.haml @@ -14,3 +14,4 @@ %td= block.severity = will_paginate @blocks, pagination_options += link_to 'Add new', new_admin_domain_block_path, class: 'button' diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml new file mode 100644 index 000000000..fbd39d6cf --- /dev/null +++ b/app/views/admin/domain_blocks/new.html.haml @@ -0,0 +1,18 @@ +- content_for :page_title do + New domain block + += simple_form_for @domain_block, url: admin_domain_blocks_path do |f| + = render 'shared/error_messages', object: @domain_block + + %p.hint The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts. + + = f.input :domain, placeholder: 'Domain' + = f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false + + %p.hint + %strong Silence + will make the account's posts invisible to anyone who isn't following them. + %strong Suspend + will remove all of the account's content, media, and profile data. + .actions + = f.button :button, 'Create block', type: :submit diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml new file mode 100644 index 000000000..839259dc2 --- /dev/null +++ b/app/views/admin/reports/index.html.haml @@ -0,0 +1,32 @@ +- content_for :page_title do + Reports + +.filters + .filter-subset + %strong Status + %ul + %li= filter_link_to 'Unresolved', action_taken: nil + %li= filter_link_to 'Resolved', action_taken: '1' + += form_tag do + + %table.table + %thead + %tr + %th + %th ID + %th Target + %th Reported by + %th Comment + %th + %tbody + - @reports.each do |report| + %tr + %td= check_box_tag 'select', report.id + %td= "##{report.id}" + %td= link_to report.target_account.acct, admin_account_path(report.target_account.id) + %td= link_to report.account.acct, admin_account_path(report.account.id) + %td= truncate(report.comment, length: 30, separator: ' ') + %td= table_link_to 'circle', 'View', admin_report_path(report) + += will_paginate @reports, pagination_options diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml new file mode 100644 index 000000000..caa8415df --- /dev/null +++ b/app/views/admin/reports/show.html.haml @@ -0,0 +1,44 @@ +- content_for :page_title do + = "Report ##{@report.id}" + +.report-accounts + .report-accounts__item + %strong Reported account: + = render partial: 'authorize_follow/card', locals: { account: @report.target_account } + .report-accounts__item + %strong Reported by: + = render partial: 'authorize_follow/card', locals: { account: @report.account } + +%p + %strong Comment: + - if @report.comment.blank? + None + - else + = @report.comment + +- unless @statuses.empty? + %hr/ + + - @statuses.each do |status| + .report-status + .activity-stream.activity-stream-headless + .entry= render partial: 'stream_entries/simple_status', locals: { status: status } + .report-status__actions + = link_to remove_admin_report_path(@report, status_id: status.id), method: :post, class: 'icon-button', style: 'font-size: 24px; width: 24px; height: 24px', title: 'Delete' do + = fa_icon 'trash' + +- if !@report.action_taken? + %hr/ + + %div{ style: 'overflow: hidden' } + %div{ style: 'float: right' } + = link_to 'Silence account', silence_admin_report_path(@report), method: :post, class: 'button' + = link_to 'Suspend account', suspend_admin_report_path(@report), method: :post, class: 'button' + %div{ style: 'float: left' } + = link_to 'Mark as resolved', resolve_admin_report_path(@report), method: :post, class: 'button' +- elsif !@report.action_taken_by_account.nil? + %hr/ + + %p + %strong Action taken by: + = @report.action_taken_by_account.acct diff --git a/app/views/admin/settings/index.html.haml b/app/views/admin/settings/index.html.haml index 5b482213b..02faac8c2 100644 --- a/app/views/admin/settings/index.html.haml +++ b/app/views/admin/settings/index.html.haml @@ -17,6 +17,10 @@ %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 title + %td= best_in_place @settings['site_title'], :value, url: admin_setting_path(@settings['site_title']) + %tr + %td %strong Site description %br/ Displayed as a paragraph on the frontpage and used as a meta tag. @@ -33,4 +37,16 @@ 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 + %td= best_in_place @settings['site_extended_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_extended_description']) + %tr + %td + %strong Open registration + %td= best_in_place @settings['open_registrations'], :value, as: :checkbox, collection: { false: 'Disabled', true: 'Enabled'}, url: admin_setting_path(@settings['open_registrations']) + %tr + %td + %strong Closed registration message + %br/ + Displayed on frontpage when registrations are closed + %br/ + You can use HTML tags + %td= best_in_place @settings['closed_registrations_message'], :value, as: :textarea, url: admin_setting_path(@settings['closed_registrations_message']) diff --git a/app/views/api/v1/accounts/relationship.rabl b/app/views/api/v1/accounts/relationship.rabl index 22b37586e..d6f1dd48a 100644 --- a/app/views/api/v1/accounts/relationship.rabl +++ b/app/views/api/v1/accounts/relationship.rabl @@ -4,4 +4,5 @@ attribute :id node(:following) { |account| @following[account.id] || false } node(:followed_by) { |account| @followed_by[account.id] || false } node(:blocking) { |account| @blocking[account.id] || false } +node(:muting) { |account| @muting[account.id] || false } node(:requested) { |account| @requested[account.id] || false } diff --git a/app/views/api/v1/accounts/show.rabl b/app/views/api/v1/accounts/show.rabl index 151a5080d..32df0457a 100644 --- a/app/views/api/v1/accounts/show.rabl +++ b/app/views/api/v1/accounts/show.rabl @@ -1,11 +1,11 @@ object @account -attributes :id, :username, :acct, :display_name, :locked +attributes :id, :username, :acct, :display_name, :locked, :created_at node(:note) { |account| Formatter.instance.simplified_format(account) } node(:url) { |account| TagManager.instance.url_for(account) } node(:avatar) { |account| full_asset_url(account.avatar.url(:original)) } node(:header) { |account| full_asset_url(account.header.url(:original)) } -node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : (account.try(:followers_count) || account.followers.count) } -node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : (account.try(:following_count) || account.following.count) } -node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : (account.try(:statuses_count) || account.statuses.count) } +node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : account.followers_count } +node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : account.following_count } +node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : account.statuses_count } diff --git a/app/views/api/v1/instances/show.rabl b/app/views/api/v1/instances/show.rabl new file mode 100644 index 000000000..88eb08a9e --- /dev/null +++ b/app/views/api/v1/instances/show.rabl @@ -0,0 +1,6 @@ +object false + +node(:uri) { Rails.configuration.x.local_domain } +node(:title) { Setting.site_title } +node(:description) { Setting.site_description } +node(:email) { Setting.site_contact_email } diff --git a/app/views/api/v1/media/create.rabl b/app/views/api/v1/media/create.rabl index 0b42e6e3d..916217cbd 100644 --- a/app/views/api/v1/media/create.rabl +++ b/app/views/api/v1/media/create.rabl @@ -1,5 +1,5 @@ object @media attribute :id, :type -node(:url) { |media| full_asset_url(media.file.url( :original)) } -node(:preview_url) { |media| full_asset_url(media.file.url( :small)) } +node(:url) { |media| full_asset_url(media.file.url(:original)) } +node(:preview_url) { |media| full_asset_url(media.file.url(:small)) } node(:text_url) { |media| medium_url(media) } diff --git a/app/views/api/v1/mutes/index.rabl b/app/views/api/v1/mutes/index.rabl new file mode 100644 index 000000000..9f3b13a53 --- /dev/null +++ b/app/views/api/v1/mutes/index.rabl @@ -0,0 +1,2 @@ +collection @accounts +extends 'api/v1/accounts/show' diff --git a/app/views/api/v1/notifications/show.rabl b/app/views/api/v1/notifications/show.rabl index fe2218ed7..ca34f2d5d 100644 --- a/app/views/api/v1/notifications/show.rabl +++ b/app/views/api/v1/notifications/show.rabl @@ -1,6 +1,6 @@ object @notification -attributes :id, :type +attributes :id, :type, :created_at child from_account: :account do extends 'api/v1/accounts/show' diff --git a/app/views/api/v1/reports/index.rabl b/app/views/api/v1/reports/index.rabl new file mode 100644 index 000000000..4f0794027 --- /dev/null +++ b/app/views/api/v1/reports/index.rabl @@ -0,0 +1,2 @@ +collection @reports +extends 'api/v1/reports/show' diff --git a/app/views/api/v1/reports/show.rabl b/app/views/api/v1/reports/show.rabl new file mode 100644 index 000000000..006db51e3 --- /dev/null +++ b/app/views/api/v1/reports/show.rabl @@ -0,0 +1,2 @@ +object @report +attributes :id, :action_taken diff --git a/app/views/api/v1/search/index.rabl b/app/views/api/v1/search/index.rabl new file mode 100644 index 000000000..8d1640f2d --- /dev/null +++ b/app/views/api/v1/search/index.rabl @@ -0,0 +1,13 @@ +object @search + +child :accounts, object_root: false do + extends 'api/v1/accounts/show' +end + +node(:hashtags) do |search| + search.hashtags.map(&:name) +end + +child :statuses, object_root: false do + extends 'api/v1/statuses/show' +end diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl index 059e0d13f..54e8a86d8 100644 --- a/app/views/api/v1/statuses/_show.rabl +++ b/app/views/api/v1/statuses/_show.rabl @@ -3,8 +3,8 @@ attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitiv node(:uri) { |status| TagManager.instance.uri_for(status) } node(:content) { |status| Formatter.instance.format(status) } 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 } +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' diff --git a/app/views/doorkeeper/authorized_applications/index.html.haml b/app/views/doorkeeper/authorized_applications/index.html.haml new file mode 100644 index 000000000..d4719881c --- /dev/null +++ b/app/views/doorkeeper/authorized_applications/index.html.haml @@ -0,0 +1,23 @@ +- content_for :page_title do + = t('doorkeeper.authorized_applications.index.title') + +%table.table + %thead + %tr + %th= t('doorkeeper.authorized_applications.index.application') + %th= t('doorkeeper.authorized_applications.index.scopes') + %th= t('doorkeeper.authorized_applications.index.created_at') + %th + %tbody + - @applications.each do |application| + %tr + %td + - if application.website.blank? + = application.name + - else + = link_to application.name, application.website + %th= application.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.join('<br />').html_safe + %td= l application.created_at + %td + - unless application.superapp? + = table_link_to 'times', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') } diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml index 750d6036f..59fe078df 100644 --- a/app/views/layouts/admin.html.haml +++ b/app/views/layouts/admin.html.haml @@ -12,6 +12,15 @@ .content-wrapper .content %h2= yield :page_title + + - if flash[:notice] + .flash-message.notice + %strong= flash[:notice] + + - if flash[:alert] + .flash-message.alert + %strong= flash[:alert] + = yield = render template: "layouts/application", locals: { body_classes: 'admin' } diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index e122e1c55..7eae6982b 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -13,7 +13,7 @@ %title = "#{yield(:page_title)} - " if content_for?(:page_title) - Mastodon + = Setting.site_title = stylesheet_link_tag 'application', media: 'all' = csrf_meta_tags diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb index ae52173b5..21bf444c3 100644 --- a/app/views/layouts/mailer.text.erb +++ b/app/views/layouts/mailer.text.erb @@ -1,5 +1,5 @@ <%= yield %> - --- <%= t('application_mailer.signature', instance: Rails.configuration.x.local_domain) %> +<%= t('application_mailer.settings', link: settings_preferences_url) %> diff --git a/app/views/notification_mailer/_status.text.erb b/app/views/notification_mailer/_status.text.erb index b089a7b73..85a0136b7 100644 --- a/app/views/notification_mailer/_status.text.erb +++ b/app/views/notification_mailer/_status.text.erb @@ -1,3 +1,3 @@ -<%= strip_tags(@status.content) %> +<%= raw Formatter.instance.plaintext(status) %> -<%= web_url("statuses/#{@status.id}") %> +<%= raw t('application_mailer.view')%> <%= web_url("statuses/#{status.id}") %> diff --git a/app/views/notification_mailer/digest.text.erb b/app/views/notification_mailer/digest.text.erb new file mode 100644 index 000000000..95aed6793 --- /dev/null +++ b/app/views/notification_mailer/digest.text.erb @@ -0,0 +1,15 @@ +<%= display_name(@me) %>, + +<%= raw t('notification_mailer.digest.body', since: @since, instance: root_url) %> +<% @notifications.each do |notification| %> + +* <%= raw t('notification_mailer.digest.mention', name: notification.from_account.acct) %> + + <%= raw Formatter.instance.plaintext(notification.target_status) %> + + <%= raw t('application_mailer.view')%> <%= web_url("statuses/#{notification.target_status.id}") %> +<% end %> +<% if @follows_since > 0 %> + +<%= raw t('notification_mailer.digest.new_followers_summary', count: @follows_since) %> +<% end %> diff --git a/app/views/notification_mailer/favourite.text.erb b/app/views/notification_mailer/favourite.text.erb index b2e1e3e9e..99852592f 100644 --- a/app/views/notification_mailer/favourite.text.erb +++ b/app/views/notification_mailer/favourite.text.erb @@ -1,5 +1,5 @@ <%= display_name(@me) %>, -<%= t('notification_mailer.favourite.body', name: @account.acct) %> +<%= raw t('notification_mailer.favourite.body', name: @account.acct) %> -<%= render partial: 'status' %> +<%= render partial: 'status', locals: { status: @status } %> diff --git a/app/views/notification_mailer/follow.text.erb b/app/views/notification_mailer/follow.text.erb index 4b2ec142c..af41a3080 100644 --- a/app/views/notification_mailer/follow.text.erb +++ b/app/views/notification_mailer/follow.text.erb @@ -1,5 +1,5 @@ <%= display_name(@me) %>, -<%= t('notification_mailer.follow.body', name: @account.acct) %> +<%= raw t('notification_mailer.follow.body', name: @account.acct) %> -<%= web_url("accounts/#{@account.id}") %> +<%= raw t('application_mailer.view')%> <%= web_url("accounts/#{@account.id}") %> diff --git a/app/views/notification_mailer/follow_request.text.erb b/app/views/notification_mailer/follow_request.text.erb index c0d38ec67..49087a575 100644 --- a/app/views/notification_mailer/follow_request.text.erb +++ b/app/views/notification_mailer/follow_request.text.erb @@ -1,5 +1,5 @@ <%= display_name(@me) %>, -<%= t('notification_mailer.follow_request.body', name: @account.acct) %> +<%= raw t('notification_mailer.follow_request.body', name: @account.acct) %> -<%= web_url("follow_requests") %> +<%= raw t('application_mailer.view')%> <%= web_url("follow_requests") %> diff --git a/app/views/notification_mailer/mention.text.erb b/app/views/notification_mailer/mention.text.erb index 31a294bb9..c0d4be1d8 100644 --- a/app/views/notification_mailer/mention.text.erb +++ b/app/views/notification_mailer/mention.text.erb @@ -1,5 +1,5 @@ <%= display_name(@me) %>, -<%= t('notification_mailer.mention.body', name: @status.account.acct) %> +<%= raw t('notification_mailer.mention.body', name: @status.account.acct) %> -<%= render partial: 'status' %> +<%= render partial: 'status', locals: { status: @status } %> diff --git a/app/views/notification_mailer/reblog.text.erb b/app/views/notification_mailer/reblog.text.erb index 7af8052ca..c32b48650 100644 --- a/app/views/notification_mailer/reblog.text.erb +++ b/app/views/notification_mailer/reblog.text.erb @@ -1,5 +1,5 @@ <%= display_name(@me) %>, -<%= t('notification_mailer.reblog.body', name: @account.acct) %> +<%= raw t('notification_mailer.reblog.body', name: @account.acct) %> -<%= render partial: 'status' %> +<%= render partial: 'status', locals: { status: @status } %> diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml new file mode 100644 index 000000000..0a0ff8633 --- /dev/null +++ b/app/views/settings/exports/show.html.haml @@ -0,0 +1,17 @@ +- content_for :page_title do + = t('settings.export') + +%table.table + %tbody + %tr + %th= t('exports.storage') + %td= number_to_human_size @total_storage + %td + %tr + %th= t('exports.follows') + %td= @total_follows + %td= table_link_to 'download', t('exports.csv'), follows_settings_export_path(format: :csv) + %tr + %th= t('exports.blocks') + %td= @total_blocks + %td= table_link_to 'download', t('exports.csv'), blocks_settings_export_path(format: :csv) diff --git a/app/views/settings/imports/show.html.haml b/app/views/settings/imports/show.html.haml new file mode 100644 index 000000000..8502913dc --- /dev/null +++ b/app/views/settings/imports/show.html.haml @@ -0,0 +1,11 @@ +- content_for :page_title do + = t('settings.import') + +%p.hint= t('imports.preface') + += simple_form_for @import, url: settings_import_path do |f| + = f.input :type, collection: Import.types.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") } + = f.input :data, wrapper: :with_label, hint: t('simple_form.hints.imports.data') + + .actions + = f.button :button, t('imports.upload'), type: :submit diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index aee0540d2..64cf32c3a 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -7,7 +7,7 @@ .fields-group = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) } - = f.input :setting_default_privacy, collection: Status.visibilities.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| I18n.t("statuses.visibilities.#{visibility}") }, required: false + = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| I18n.t("statuses.visibilities.#{visibility}") }, required: false .fields-group = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| @@ -16,6 +16,7 @@ = ff.input :reblog, as: :boolean, wrapper: :with_label = ff.input :favourite, as: :boolean, wrapper: :with_label = ff.input :mention, as: :boolean, wrapper: :with_label + = ff.input :digest, as: :boolean, wrapper: :with_label = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff| = ff.input :must_be_follower, as: :boolean, wrapper: :with_label diff --git a/app/views/settings/two_factor_auths/show.html.haml b/app/views/settings/two_factor_auths/show.html.haml index bad359f8f..87bfadc69 100644 --- a/app/views/settings/two_factor_auths/show.html.haml +++ b/app/views/settings/two_factor_auths/show.html.haml @@ -3,11 +3,15 @@ .simple_form - if current_user.otp_required_for_login - %p= t('two_factor_auth.instructions_html') + %p.hint= t('two_factor_auth.instructions_html') .qr-code= raw @qrcode.as_svg(padding: 0, module_size: 5) + %p.hint= t('two_factor_auth.plaintext_secret_html', secret: current_user.otp_secret) + + %p.hint= t('two_factor_auth.warning') + = link_to t('two_factor_auth.disable'), disable_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button' - else - %p= t('two_factor_auth.description_html') + %p.hint= t('two_factor_auth.description_html') = link_to t('two_factor_auth.enable'), enable_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button' diff --git a/app/views/shared/_landing_strip.html.haml b/app/views/shared/_landing_strip.html.haml new file mode 100644 index 000000000..bb081e544 --- /dev/null +++ b/app/views/shared/_landing_strip.html.haml @@ -0,0 +1,2 @@ +.landing-strip + = t('landing_strip_html', name: display_name(account), domain: Rails.configuration.x.local_domain, sign_up_path: new_user_registration_path) diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 235dc6086..8495f28b9 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -9,8 +9,10 @@ .status__content.e-content.p-name.emojify< - unless status.spoiler_text.blank? - %p= status.spoiler_text - = Formatter.instance.format(status) + %p{ style: 'margin-bottom: 0' }< + %span>= "#{status.spoiler_text} " + %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') + %div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) - unless status.media_attachments.empty? - if status.media_attachments.first.video? @@ -22,9 +24,9 @@ .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', class: "u-#{media.video? ? 'video' : 'photo'}" + .status__attachments__inner + - status.media_attachments.each do |media| + = render partial: 'stream_entries/media', locals: { media: media } %div.detailed-status__meta %data.dt-published{ value: status.created_at.to_time.iso8601 } @@ -39,11 +41,11 @@ · %span< = fa_icon('retweet') - %span= status.reblogs.count + %span= status.reblogs_count · %span< = fa_icon('star') - %span= status.favourites.count + %span= status.favourites_count - if user_signed_in? · diff --git a/app/views/stream_entries/_favourite.html.haml b/app/views/stream_entries/_favourite.html.haml deleted file mode 100644 index ea4879328..000000000 --- a/app/views/stream_entries/_favourite.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -.entry.entry-favourite - .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 deleted file mode 100644 index da6d062f0..000000000 --- a/app/views/stream_entries/_follow.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -.entry.entry-follow - .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/_media.html.haml b/app/views/stream_entries/_media.html.haml new file mode 100644 index 000000000..cd7faa700 --- /dev/null +++ b/app/views/stream_entries/_media.html.haml @@ -0,0 +1,4 @@ +.media-item + = link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : "", target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do + - unless media.image? + %video{ src: media.file.url(:original), autoplay: true, loop: true }/ diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index 95f90abd9..2eb9bf166 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -14,19 +14,22 @@ .status__content.e-content.p-name.emojify< - unless status.spoiler_text.blank? - %p= status.spoiler_text - = Formatter.instance.format(status) + %p{ style: 'margin-bottom: 0' }< + %span>= "#{status.spoiler_text} " + %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') + %div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) - unless status.media_attachments.empty? .status__attachments - if status.sensitive? = 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', class: 'u-video' do - .video-item__play - = fa_icon('play') + .status__attachments__inner + .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', 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', class: "u-#{media.video? ? 'video' : 'photo'}" + .status__attachments__inner + - status.media_attachments.each do |media| + = render partial: 'stream_entries/media', locals: { media: media } diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml index f70e2c890..cdd0dde3b 100644 --- a/app/views/stream_entries/_status.html.haml +++ b/app/views/stream_entries/_status.html.haml @@ -4,7 +4,7 @@ - centered ||= include_threads && !is_predecessor && !is_successor - if status.reply? && include_threads - = render partial: 'status', collection: @ancestors, as: :status, locals: { is_predecessor: true } + = render partial: 'stream_entries/status', collection: @ancestors, as: :status, locals: { is_predecessor: true } .entry{ class: entry_classes(status, is_predecessor, is_successor, include_threads) } - if status.reblog? @@ -19,4 +19,4 @@ = render partial: centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status', locals: { status: proper_status(status) } - if include_threads - = render partial: 'status', collection: @descendants, as: :status, locals: { is_successor: true } + = render partial: 'stream_entries/status', collection: @descendants, as: :status, locals: { is_successor: true } diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml index 6bad45705..c109ff4b8 100644 --- a/app/views/stream_entries/show.html.haml +++ b/app/views/stream_entries/show.html.haml @@ -20,5 +20,8 @@ %meta{ property: 'twitter:card', content: 'summary' }/ +- if !user_signed_in? && !Rails.configuration.x.single_user_mode + = render partial: 'shared/landing_strip', locals: { account: @stream_entry.account } + .activity-stream.activity-stream-headless - = render partial: @type, locals: { @type.to_sym => @stream_entry.activity, include_threads: true } + = render partial: "stream_entries/#{@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 412ec4fa5..32a50e158 100644 --- a/app/views/tags/show.html.haml +++ b/app/views/tags/show.html.haml @@ -1,10 +1,18 @@ +- content_for :page_title do + = "##{@tag.name}" + +.compact-header + %h1< + = link_to 'Mastodon', root_path + %small= "##{@tag.name}" + - if @statuses.empty? .accounts-grid = render partial: 'accounts/nothing_here' - else .activity-stream.h-feed - = render partial: 'stream_entries/status', collection: @statuses, as: :status, cached: true + = render partial: 'stream_entries/status', collection: @statuses, as: :status -.pagination - - if @statuses.size == 20 +- if @statuses.size == 20 + .pagination = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), tag_url(@tag, max_id: @statuses.last.id), class: 'next_page', rel: 'next' diff --git a/app/workers/after_remote_follow_request_worker.rb b/app/workers/after_remote_follow_request_worker.rb new file mode 100644 index 000000000..1f2db3061 --- /dev/null +++ b/app/workers/after_remote_follow_request_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AfterRemoteFollowRequestWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: 5 + + def perform(follow_request_id) + follow_request = FollowRequest.find(follow_request_id) + updated_account = FetchRemoteAccountService.new.call(follow_request.target_account.remote_url) + + return if updated_account.nil? || updated_account.locked? + + follow_request.destroy + FollowService.new.call(follow_request.account, updated_account.acct) + end +end diff --git a/app/workers/after_remote_follow_worker.rb b/app/workers/after_remote_follow_worker.rb new file mode 100644 index 000000000..bdd2c2a91 --- /dev/null +++ b/app/workers/after_remote_follow_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AfterRemoteFollowWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: 5 + + def perform(follow_id) + follow = Follow.find(follow_id) + updated_account = FetchRemoteAccountService.new.call(follow.target_account.remote_url) + + return if updated_account.nil? || !updated_account.locked? + + follow.destroy + FollowService.new.call(follow.account, updated_account.acct) + end +end diff --git a/app/workers/digest_mailer_worker.rb b/app/workers/digest_mailer_worker.rb new file mode 100644 index 000000000..dedb21e4e --- /dev/null +++ b/app/workers/digest_mailer_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class DigestMailerWorker + include Sidekiq::Worker + + sidekiq_options queue: 'mailers' + + def perform(user_id) + user = User.find(user_id) + return unless user.settings.notification_emails['digest'] + NotificationMailer.digest(user.account).deliver_now! + user.touch(:last_emailed_at) + end +end diff --git a/app/workers/domain_block_worker.rb b/app/workers/domain_block_worker.rb new file mode 100644 index 000000000..884477829 --- /dev/null +++ b/app/workers/domain_block_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class DomainBlockWorker + include Sidekiq::Worker + + def perform(domain_block_id) + BlockDomainService.new.call(DomainBlock.find(domain_block_id)) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb new file mode 100644 index 000000000..a58dfaa74 --- /dev/null +++ b/app/workers/feed_insert_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class FeedInsertWorker + include Sidekiq::Worker + + def perform(status_id, follower_id) + status = Status.find(status_id) + follower = Account.find(follower_id) + + return if FeedManager.instance.filter?(:home, status, follower.id) + FeedManager.instance.push(:home, follower, status) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb new file mode 100644 index 000000000..7cf29fb53 --- /dev/null +++ b/app/workers/import_worker.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'csv' + +class ImportWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: false + + def perform(import_id) + import = Import.find(import_id) + + case import.type + when 'blocking' + process_blocks(import) + when 'following' + process_follows(import) + end + + import.destroy + end + + private + + def process_blocks(import) + from_account = import.account + + CSV.foreach(import.data.path) do |row| + next if row.size != 1 + + begin + target_account = FollowRemoteAccountService.new.call(row[0]) + next if target_account.nil? + BlockService.new.call(from_account, target_account) + rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError + next + end + end + end + + def process_follows(import) + from_account = import.account + + CSV.foreach(import.data.path) do |row| + next if row.size != 1 + + begin + FollowService.new.call(from_account, row[0]) + rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError + next + end + end + end +end diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb index af3394b8b..834b0088b 100644 --- a/app/workers/link_crawl_worker.rb +++ b/app/workers/link_crawl_worker.rb @@ -3,7 +3,7 @@ class LinkCrawlWorker include Sidekiq::Worker - sidekiq_options retry: false + sidekiq_options queue: 'pull', retry: false def perform(status_id) FetchLinkCardService.new.call(Status.find(status_id)) diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index 0f288f43f..d745cb99c 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -3,6 +3,8 @@ class MergeWorker include Sidekiq::Worker + sidekiq_options queue: 'pull' + def perform(from_account_id, into_account_id) FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id)) end diff --git a/app/workers/notification_worker.rb b/app/workers/notification_worker.rb index e4c38d384..da1d6ab45 100644 --- a/app/workers/notification_worker.rb +++ b/app/workers/notification_worker.rb @@ -3,9 +3,9 @@ class NotificationWorker include Sidekiq::Worker - sidekiq_options retry: 5 + sidekiq_options queue: 'push', retry: 5 - def perform(stream_entry_id, target_account_id) - SendInteractionService.new.call(StreamEntry.find(stream_entry_id), Account.find(target_account_id)) + def perform(xml, source_account_id, target_account_id) + SendInteractionService.new.call(xml, Account.find(source_account_id), Account.find(target_account_id)) end end diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb index 15005bc80..466def3a8 100644 --- a/app/workers/pubsubhubbub/delivery_worker.rb +++ b/app/workers/pubsubhubbub/delivery_worker.rb @@ -22,6 +22,7 @@ class Pubsubhubbub::DeliveryWorker .headers(headers) .post(subscription.callback_url, body: payload) + return subscription.destroy! if response.code > 299 && response.code < 500 && response.code != 429 # HTTP 4xx means error is not temporary, except for 429 (throttling) raise "Delivery failed for #{subscription.callback_url}: HTTP #{response.code}" unless response.code > 199 && response.code < 300 subscription.touch(:last_successful_delivery_at) diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb index d5437bf6b..82ff257af 100644 --- a/app/workers/pubsubhubbub/distribution_worker.rb +++ b/app/workers/pubsubhubbub/distribution_worker.rb @@ -13,8 +13,11 @@ class Pubsubhubbub::DistributionWorker account = stream_entry.account renderer = AccountsController.renderer.new(method: 'get', http_host: Rails.configuration.x.local_domain, https: Rails.configuration.x.use_https) payload = renderer.render(:show, assigns: { account: account, entries: [stream_entry] }, formats: [:atom]) + # domains = account.followers_domains - Subscription.where(account: account).active.select('id').find_each do |subscription| + Subscription.where(account: account).active.select('id, callback_url').find_each do |subscription| + host = Addressable::URI.parse(subscription.callback_url).host + next if DomainBlock.blocked?(host) # || !domains.include?(host) Pubsubhubbub::DeliveryWorker.perform_async(subscription.id, payload) end rescue ActiveRecord::RecordNotFound diff --git a/app/workers/push_notification_worker.rb b/app/workers/push_notification_worker.rb deleted file mode 100644 index a61d0e349..000000000 --- a/app/workers/push_notification_worker.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class PushNotificationWorker - include Sidekiq::Worker - - def perform(notification_id) - SendPushNotificationService.new.call(Notification.find(notification_id)) - rescue ActiveRecord::RecordNotFound - true - end -end diff --git a/app/workers/regeneration_worker.rb b/app/workers/regeneration_worker.rb index 3aece0ba2..da8b845f6 100644 --- a/app/workers/regeneration_worker.rb +++ b/app/workers/regeneration_worker.rb @@ -3,7 +3,9 @@ class RegenerationWorker include Sidekiq::Worker - def perform(account_id, timeline_type) - PrecomputeFeedService.new.call(timeline_type, Account.find(account_id)) + sidekiq_options queue: 'pull', backtrace: true, unique: :until_executed + + def perform(account_id, _ = :home) + PrecomputeFeedService.new.call(:home, Account.find(account_id)) end end diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb index 593edd032..38287e8e6 100644 --- a/app/workers/thread_resolve_worker.rb +++ b/app/workers/thread_resolve_worker.rb @@ -3,7 +3,7 @@ class ThreadResolveWorker include Sidekiq::Worker - sidekiq_options retry: false + sidekiq_options queue: 'pull', retry: false def perform(child_status_id, parent_url) child_status = Status.find(child_status_id) diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb index dbf7243de..ea6aacebf 100644 --- a/app/workers/unmerge_worker.rb +++ b/app/workers/unmerge_worker.rb @@ -3,6 +3,8 @@ class UnmergeWorker include Sidekiq::Worker + sidekiq_options queue: 'pull' + def perform(from_account_id, into_account_id) FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id)) end |