From 2939e9898b1e0e7da6db802a00e594be4c85499d Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 13 Jan 2017 02:42:22 +0100 Subject: Extend rails-settings-cached to merge db-saved hash values with defaults --- app/controllers/api/v1/accounts_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/controllers/api/v1') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 05ff806c5..bd52dfb2c 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -96,7 +96,7 @@ class Api::V1::AccountsController < ApiController limit = params[:limit] ? [DEFAULT_ACCOUNTS_LIMIT, params[:limit].to_i].min : DEFAULT_ACCOUNTS_LIMIT @accounts = SearchService.new.call(params[:q], limit, params[:resolve] == 'true') - set_account_counters_maps(@accounts) + set_account_counters_maps(@accounts) unless @accounts.nil? render action: :index end -- cgit From d6bc0e8db4d25a4533feb56164b0d9cd3ef2af6e Mon Sep 17 00:00:00 2001 From: Effy Elden Date: Sun, 15 Jan 2017 08:58:50 +1100 Subject: Add tracking of OAuth app that posted a status, extend OAuth apps to have optional website field, add application details to API, show application name and website on detailed status views. Resolves #11 --- .../components/features/status/components/detailed_status.jsx | 2 +- app/assets/stylesheets/components.scss | 2 +- app/controllers/api/v1/apps_controller.rb | 2 +- app/controllers/api/v1/statuses_controller.rb | 2 +- app/models/concerns/application.rb | 8 ++++++++ app/models/status.rb | 2 ++ app/services/post_status_service.rb | 2 +- app/views/api/v1/apps/show.rabl | 3 +++ app/views/api/v1/statuses/_show.rabl | 4 ++++ app/views/stream_entries/_detailed_status.html.haml | 3 +++ db/migrate/20170114194937_add_application_to_statuses.rb | 5 +++++ db/migrate/20170114203041_add_website_to_oauth_application.rb | 5 +++++ db/schema.rb | 4 +++- 13 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 app/models/concerns/application.rb create mode 100644 app/views/api/v1/apps/show.rabl create mode 100644 db/migrate/20170114194937_add_application_to_statuses.rb create mode 100644 db/migrate/20170114203041_add_website_to_oauth_application.rb (limited to 'app/controllers/api/v1') diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx index b967d966f..7cbca4633 100644 --- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx +++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx @@ -54,7 +54,7 @@ const DetailedStatus = React.createClass({ {media}
- · · + · {status.getIn(['application', 'name'])} · ·
); diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index f1edfce9d..9e9e564f9 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -183,7 +183,7 @@ } } -.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .account__display-name { +.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .detailed-status__application, .account__display-name { text-decoration: none; } diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index 1b33770f4..ca9dd0b7e 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -4,6 +4,6 @@ class Api::V1::AppsController < ApiController respond_to :json def create - @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes)) + @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website]) end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index f7b4ed610..f033ef6c1 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -52,7 +52,7 @@ class Api::V1::StatusesController < ApiController end def create - @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], sensitive: params[:sensitive], visibility: params[:visibility]) + @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], sensitive: params[:sensitive], visibility: params[:visibility], application: doorkeeper_token.application) render action: :show end diff --git a/app/models/concerns/application.rb b/app/models/concerns/application.rb new file mode 100644 index 000000000..613be34ee --- /dev/null +++ b/app/models/concerns/application.rb @@ -0,0 +1,8 @@ +module ApplicationExtension + extend ActiveSupport::Concern + included do + validates :website + end +end + +Doorkeeper::Application.send :include, ApplicationExtension \ No newline at end of file diff --git a/app/models/status.rb b/app/models/status.rb index bc595c93b..8301ae16e 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -7,6 +7,8 @@ class Status < ApplicationRecord enum visibility: [:public, :unlisted, :private], _suffix: :visibility + belongs_to :application, class_name: 'Doorkeeper::Application' + belongs_to :account, inverse_of: :statuses belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account' diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 55405c0db..86a84f512 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -10,7 +10,7 @@ class PostStatusService < BaseService # @option [Enumerable] :media_ids Optional array of media IDs to attach # @return [Status] def call(account, text, in_reply_to = nil, options = {}) - status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive], visibility: options[:visibility]) + status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive], visibility: options[:visibility], application: options[:application]) attach_media(status, options[:media_ids]) process_mentions_service.call(status) process_hashtags_service.call(status) diff --git a/app/views/api/v1/apps/show.rabl b/app/views/api/v1/apps/show.rabl new file mode 100644 index 000000000..30cfd81ab --- /dev/null +++ b/app/views/api/v1/apps/show.rabl @@ -0,0 +1,3 @@ +object @application + +attributes :id, :name, :website \ No newline at end of file diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl index a3391a67e..a3fc78763 100644 --- a/app/views/api/v1/statuses/_show.rabl +++ b/app/views/api/v1/statuses/_show.rabl @@ -6,6 +6,10 @@ node(:url) { |status| TagManager.instance.url_for(status) } node(:reblogs_count) { |status| defined?(@reblogs_counts_map) ? (@reblogs_counts_map[status.id] || 0) : status.reblogs.count } node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites.count } +child :application do + extends 'api/v1/apps/show' +end + child :account do extends 'api/v1/accounts/show' end diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 32f7c2e40..bc9940915 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -28,6 +28,9 @@ = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: @external_links ? '_blank' : nil, rel: 'noopener' do %span= l(status.created_at) · + = link_to status.application.website, class: 'detailed-status__application', target: @external_links ? '_blank' : nil, rel: 'noooper' do + %span= status.application.name + · %span = fa_icon('retweet') %span= status.reblogs.count diff --git a/db/migrate/20170114194937_add_application_to_statuses.rb b/db/migrate/20170114194937_add_application_to_statuses.rb new file mode 100644 index 000000000..b699db2ac --- /dev/null +++ b/db/migrate/20170114194937_add_application_to_statuses.rb @@ -0,0 +1,5 @@ +class AddApplicationToStatuses < ActiveRecord::Migration[5.0] + def change + add_column :statuses, :application_id, :int + end +end diff --git a/db/migrate/20170114203041_add_website_to_oauth_application.rb b/db/migrate/20170114203041_add_website_to_oauth_application.rb new file mode 100644 index 000000000..ee674be72 --- /dev/null +++ b/db/migrate/20170114203041_add_website_to_oauth_application.rb @@ -0,0 +1,5 @@ +class AddWebsiteToOauthApplication < ActiveRecord::Migration[5.0] + def change + add_column :oauth_applications, :website, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 1cd1258db..37da0c44e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170112154826) do +ActiveRecord::Schema.define(version: 20170114203041) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -153,6 +153,7 @@ ActiveRecord::Schema.define(version: 20170112154826) do t.datetime "created_at" t.datetime "updated_at" t.boolean "superapp", default: false, null: false + t.string "website" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree end @@ -259,6 +260,7 @@ ActiveRecord::Schema.define(version: 20170112154826) do t.boolean "sensitive", default: false t.integer "visibility", default: 0, null: false t.integer "in_reply_to_account_id" + t.integer "application_id" t.index ["account_id"], name: "index_statuses_on_account_id", using: :btree t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", using: :btree t.index ["reblog_of_id"], name: "index_statuses_on_reblog_of_id", using: :btree -- cgit From 7d53ee73f323b04757a78e83dcbe49c872d481eb Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 16 Jan 2017 13:27:58 +0100 Subject: Fix #238 - Add "favourites" column --- .../javascripts/components/actions/favourites.jsx | 83 ++++++++++++++++++++++ .../javascripts/components/actions/timelines.jsx | 5 ++ .../javascripts/components/containers/mastodon.jsx | 2 + .../components/features/account_timeline/index.jsx | 3 +- .../features/favourited_statuses/index.jsx | 63 ++++++++++++++++ .../components/features/getting_started/index.jsx | 4 +- .../components/middleware/loading_bar.jsx | 25 +++++++ .../javascripts/components/reducers/accounts.jsx | 6 ++ .../javascripts/components/reducers/index.jsx | 2 + .../javascripts/components/reducers/modal.jsx | 18 ++--- .../components/reducers/status_lists.jsx | 39 ++++++++++ .../javascripts/components/reducers/statuses.jsx | 68 ++++++++++-------- .../javascripts/components/reducers/user_lists.jsx | 38 +++++----- .../components/store/configureStore.jsx | 2 +- app/controllers/api/v1/favourites_controller.rb | 2 +- 15 files changed, 297 insertions(+), 63 deletions(-) create mode 100644 app/assets/javascripts/components/actions/favourites.jsx create mode 100644 app/assets/javascripts/components/features/favourited_statuses/index.jsx create mode 100644 app/assets/javascripts/components/middleware/loading_bar.jsx create mode 100644 app/assets/javascripts/components/reducers/status_lists.jsx (limited to 'app/controllers/api/v1') diff --git a/app/assets/javascripts/components/actions/favourites.jsx b/app/assets/javascripts/components/actions/favourites.jsx new file mode 100644 index 000000000..a25c1ae1c --- /dev/null +++ b/app/assets/javascripts/components/actions/favourites.jsx @@ -0,0 +1,83 @@ +import api, { getLinks } from '../api' + +export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; +export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; +export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL'; + +export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST'; +export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS'; +export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL'; + +export function fetchFavouritedStatuses() { + return (dispatch, getState) => { + dispatch(fetchFavouritedStatusesRequest()); + + api(getState).get('/api/v1/favourites').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchFavouritedStatusesFail(error)); + }); + }; +}; + +export function fetchFavouritedStatusesRequest() { + return { + type: FAVOURITED_STATUSES_FETCH_REQUEST + }; +}; + +export function fetchFavouritedStatusesSuccess(statuses, next) { + return { + type: FAVOURITED_STATUSES_FETCH_SUCCESS, + statuses, + next + }; +}; + +export function fetchFavouritedStatusesFail(error) { + return { + type: FAVOURITED_STATUSES_FETCH_FAIL, + error + }; +}; + +export function expandFavouritedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'favourites', 'next'], null); + + if (url === null) { + return; + } + + dispatch(expandFavouritedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandFavouritedStatusesFail(error)); + }); + }; +}; + +export function expandFavouritedStatusesRequest() { + return { + type: FAVOURITED_STATUSES_EXPAND_REQUEST + }; +}; + +export function expandFavouritedStatusesSuccess(statuses, next) { + return { + type: FAVOURITED_STATUSES_EXPAND_SUCCESS, + statuses, + next + }; +}; + +export function expandFavouritedStatusesFail(error) { + return { + type: FAVOURITED_STATUSES_EXPAND_FAIL, + error + }; +}; diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx index 8bb939d31..975a0e090 100644 --- a/app/assets/javascripts/components/actions/timelines.jsx +++ b/app/assets/javascripts/components/actions/timelines.jsx @@ -97,6 +97,11 @@ export function expandTimeline(timeline, id = null) { return (dispatch, getState) => { const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last(); + if (!lastId) { + // If timeline is empty, don't try to load older posts since there are none + return; + } + dispatch(expandTimelineRequest(timeline)); let path = timeline; diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index af495652f..5f4b2cf79 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -34,6 +34,7 @@ import HashtagTimeline from '../features/hashtag_timeline'; import Notifications from '../features/notifications'; import FollowRequests from '../features/follow_requests'; import GenericNotFound from '../features/generic_not_found'; +import FavouritedStatuses from '../features/favourited_statuses'; import { IntlProvider, addLocaleData } from 'react-intl'; import en from 'react-intl/locale-data/en'; import de from 'react-intl/locale-data/de'; @@ -113,6 +114,7 @@ const Mastodon = React.createClass({ + diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx index 7a3dbe160..4a66dbbf5 100644 --- a/app/assets/javascripts/components/features/account_timeline/index.jsx +++ b/app/assets/javascripts/components/features/account_timeline/index.jsx @@ -18,7 +18,8 @@ const AccountTimeline = React.createClass({ propTypes: { params: React.PropTypes.object.isRequired, dispatch: React.PropTypes.func.isRequired, - statusIds: ImmutablePropTypes.list + statusIds: ImmutablePropTypes.list, + me: React.PropTypes.number.isRequired }, mixins: [PureRenderMixin], diff --git a/app/assets/javascripts/components/features/favourited_statuses/index.jsx b/app/assets/javascripts/components/features/favourited_statuses/index.jsx new file mode 100644 index 000000000..a2d521736 --- /dev/null +++ b/app/assets/javascripts/components/features/favourited_statuses/index.jsx @@ -0,0 +1,63 @@ +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites'; +import Column from '../ui/components/column'; +import StatusList from '../../components/status_list'; +import ColumnBackButton from '../public_timeline/components/column_back_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + heading: { id: 'column.favourites', defaultMessage: 'Favourites' } +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'favourites', 'items']), + loaded: state.getIn(['status_lists', 'favourites', 'loaded']), + me: state.getIn(['meta', 'me']) +}); + +const Favourites = React.createClass({ + + propTypes: { + params: React.PropTypes.object.isRequired, + dispatch: React.PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + loaded: React.PropTypes.bool, + intl: React.PropTypes.object.isRequired, + me: React.PropTypes.number.isRequired + }, + + mixins: [PureRenderMixin], + + componentWillMount () { + this.props.dispatch(fetchFavouritedStatuses()); + }, + + handleScrollToBottom () { + this.props.dispatch(expandFavouritedStatuses()); + }, + + render () { + const { statusIds, loaded, intl, me } = this.props; + + if (!loaded) { + return ( + + + + ); + } + + return ( + + + + + ); + } + +}); + +export default connect(mapStateToProps)(injectIntl(Favourites)); diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index d7c2f8df7..42e0a9e24 100644 --- a/app/assets/javascripts/components/features/getting_started/index.jsx +++ b/app/assets/javascripts/components/features/getting_started/index.jsx @@ -10,7 +10,8 @@ const messages = defineMessages({ public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public 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' } + sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' } }); const mapStateToProps = state => ({ @@ -29,6 +30,7 @@ const GettingStarted = ({ intl, me }) => {
+ {followRequests}
diff --git a/app/assets/javascripts/components/middleware/loading_bar.jsx b/app/assets/javascripts/components/middleware/loading_bar.jsx new file mode 100644 index 000000000..a98f1bb2b --- /dev/null +++ b/app/assets/javascripts/components/middleware/loading_bar.jsx @@ -0,0 +1,25 @@ +import { showLoading, hideLoading } from 'react-redux-loading-bar'; + +const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED']; + +export default function loadingBarMiddleware(config = {}) { + const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes; + + return ({ dispatch }) => next => (action) => { + if (action.type && !action.skipLoading) { + const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes; + + const isPending = new RegExp(`${PENDING}$`, 'g'); + const isFulfilled = new RegExp(`${FULFILLED}$`, 'g'); + const isRejected = new RegExp(`${REJECTED}$`, 'g'); + + if (action.type.match(isPending)) { + dispatch(showLoading()); + } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) { + dispatch(hideLoading()); + } + } + + return next(action); + }; +}; diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index ae048df3b..73dee9078 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -32,6 +32,10 @@ import { NOTIFICATIONS_REFRESH_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS } from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; import { STORE_HYDRATE } from '../actions/store'; import Immutable from 'immutable'; @@ -90,6 +94,8 @@ export default function accounts(state = initialState, action) { case ACCOUNT_TIMELINE_FETCH_SUCCESS: case ACCOUNT_TIMELINE_EXPAND_SUCCESS: case CONTEXT_FETCH_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: return normalizeAccountsFromStatuses(state, action.statuses); case REBLOG_SUCCESS: case FAVOURITE_SUCCESS: diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx index 068491949..80c913d2d 100644 --- a/app/assets/javascripts/components/reducers/index.jsx +++ b/app/assets/javascripts/components/reducers/index.jsx @@ -12,6 +12,7 @@ import relationships from './relationships'; import search from './search'; import notifications from './notifications'; import settings from './settings'; +import status_lists from './status_lists'; export default combineReducers({ timelines, @@ -21,6 +22,7 @@ export default combineReducers({ loadingBar: loadingBarReducer, modal, user_lists, + status_lists, accounts, statuses, relationships, diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx index b529b6aa8..ac53ea210 100644 --- a/app/assets/javascripts/components/reducers/modal.jsx +++ b/app/assets/javascripts/components/reducers/modal.jsx @@ -8,14 +8,14 @@ const initialState = Immutable.Map({ export default function modal(state = initialState, action) { switch(action.type) { - case MEDIA_OPEN: - return state.withMutations(map => { - map.set('url', action.url); - map.set('open', true); - }); - case MODAL_CLOSE: - return state.set('open', false); - default: - return state; + case MEDIA_OPEN: + return state.withMutations(map => { + map.set('url', action.url); + map.set('open', true); + }); + case MODAL_CLOSE: + return state.set('open', false); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/status_lists.jsx b/app/assets/javascripts/components/reducers/status_lists.jsx new file mode 100644 index 000000000..b883b1c58 --- /dev/null +++ b/app/assets/javascripts/components/reducers/status_lists.jsx @@ -0,0 +1,39 @@ +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + favourites: Immutable.Map({ + next: null, + loaded: false, + items: Immutable.List() + }) +}); + +const normalizeList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('items', Immutable.List(statuses.map(item => item.id))); + })); +}; + +const appendToList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('items', map.get('items').push(...statuses.map(item => item.id))); + })); +}; + +export default function statusLists(state = initialState, action) { + switch(action.type) { + case FAVOURITED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'favourites', action.statuses, action.next); + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'favourites', action.statuses, action.next); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx index c740b6d64..084b6304c 100644 --- a/app/assets/javascripts/components/reducers/statuses.jsx +++ b/app/assets/javascripts/components/reducers/statuses.jsx @@ -28,6 +28,10 @@ import { NOTIFICATIONS_REFRESH_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS } from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; import Immutable from 'immutable'; const normalizeStatus = (state, status) => { @@ -77,36 +81,38 @@ const initialState = Immutable.Map(); export default function statuses(state = initialState, action) { switch(action.type) { - case TIMELINE_UPDATE: - case STATUS_FETCH_SUCCESS: - case NOTIFICATIONS_UPDATE: - return normalizeStatus(state, action.status); - case REBLOG_SUCCESS: - case UNREBLOG_SUCCESS: - case FAVOURITE_SUCCESS: - case UNFAVOURITE_SUCCESS: - return normalizeStatus(state, action.response); - case FAVOURITE_REQUEST: - return state.setIn([action.status.get('id'), 'favourited'], true); - case FAVOURITE_FAIL: - return state.setIn([action.status.get('id'), 'favourited'], false); - case REBLOG_REQUEST: - return state.setIn([action.status.get('id'), 'reblogged'], true); - case REBLOG_FAIL: - return state.setIn([action.status.get('id'), 'reblogged'], false); - case TIMELINE_REFRESH_SUCCESS: - case TIMELINE_EXPAND_SUCCESS: - case ACCOUNT_TIMELINE_FETCH_SUCCESS: - case ACCOUNT_TIMELINE_EXPAND_SUCCESS: - case CONTEXT_FETCH_SUCCESS: - case NOTIFICATIONS_REFRESH_SUCCESS: - case NOTIFICATIONS_EXPAND_SUCCESS: - return normalizeStatuses(state, action.statuses); - case TIMELINE_DELETE: - return deleteStatus(state, action.id, action.references); - case ACCOUNT_BLOCK_SUCCESS: - return filterStatuses(state, action.relationship); - default: - return state; + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeStatus(state, action.status); + case REBLOG_SUCCESS: + case UNREBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeStatus(state, action.response); + case FAVOURITE_REQUEST: + return state.setIn([action.status.get('id'), 'favourited'], true); + case FAVOURITE_FAIL: + return state.setIn([action.status.get('id'), 'favourited'], false); + case REBLOG_REQUEST: + return state.setIn([action.status.get('id'), 'reblogged'], true); + case REBLOG_FAIL: + return state.setIn([action.status.get('id'), 'reblogged'], false); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return normalizeStatuses(state, action.statuses); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); + case ACCOUNT_BLOCK_SUCCESS: + return filterStatuses(state, action.relationship); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx index 36093663f..72922f509 100644 --- a/app/assets/javascripts/components/reducers/user_lists.jsx +++ b/app/assets/javascripts/components/reducers/user_lists.jsx @@ -36,24 +36,24 @@ const appendToList = (state, type, id, accounts, next) => { export default function userLists(state = initialState, action) { switch(action.type) { - case FOLLOWERS_FETCH_SUCCESS: - return normalizeList(state, 'followers', action.id, action.accounts, action.next); - case FOLLOWERS_EXPAND_SUCCESS: - return appendToList(state, 'followers', action.id, action.accounts, action.next); - case FOLLOWING_FETCH_SUCCESS: - return normalizeList(state, 'following', action.id, action.accounts, action.next); - case FOLLOWING_EXPAND_SUCCESS: - return appendToList(state, 'following', action.id, action.accounts, action.next); - case REBLOGS_FETCH_SUCCESS: - return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id))); - case FAVOURITES_FETCH_SUCCESS: - return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id))); - case FOLLOW_REQUESTS_FETCH_SUCCESS: - return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); - case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: - case FOLLOW_REQUEST_REJECT_SUCCESS: - return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); - default: - return state; + case FOLLOWERS_FETCH_SUCCESS: + return normalizeList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWERS_EXPAND_SUCCESS: + return appendToList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWING_FETCH_SUCCESS: + return normalizeList(state, 'following', action.id, action.accounts, action.next); + case FOLLOWING_EXPAND_SUCCESS: + return appendToList(state, 'following', action.id, action.accounts, action.next); + case REBLOGS_FETCH_SUCCESS: + return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id))); + case FAVOURITES_FETCH_SUCCESS: + return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id))); + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: + case FOLLOW_REQUEST_REJECT_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); + default: + return state; } }; diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx index 2c1476e5d..87f469999 100644 --- a/app/assets/javascripts/components/store/configureStore.jsx +++ b/app/assets/javascripts/components/store/configureStore.jsx @@ -1,7 +1,7 @@ import { createStore, applyMiddleware, compose } from 'redux'; import thunk from 'redux-thunk'; import appReducer from '../reducers'; -import { loadingBarMiddleware } from 'react-redux-loading-bar'; +import loadingBarMiddleware from '../middleware/loading_bar'; import errorsMiddleware from '../middleware/errors'; import Immutable from 'immutable'; diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index a71592acd..ea799fd55 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -13,7 +13,7 @@ class Api::V1::FavouritesController < ApiController set_maps(@statuses) set_counters_maps(@statuses) - next_path = api_v1_favourites_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = api_v1_favourites_url(max_id: results.last.id) if results.size == DEFAULT_STATUSES_LIMIT prev_path = api_v1_favourites_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) -- cgit From 98560b232acc9d16d3515133721266f170e31ae6 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 19 Jan 2017 11:06:06 +0100 Subject: Don't show loading bar when re-loading already loaded status. Don't even try to fetch ancestors from DB when in_reply_to_id is nil --- .../javascripts/components/actions/statuses.jsx | 26 +++++++++++++--------- app/controllers/api/v1/statuses_controller.rb | 2 +- 2 files changed, 16 insertions(+), 12 deletions(-) (limited to 'app/controllers/api/v1') diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx index cbee94bca..21a56381e 100644 --- a/app/assets/javascripts/components/actions/statuses.jsx +++ b/app/assets/javascripts/components/actions/statuses.jsx @@ -14,39 +14,43 @@ export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; -export function fetchStatusRequest(id) { +export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, - id: id + id, + skipLoading }; }; export function fetchStatus(id) { return (dispatch, getState) => { - dispatch(fetchStatusRequest(id)); + const skipLoading = getState().getIn(['statuses', id], null) !== null; + + dispatch(fetchStatusRequest(id, skipLoading)); api(getState).get(`/api/v1/statuses/${id}`).then(response => { - dispatch(fetchStatusSuccess(response.data)); + dispatch(fetchStatusSuccess(response.data, skipLoading)); dispatch(fetchContext(id)); }).catch(error => { - dispatch(fetchStatusFail(id, error)); + dispatch(fetchStatusFail(id, error, skipLoading)); }); }; }; -export function fetchStatusSuccess(status, context) { +export function fetchStatusSuccess(status, skipLoading) { return { type: STATUS_FETCH_SUCCESS, - status: status, - context: context + status, + skipLoading }; }; -export function fetchStatusFail(id, error) { +export function fetchStatusFail(id, error, skipLoading) { return { type: STATUS_FETCH_FAIL, - id: id, - error: error + id, + error, + skipLoading }; }; diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index f033ef6c1..c661d81c1 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -14,7 +14,7 @@ class Api::V1::StatusesController < ApiController end def context - @context = OpenStruct.new(ancestors: @status.ancestors(current_account), descendants: @status.descendants(current_account)) + @context = OpenStruct.new(ancestors: @status.in_reply_to_id.nil? ? [] : @status.ancestors(current_account), descendants: @status.descendants(current_account)) statuses = [@status] + @context[:ancestors] + @context[:descendants] set_maps(statuses) -- cgit From f0de621e76b5a5ba3f7e67bd88c0183aac22b985 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 20 Jan 2017 01:00:14 +0100 Subject: Fix #463 - Fetch and display previews of URLs using OpenGraph tags --- Gemfile | 1 + Gemfile.lock | 2 + .../javascripts/components/actions/cards.jsx | 40 +++++++++ .../javascripts/components/actions/statuses.jsx | 2 + .../components/features/status/components/card.jsx | 96 ++++++++++++++++++++++ .../features/status/components/detailed_status.jsx | 3 + .../features/status/containers/card_container.jsx | 8 ++ .../javascripts/components/reducers/cards.jsx | 14 ++++ .../javascripts/components/reducers/index.jsx | 4 +- app/assets/stylesheets/components.scss | 6 ++ app/controllers/api/v1/statuses_controller.rb | 8 +- app/lib/statsd_monitor.rb | 11 --- app/models/preview_card.rb | 20 +++++ app/models/status.rb | 1 + app/services/fetch_link_card_service.rb | 33 ++++++++ app/services/post_status_service.rb | 1 + app/views/api/v1/statuses/card.rabl | 5 ++ app/workers/link_crawl_worker.rb | 13 +++ config/application.rb | 3 +- config/initializers/inflections.rb | 1 + config/routes.rb | 3 +- db/migrate/20170119214911_create_preview_cards.rb | 17 ++++ db/schema.rb | 16 +++- lib/statsd_monitor.rb | 11 +++ spec/fabricators/preview_card_fabricator.rb | 5 ++ spec/models/preview_card_spec.rb | 5 ++ spec/models/subscription_spec.rb | 2 +- 27 files changed, 313 insertions(+), 18 deletions(-) create mode 100644 app/assets/javascripts/components/actions/cards.jsx create mode 100644 app/assets/javascripts/components/features/status/components/card.jsx create mode 100644 app/assets/javascripts/components/features/status/containers/card_container.jsx create mode 100644 app/assets/javascripts/components/reducers/cards.jsx delete mode 100644 app/lib/statsd_monitor.rb create mode 100644 app/models/preview_card.rb create mode 100644 app/services/fetch_link_card_service.rb create mode 100644 app/views/api/v1/statuses/card.rabl create mode 100644 app/workers/link_crawl_worker.rb create mode 100644 db/migrate/20170119214911_create_preview_cards.rb create mode 100644 lib/statsd_monitor.rb create mode 100644 spec/fabricators/preview_card_fabricator.rb create mode 100644 spec/models/preview_card_spec.rb (limited to 'app/controllers/api/v1') diff --git a/Gemfile b/Gemfile index aa149c61e..bab7cebb5 100644 --- a/Gemfile +++ b/Gemfile @@ -48,6 +48,7 @@ gem 'rails-settings-cached' gem 'pg_search' gem 'simple-navigation' gem 'statsd-instrument' +gem 'ruby-oembed', require: 'oembed' gem 'react-rails' gem 'browserify-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 9b33580fc..20ea37fcc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -334,6 +334,7 @@ GEM rainbow (>= 1.99.1, < 3.0) ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) + ruby-oembed (0.10.1) ruby-progressbar (1.8.1) safe_yaml (1.0.4) sass (3.4.22) @@ -457,6 +458,7 @@ DEPENDENCIES rspec-rails rspec-sidekiq rubocop + ruby-oembed sass-rails (~> 5.0) sdoc (~> 0.4.0) sidekiq diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx new file mode 100644 index 000000000..808f1835b --- /dev/null +++ b/app/assets/javascripts/components/actions/cards.jsx @@ -0,0 +1,40 @@ +import api from '../api'; + +export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST'; +export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS'; +export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL'; + +export function fetchStatusCard(id) { + return (dispatch, getState) => { + dispatch(fetchStatusCardRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/card`).then(response => { + dispatch(fetchStatusCardSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchStatusCardFail(id, error)); + }); + }; +}; + +export function fetchStatusCardRequest(id) { + return { + type: STATUS_CARD_FETCH_REQUEST, + id + }; +}; + +export function fetchStatusCardSuccess(id, card) { + return { + type: STATUS_CARD_FETCH_SUCCESS, + id, + card + }; +}; + +export function fetchStatusCardFail(id, error) { + return { + type: STATUS_CARD_FETCH_FAIL, + id, + error + }; +}; diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx index 21a56381e..9ac215727 100644 --- a/app/assets/javascripts/components/actions/statuses.jsx +++ b/app/assets/javascripts/components/actions/statuses.jsx @@ -1,6 +1,7 @@ import api from '../api'; import { deleteFromTimelines } from './timelines'; +import { fetchStatusCard } from './cards'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; @@ -31,6 +32,7 @@ export function fetchStatus(id) { 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)); }); diff --git a/app/assets/javascripts/components/features/status/components/card.jsx b/app/assets/javascripts/components/features/status/components/card.jsx new file mode 100644 index 000000000..7161de364 --- /dev/null +++ b/app/assets/javascripts/components/features/status/components/card.jsx @@ -0,0 +1,96 @@ +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: '2', + padding: '8px', + paddingLeft: '14px' +}; + +const titleStyle = { + display: 'block', + fontWeight: '500', + marginBottom: '5px', + color: '#d9e1e8' +}; + +const descriptionStyle = { + color: '#d9e1e8' +}; + +const imageOuterStyle = { + flex: '1', + background: '#373b4a' +}; + +const imageStyle = { + display: 'block', + width: '100%', + height: 'auto', + margin: '0', + borderRadius: '4px 0 0 4px' +}; + +const hostStyle = { + display: 'block', + marginTop: '5px', + fontSize: '13px' +}; + +const getHostname = url => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +const Card = React.createClass({ + propTypes: { + card: ImmutablePropTypes.map + }, + + mixins: [PureRenderMixin], + + render () { + const { card } = this.props; + + if (card === null) { + return null; + } + + let image = ''; + + if (card.get('image')) { + image = ( +
+ {card.get('title')} +
+ ); + } + + return ( + + {image} + +
+ {card.get('title')} +

{card.get('description')}

+ {getHostname(card.get('url'))} +
+
+ ); + } +}); + +export default Card; diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx index 14a504c7c..f2d6ae48a 100644 --- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx +++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx @@ -7,6 +7,7 @@ import MediaGallery from '../../../components/media_gallery'; import VideoPlayer from '../../../components/video_player'; import { Link } from 'react-router'; import { FormattedDate, FormattedNumber } from 'react-intl'; +import CardContainer from '../containers/card_container'; const DetailedStatus = React.createClass({ @@ -42,6 +43,8 @@ const DetailedStatus = React.createClass({ } else { media = ; } + } else { + media = ; } if (status.get('application')) { diff --git a/app/assets/javascripts/components/features/status/containers/card_container.jsx b/app/assets/javascripts/components/features/status/containers/card_container.jsx new file mode 100644 index 000000000..5c8bfeec2 --- /dev/null +++ b/app/assets/javascripts/components/features/status/containers/card_container.jsx @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Card from '../components/card'; + +const mapStateToProps = (state, { statusId }) => ({ + card: state.getIn(['cards', statusId], null) +}); + +export default connect(mapStateToProps)(Card); diff --git a/app/assets/javascripts/components/reducers/cards.jsx b/app/assets/javascripts/components/reducers/cards.jsx new file mode 100644 index 000000000..3c9395011 --- /dev/null +++ b/app/assets/javascripts/components/reducers/cards.jsx @@ -0,0 +1,14 @@ +import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards'; + +import Immutable from 'immutable'; + +const initialState = Immutable.Map(); + +export default function cards(state = initialState, action) { + switch(action.type) { + case STATUS_CARD_FETCH_SUCCESS: + return state.set(action.id, Immutable.fromJS(action.card)); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx index 80c913d2d..0798116c4 100644 --- a/app/assets/javascripts/components/reducers/index.jsx +++ b/app/assets/javascripts/components/reducers/index.jsx @@ -13,6 +13,7 @@ import search from './search'; import notifications from './notifications'; import settings from './settings'; import status_lists from './status_lists'; +import cards from './cards'; export default combineReducers({ timelines, @@ -28,5 +29,6 @@ export default combineReducers({ relationships, search, notifications, - settings + settings, + cards }); diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index fedf73b1d..7e61323ab 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -680,3 +680,9 @@ button.active i.fa-retweet { transition-duration: 0.9s; background-position: 0 -209px; } + +.status-card { + &:hover { + background: #363c4b; + } +} diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index c661d81c1..37ed5e6dd 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -3,8 +3,8 @@ class Api::V1::StatusesController < ApiController before_action -> { doorkeeper_authorize! :read }, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite] before_action -> { doorkeeper_authorize! :write }, only: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite] - before_action :require_user!, except: [:show, :context, :reblogged_by, :favourited_by] - before_action :set_status, only: [:show, :context, :reblogged_by, :favourited_by] + before_action :require_user!, except: [:show, :context, :card, :reblogged_by, :favourited_by] + before_action :set_status, only: [:show, :context, :card, :reblogged_by, :favourited_by] respond_to :json @@ -21,6 +21,10 @@ class Api::V1::StatusesController < ApiController set_counters_maps(statuses) end + def card + @card = PreviewCard.find_by!(status: @status) + end + def reblogged_by results = @status.reblogs.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h diff --git a/app/lib/statsd_monitor.rb b/app/lib/statsd_monitor.rb deleted file mode 100644 index e48ce6541..000000000 --- a/app/lib/statsd_monitor.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class StatsDMonitor - def initialize(app) - @app = app - end - - def call(env) - @app.call(env) - end -end diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb new file mode 100644 index 000000000..e59b05eb8 --- /dev/null +++ b/app/models/preview_card.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class PreviewCard < ApplicationRecord + IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze + + belongs_to :status + + has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' } + + validates :url, presence: true + validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES + validates_attachment_size :image, less_than: 1.megabytes + + def save_with_optional_image! + save! + rescue ActiveRecord::RecordInvalid + self.image = nil + save! + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 5710f9cca..d5f52b55c 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -23,6 +23,7 @@ class Status < ApplicationRecord has_and_belongs_to_many :tags has_one :notification, as: :activity, dependent: :destroy + has_one :preview_card, dependent: :destroy validates :account, presence: true validates :uri, uniqueness: true, unless: 'local?' diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb new file mode 100644 index 000000000..2779b79b5 --- /dev/null +++ b/app/services/fetch_link_card_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class FetchLinkCardService < BaseService + def call(status) + # Get first URL + url = URI.extract(status.text).reject { |uri| (uri =~ /\Ahttps?:\/\//).nil? }.first + + return if url.nil? + + response = http_client.get(url) + + return if response.code != 200 + + page = Nokogiri::HTML(response.to_s) + card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url) + + card.title = meta_property(page, 'og:title') || page.at_xpath('//title')&.content + card.description = meta_property(page, 'og:description') || meta_property(page, 'description') + card.image = URI.parse(meta_property(page, 'og:image')) if meta_property(page, 'og:image') + + card.save_with_optional_image! + end + + private + + def http_client + HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow + end + + def meta_property(html, property) + html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value + end +end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index af31c923f..8765ef5e3 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -22,6 +22,7 @@ class PostStatusService < BaseService process_mentions_service.call(status) process_hashtags_service.call(status) + LinkCrawlWorker.perform_async(status.id) DistributionWorker.perform_async(status.id) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) diff --git a/app/views/api/v1/statuses/card.rabl b/app/views/api/v1/statuses/card.rabl new file mode 100644 index 000000000..8ba8dcbb1 --- /dev/null +++ b/app/views/api/v1/statuses/card.rabl @@ -0,0 +1,5 @@ +object @card + +attributes :url, :title, :description + +node(:image) { |card| card.image? ? full_asset_url(card.image.url(:original)) : nil } diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb new file mode 100644 index 000000000..af3394b8b --- /dev/null +++ b/app/workers/link_crawl_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class LinkCrawlWorker + include Sidekiq::Worker + + sidekiq_options retry: false + + def perform(status_id) + FetchLinkCardService.new.call(Status.find(status_id)) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/config/application.rb b/config/application.rb index e97fb165b..d0b06bf95 100644 --- a/config/application.rb +++ b/config/application.rb @@ -3,6 +3,7 @@ require_relative 'boot' require 'rails/all' require_relative '../app/lib/exceptions' +require_relative '../lib/statsd_monitor' # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. @@ -30,7 +31,7 @@ module Mastodon config.active_job.queue_adapter = :sidekiq - config.middleware.insert(0, 'StatsDMonitor') + config.middleware.insert(0, ::StatsDMonitor) config.middleware.insert_before 0, Rack::Cors do allow do diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 8fd1ae72c..b5e43e705 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -12,4 +12,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'StatsD' + inflect.acronym 'OEmbed' end diff --git a/config/routes.rb b/config/routes.rb index 42de503f0..4606c663a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,7 @@ Rails.application.routes.draw do resources :statuses, only: [:create, :show, :destroy] do member do get :context + get :card get :reblogged_by get :favourited_by @@ -146,7 +147,7 @@ Rails.application.routes.draw do get '/about', to: 'about#index' get '/about/more', to: 'about#more' get '/terms', to: 'about#terms' - + root 'home#index' match '*unmatched_route', via: :all, to: 'application#raise_not_found' diff --git a/db/migrate/20170119214911_create_preview_cards.rb b/db/migrate/20170119214911_create_preview_cards.rb new file mode 100644 index 000000000..70ed91bbd --- /dev/null +++ b/db/migrate/20170119214911_create_preview_cards.rb @@ -0,0 +1,17 @@ +class CreatePreviewCards < ActiveRecord::Migration[5.0] + def change + create_table :preview_cards do |t| + t.integer :status_id + t.string :url, null: false, default: '' + + # OpenGraph + t.string :title, null: true + t.string :description, null: true + t.attachment :image + + t.timestamps + end + + add_index :preview_cards, :status_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 37da0c44e..abe6f1bfe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170114203041) do +ActiveRecord::Schema.define(version: 20170119214911) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -157,6 +157,20 @@ ActiveRecord::Schema.define(version: 20170114203041) do t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree end + create_table "preview_cards", force: :cascade do |t| + t.integer "status_id" + t.string "url", default: "", null: false + t.string "title" + t.string "description" + t.string "image_file_name" + t.string "image_content_type" + t.integer "image_file_size" + t.datetime "image_updated_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["status_id"], name: "index_preview_cards_on_status_id", unique: true, using: :btree + end + create_table "pubsubhubbub_subscriptions", force: :cascade do |t| t.string "topic", default: "", null: false t.string "callback", default: "", null: false diff --git a/lib/statsd_monitor.rb b/lib/statsd_monitor.rb new file mode 100644 index 000000000..e48ce6541 --- /dev/null +++ b/lib/statsd_monitor.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class StatsDMonitor + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + end +end diff --git a/spec/fabricators/preview_card_fabricator.rb b/spec/fabricators/preview_card_fabricator.rb new file mode 100644 index 000000000..448a94e7e --- /dev/null +++ b/spec/fabricators/preview_card_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:preview_card) do + status_id 1 + url "MyString" + html "MyText" +end diff --git a/spec/models/preview_card_spec.rb b/spec/models/preview_card_spec.rb new file mode 100644 index 000000000..14ef23923 --- /dev/null +++ b/spec/models/preview_card_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe PreviewCard, type: :model do + +end diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb index d40bf0b44..9cb3d41ce 100644 --- a/spec/models/subscription_spec.rb +++ b/spec/models/subscription_spec.rb @@ -1,5 +1,5 @@ require 'rails_helper' RSpec.describe Subscription, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + end -- cgit From 3a4b5961bea66681ba9c455fcfd3ec497bfaed2f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 21 Jan 2017 22:14:13 +0100 Subject: Method to fetch a single notification --- app/controllers/api/v1/notifications_controller.rb | 4 ++++ config/routes.rb | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) (limited to 'app/controllers/api/v1') diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index c8f162cb0..3fd701997 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -20,4 +20,8 @@ class Api::V1::NotificationsController < ApiController set_pagination_headers(next_path, prev_path) end + + def show + @notification = Notification.where(account: current_account).find(params[:id]) + end end diff --git a/config/routes.rb b/config/routes.rb index 4606c663a..349ab336a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -114,7 +114,7 @@ Rails.application.routes.draw do end end - resources :notifications, only: [:index] + resources :notifications, only: [:index, :show] resources :favourites, only: [:index] resources :accounts, only: [:show] do -- cgit From f392030ab82a70086f93bb02c3faab53c3fbd28e Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 23 Jan 2017 21:09:27 +0100 Subject: Add /api/v1/notifications/clear, non-existing link cards for statuses will now return empty hash instead of throwing a 404 error. When following, merge into timeline will filter statuses --- app/assets/javascripts/components/actions/cards.jsx | 7 +++---- app/controllers/api/v1/notifications_controller.rb | 5 +++++ app/controllers/api/v1/statuses_controller.rb | 3 ++- app/lib/feed_manager.rb | 1 + config/routes.rb | 16 ++++++++++------ 5 files changed, 21 insertions(+), 11 deletions(-) (limited to 'app/controllers/api/v1') diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx index ee421d5d7..714e80525 100644 --- a/app/assets/javascripts/components/actions/cards.jsx +++ b/app/assets/javascripts/components/actions/cards.jsx @@ -9,13 +9,12 @@ export function fetchStatusCard(id) { dispatch(fetchStatusCardRequest(id)); api(getState).get(`/api/v1/statuses/${id}/card`).then(response => { - dispatch(fetchStatusCardSuccess(id, response.data)); - }).catch(error => { - if (error.response.status === 404) { - // This is fine + if (response.data.length === 0) { return; } + dispatch(fetchStatusCardSuccess(id, response.data)); + }).catch(error => { dispatch(fetchStatusCardFail(id, error)); }); }; diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 3fd701997..ee12446d8 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -24,4 +24,9 @@ class Api::V1::NotificationsController < ApiController def show @notification = Notification.where(account: current_account).find(params[:id]) end + + def clear + Notification.where(account: current_account).delete_all + render_empty + end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 37ed5e6dd..da87ebbad 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -22,7 +22,8 @@ class Api::V1::StatusesController < ApiController end def card - @card = PreviewCard.find_by!(status: @status) + @card = PreviewCard.find_by(status: @status) + render_empty if @card.nil? end def reblogged_by diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 0056321fa..19f9dc16f 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -43,6 +43,7 @@ class FeedManager timeline_key = key(:home, into_account.id) from_account.statuses.limit(MAX_ITEMS).each do |status| + next if filter?(:home, status, into_account) redis.zadd(timeline_key, status.id, status.id) end diff --git a/config/routes.rb b/config/routes.rb index 7b9cda908..15fb924f1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -103,10 +103,11 @@ Rails.application.routes.draw do get '/timelines/public', to: 'timelines#public', as: :public_timeline get '/timelines/tag/:id', to: 'timelines#tag', as: :hashtag_timeline - resources :follows, only: [:create] - resources :media, only: [:create] - resources :apps, only: [:create] - resources :blocks, only: [:index] + resources :follows, only: [:create] + resources :media, only: [:create] + resources :apps, only: [:create] + resources :blocks, only: [:index] + resources :favourites, only: [:index] resources :follow_requests, only: [:index] do member do @@ -115,8 +116,11 @@ Rails.application.routes.draw do end end - resources :notifications, only: [:index, :show] - resources :favourites, only: [:index] + resources :notifications, only: [:index, :show] do + collection do + post :clear + end + end resources :accounts, only: [:show] do collection do -- cgit From cca82bf0a2f0ccbf0feda00763fd7df0877845b6 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 23 Jan 2017 21:29:34 +0100 Subject: Move merging/unmerging of timelines into background. Move blocking into background as well since it's a computationally expensive --- app/controllers/api/v1/accounts_controller.rb | 9 +++++++-- app/lib/feed_manager.rb | 9 +++++++++ app/services/follow_service.rb | 2 +- app/services/unfollow_service.rb | 17 +---------------- app/workers/block_worker.rb | 9 +++++++++ app/workers/merge_worker.rb | 9 +++++++++ app/workers/unmerge_worker.rb | 9 +++++++++ 7 files changed, 45 insertions(+), 19 deletions(-) create mode 100644 app/workers/block_worker.rb create mode 100644 app/workers/merge_worker.rb create mode 100644 app/workers/unmerge_worker.rb (limited to 'app/controllers/api/v1') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index bd52dfb2c..234844ed7 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -65,8 +65,13 @@ class Api::V1::AccountsController < ApiController end def block - BlockService.new.call(current_user.account, @account) - set_relationship + BlockWorker.perform_async(current_user.account_id, @account.id) + + @following = { @account.id => false } + @followed_by = { @account.id => false } + @blocking = { @account.id => true } + @requested = { @account.id => false } + render action: :relationship end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 19f9dc16f..cdd26e69c 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -50,6 +50,15 @@ class FeedManager trim(:home, into_account.id) end + def unmerge_from_timeline(from_account, into_account) + timeline_key = key(:home, into_account.id) + + from_account.statuses.select('id').find_each do |status| + redis.zrem(timeline_key, status.id) + redis.zremrangebyscore(timeline_key, status.id, status.id) + end + end + def inline_render(target_account, template, object) rabl_scope = Class.new do include RoutingHelper diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 555f01b6d..87c16a621 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -38,7 +38,7 @@ class FollowService < BaseService NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) end - FeedManager.instance.merge_into_timeline(target_account, source_account) + MergeWorker.perform_async(target_account.id, source_account.id) Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id) follow diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 7973a3611..f469793c1 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -7,21 +7,6 @@ class UnfollowService < BaseService def call(source_account, target_account) follow = source_account.unfollow!(target_account) NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) unless target_account.local? - unmerge_from_timeline(target_account, source_account) - end - - private - - def unmerge_from_timeline(from_account, into_account) - timeline_key = FeedManager.instance.key(:home, into_account.id) - - from_account.statuses.select('id').find_each do |status| - redis.zrem(timeline_key, status.id) - redis.zremrangebyscore(timeline_key, status.id, status.id) - end - end - - def redis - Redis.current + UnmergeWorker.perform_async(target_account.id, source_account.id) end end diff --git a/app/workers/block_worker.rb b/app/workers/block_worker.rb new file mode 100644 index 000000000..dc00db197 --- /dev/null +++ b/app/workers/block_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class BlockWorker + include Sidekiq::Worker + + def perform(account_id, target_account_id) + BlockService.new.call(Account.find(account_id), Account.find(target_account_id)) + end +end diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb new file mode 100644 index 000000000..0f288f43f --- /dev/null +++ b/app/workers/merge_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MergeWorker + include Sidekiq::Worker + + def perform(from_account_id, into_account_id) + FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id)) + end +end diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb new file mode 100644 index 000000000..dbf7243de --- /dev/null +++ b/app/workers/unmerge_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class UnmergeWorker + include Sidekiq::Worker + + def perform(from_account_id, into_account_id) + FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id)) + end +end -- cgit From bf0f6eb62d0f5bd1f0d8e4e2a6e9e8fd3b297b6c Mon Sep 17 00:00:00 2001 From: blackle Date: Thu, 12 Jan 2017 23:54:26 -0500 Subject: Implement a click-to-view spoiler system --- .../javascripts/components/actions/compose.jsx | 18 ++++++ .../components/components/status_content.jsx | 18 ++++++ .../features/compose/components/compose_form.jsx | 29 ++++++++- .../compose/containers/compose_form_container.jsx | 12 ++++ .../javascripts/components/reducers/compose.jsx | 10 +++ app/assets/javascripts/extras.jsx | 10 +++ app/assets/stylesheets/components.scss | 44 ++++++++++++-- app/controllers/api/v1/statuses_controller.rb | 2 +- app/helpers/atom_builder_helper.rb | 2 + app/lib/formatter.rb | 17 +++++- app/models/status.rb | 4 +- app/services/post_status_service.rb | 4 ++ app/services/process_hashtags_service.rb | 1 + app/validators/status_length_validator.rb | 15 +++++ app/views/api/v1/statuses/_show.rabl | 2 +- .../20170112041538_add_spoiler_to_statuses.rb | 5 ++ .../20170114014334_add_spoiler_text_to_statuses.rb | 5 ++ db/schema.rb | 71 +--------------------- 18 files changed, 192 insertions(+), 77 deletions(-) create mode 100644 app/validators/status_length_validator.rb create mode 100644 db/migrate/20170112041538_add_spoiler_to_statuses.rb create mode 100644 db/migrate/20170114014334_add_spoiler_text_to_statuses.rb (limited to 'app/controllers/api/v1') diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index 05674ba89..948ccf872 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -23,6 +23,8 @@ export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; +export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; +export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; @@ -68,6 +70,8 @@ export function submitCompose() { in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')), sensitive: getState().getIn(['compose', 'sensitive']), + spoiler: getState().getIn(['compose', 'spoiler']), + spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public') }).then(function (response) { dispatch(submitComposeSuccess({ ...response.data })); @@ -218,6 +222,20 @@ export function changeComposeSensitivity(checked) { }; }; +export function changeComposeSpoilerness(checked) { + return { + type: COMPOSE_SPOILERNESS_CHANGE, + checked + }; +}; + +export function changeComposeSpoilerText(text) { + return { + type: COMPOSE_SPOILER_TEXT_CHANGE, + text + }; +}; + export function changeComposeVisibility(checked) { return { type: COMPOSE_VISIBILITY_CHANGE, diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx index f2c88cee0..7287aa836 100644 --- a/app/assets/javascripts/components/components/status_content.jsx +++ b/app/assets/javascripts/components/components/status_content.jsx @@ -18,6 +18,12 @@ const StatusContent = React.createClass({ componentDidMount () { const node = ReactDOM.findDOMNode(this); const links = node.querySelectorAll('a'); + const spoilers = node.querySelectorAll('.spoiler'); + + for (var i = 0; i < spoilers.length; ++i) { + let spoiler = spoilers[i]; + spoiler.addEventListener('click', this.onSpoilerClick.bind(this, spoiler), true); + } for (var i = 0; i < links.length; ++i) { let link = links[i]; @@ -52,6 +58,18 @@ const StatusContent = React.createClass({ } }, + onSpoilerClick (spoiler, e) { + if (e.button === 0) { + //only toggle if we're not clicking a visible link + var hasClass = $(spoiler).hasClass('spoiler-on'); + if (hasClass || e.target === spoiler) { + e.stopPropagation(); + e.preventDefault(); + $(spoiler).siblings(".spoiler").andSelf().toggleClass('spoiler-on', !hasClass); + } + } + }, + onNormalClick (e) { e.stopPropagation(); }, 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 80cb38e16..84d273299 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -14,6 +14,7 @@ import { Motion, spring } from 'react-motion'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' }, publish: { id: 'compose_form.publish', defaultMessage: 'Publish' } }); @@ -25,6 +26,8 @@ const ComposeForm = React.createClass({ suggestion_token: React.PropTypes.string, suggestions: ImmutablePropTypes.list, sensitive: React.PropTypes.bool, + spoiler: React.PropTypes.bool, + spoiler_text: React.PropTypes.string, unlisted: React.PropTypes.bool, private: React.PropTypes.bool, fileDropDate: React.PropTypes.instanceOf(Date), @@ -40,6 +43,8 @@ const ComposeForm = React.createClass({ onFetchSuggestions: React.PropTypes.func.isRequired, onSuggestionSelected: React.PropTypes.func.isRequired, onChangeSensitivity: React.PropTypes.func.isRequired, + onChangeSpoilerness: React.PropTypes.func.isRequired, + onChangeSpoilerText: React.PropTypes.func.isRequired, onChangeVisibility: React.PropTypes.func.isRequired, onChangeListability: React.PropTypes.func.isRequired, }, @@ -77,6 +82,15 @@ const ComposeForm = React.createClass({ this.props.onChangeSensitivity(e.target.checked); }, + handleChangeSpoilerness (e) { + this.props.onChangeSpoilerness(e.target.checked); + this.props.onChangeSpoilerText(''); + }, + + handleChangeSpoilerText (e) { + this.props.onChangeSpoilerText(e.target.value); + }, + handleChangeVisibility (e) { this.props.onChangeVisibility(e.target.checked); }, @@ -115,6 +129,14 @@ const ComposeForm = React.createClass({ return (
+ + {({ opacity, height }) => +
+ +
+ } +
+ {replyArea}
-
+
@@ -142,6 +164,11 @@ const ComposeForm = React.createClass({ + + {({ opacity, height }) =>