Conflicts: db/schema.rbclosed-social-glitch-2
@ -0,0 +1,71 @@ | |||
# frozen_string_literal: true | |||
class Api::V1::BookmarksController < Api::BaseController | |||
before_action -> { doorkeeper_authorize! :read } | |||
before_action :require_user! | |||
after_action :insert_pagination_headers | |||
respond_to :json | |||
def index | |||
@statuses = load_statuses | |||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) | |||
end | |||
private | |||
def load_statuses | |||
cached_bookmarks | |||
end | |||
def cached_bookmarks | |||
cache_collection( | |||
Status.reorder(nil).joins(:bookmarks).merge(results), | |||
Status | |||
) | |||
end | |||
def results | |||
@_results ||= account_bookmarks.paginate_by_max_id( | |||
limit_param(DEFAULT_STATUSES_LIMIT), | |||
params[:max_id], | |||
params[:since_id] | |||
) | |||
end | |||
def account_bookmarks | |||
current_account.bookmarks | |||
end | |||
def insert_pagination_headers | |||
set_pagination_headers(next_path, prev_path) | |||
end | |||
def next_path | |||
if records_continue? | |||
api_v1_bookmarks_url pagination_params(max_id: pagination_max_id) | |||
end | |||
end | |||
def prev_path | |||
unless results.empty? | |||
api_v1_bookmarks_url pagination_params(since_id: pagination_since_id) | |||
end | |||
end | |||
def pagination_max_id | |||
results.last.id | |||
end | |||
def pagination_since_id | |||
results.first.id | |||
end | |||
def records_continue? | |||
results.size == limit_param(DEFAULT_STATUSES_LIMIT) | |||
end | |||
def pagination_params(core_params) | |||
params.slice(:limit).permit(:limit).merge(core_params) | |||
end | |||
end |
@ -0,0 +1,39 @@ | |||
# frozen_string_literal: true | |||
class Api::V1::Statuses::BookmarksController < Api::BaseController | |||
include Authorization | |||
before_action -> { doorkeeper_authorize! :write } | |||
before_action :require_user! | |||
respond_to :json | |||
def create | |||
@status = bookmarked_status | |||
render json: @status, serializer: REST::StatusSerializer | |||
end | |||
def destroy | |||
@status = requested_status | |||
@bookmarks_map = { @status.id => false } | |||
bookmark = Bookmark.find_by!(account: current_user.account, status: @status) | |||
bookmark.destroy! | |||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, bookmarks_map: @bookmarks_map) | |||
end | |||
private | |||
def bookmarked_status | |||
authorize_with current_user.account, requested_status, :show? | |||
bookmark = Bookmark.find_or_create_by!(account: current_user.account, status: requested_status) | |||
bookmark.status.reload | |||
end | |||
def requested_status | |||
Status.find(params[:status_id]) | |||
end | |||
end |
@ -0,0 +1,87 @@ | |||
import api, { getLinks } from 'flavours/glitch/util/api'; | |||
export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; | |||
export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; | |||
export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL'; | |||
export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST'; | |||
export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS'; | |||
export const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL'; | |||
export function fetchBookmarkedStatuses() { | |||
return (dispatch, getState) => { | |||
if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { | |||
return; | |||
} | |||
dispatch(fetchBookmarkedStatusesRequest()); | |||
api(getState).get('/api/v1/bookmarks').then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); | |||
}).catch(error => { | |||
dispatch(fetchBookmarkedStatusesFail(error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchBookmarkedStatusesRequest() { | |||
return { | |||
type: BOOKMARKED_STATUSES_FETCH_REQUEST, | |||
}; | |||
}; | |||
export function fetchBookmarkedStatusesSuccess(statuses, next) { | |||
return { | |||
type: BOOKMARKED_STATUSES_FETCH_SUCCESS, | |||
statuses, | |||
next, | |||
}; | |||
}; | |||
export function fetchBookmarkedStatusesFail(error) { | |||
return { | |||
type: BOOKMARKED_STATUSES_FETCH_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function expandBookmarkedStatuses() { | |||
return (dispatch, getState) => { | |||
const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null); | |||
if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { | |||
return; | |||
} | |||
dispatch(expandBookmarkedStatusesRequest()); | |||
api(getState).get(url).then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); | |||
}).catch(error => { | |||
dispatch(expandBookmarkedStatusesFail(error)); | |||
}); | |||
}; | |||
}; | |||
export function expandBookmarkedStatusesRequest() { | |||
return { | |||
type: BOOKMARKED_STATUSES_EXPAND_REQUEST, | |||
}; | |||
}; | |||
export function expandBookmarkedStatusesSuccess(statuses, next) { | |||
return { | |||
type: BOOKMARKED_STATUSES_EXPAND_SUCCESS, | |||
statuses, | |||
next, | |||
}; | |||
}; | |||
export function expandBookmarkedStatusesFail(error) { | |||
return { | |||
type: BOOKMARKED_STATUSES_EXPAND_FAIL, | |||
error, | |||
}; | |||
}; |
@ -0,0 +1,98 @@ | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import PropTypes from 'prop-types'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'flavours/glitch/actions/bookmarks'; | |||
import Column from 'flavours/glitch/features/ui/components/column'; | |||
import ColumnHeader from 'flavours/glitch/components/column_header'; | |||
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; | |||
import StatusList from 'flavours/glitch/components/status_list'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import { debounce } from 'lodash'; | |||
const messages = defineMessages({ | |||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, | |||
}); | |||
const mapStateToProps = state => ({ | |||
statusIds: state.getIn(['status_lists', 'bookmarks', 'items']), | |||
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), | |||
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), | |||
}); | |||
@connect(mapStateToProps) | |||
@injectIntl | |||
export default class Bookmarks extends ImmutablePureComponent { | |||
static propTypes = { | |||
dispatch: PropTypes.func.isRequired, | |||
statusIds: ImmutablePropTypes.list.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
columnId: PropTypes.string, | |||
multiColumn: PropTypes.bool, | |||
hasMore: PropTypes.bool, | |||
isLoading: PropTypes.bool, | |||
}; | |||
componentWillMount () { | |||
this.props.dispatch(fetchBookmarkedStatuses()); | |||
} | |||
handlePin = () => { | |||
const { columnId, dispatch } = this.props; | |||
if (columnId) { | |||
dispatch(removeColumn(columnId)); | |||
} else { | |||
dispatch(addColumn('BOOKMARKS', {})); | |||
} | |||
} | |||
handleMove = (dir) => { | |||
const { columnId, dispatch } = this.props; | |||
dispatch(moveColumn(columnId, dir)); | |||
} | |||
handleHeaderClick = () => { | |||
this.column.scrollTop(); | |||
} | |||
setRef = c => { | |||
this.column = c; | |||
} | |||
handleScrollToBottom = debounce(() => { | |||
this.props.dispatch(expandBookmarkedStatuses()); | |||
}, 300, { leading: true }) | |||
render () { | |||
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; | |||
const pinned = !!columnId; | |||
return ( | |||
<Column ref={this.setRef} name='bookmarks'> | |||
<ColumnHeader | |||
icon='bookmark' | |||
title={intl.formatMessage(messages.heading)} | |||
onPin={this.handlePin} | |||
onMove={this.handleMove} | |||
onClick={this.handleHeaderClick} | |||
pinned={pinned} | |||
multiColumn={multiColumn} | |||
showBackButton | |||
/> | |||
<StatusList | |||
trackScroll={!pinned} | |||
statusIds={statusIds} | |||
scrollKey={`bookmarked_statuses-${columnId}`} | |||
hasMore={hasMore} | |||
isLoading={isLoading} | |||
onScrollToBottom={this.handleScrollToBottom} | |||
/> | |||
</Column> | |||
); | |||
} | |||
} |
@ -0,0 +1,26 @@ | |||
# frozen_string_literal: true | |||
# == Schema Information | |||
# | |||
# Table name: bookmarks | |||
# | |||
# id :integer not null, primary key | |||
# created_at :datetime not null | |||
# updated_at :datetime not null | |||
# account_id :integer not null | |||
# status_id :integer not null | |||
# | |||
class Bookmark < ApplicationRecord | |||
include Paginable | |||
update_index('statuses#status', :status) if Chewy.enabled? | |||
belongs_to :account, inverse_of: :bookmarks | |||
belongs_to :status, inverse_of: :bookmarks | |||
validates :status_id, uniqueness: { scope: :account_id } | |||
before_validation do | |||
self.status = status.reblog if status&.reblog? | |||
end | |||
end |
@ -0,0 +1,14 @@ | |||
class CreateBookmarks < ActiveRecord::Migration[5.1] | |||
def change | |||
create_table :bookmarks do |t| | |||
t.references :account, null: false | |||
t.references :status, null: false | |||
t.timestamps | |||
end | |||
add_foreign_key :bookmarks, :accounts, column: :account_id, on_delete: :cascade | |||
add_foreign_key :bookmarks, :statuses, column: :status_id, on_delete: :cascade | |||
add_index :bookmarks, [:account_id, :status_id], unique: true | |||
end | |||
end |
@ -0,0 +1,78 @@ | |||
require 'rails_helper' | |||
RSpec.describe Api::V1::BookmarksController, type: :controller do | |||
render_views | |||
let(:user) { Fabricate(:user) } | |||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') } | |||
describe 'GET #index' do | |||
context 'without token' do | |||
it 'returns http unauthorized' do | |||
get :index | |||
expect(response).to have_http_status :unauthorized | |||
end | |||
end | |||
context 'with token' do | |||
context 'without read scope' do | |||
before do | |||
allow(controller).to receive(:doorkeeper_token) do | |||
Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: '') | |||
end | |||
end | |||
it 'returns http forbidden' do | |||
get :index | |||
expect(response).to have_http_status :forbidden | |||
end | |||
end | |||
context 'without valid resource owner' do | |||
before do | |||
token = Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') | |||
user.destroy! | |||
allow(controller).to receive(:doorkeeper_token) { token } | |||
end | |||
it 'returns http unprocessable entity' do | |||
get :index | |||
expect(response).to have_http_status :unprocessable_entity | |||
end | |||
end | |||
context 'with read scope and valid resource owner' do | |||
before do | |||
allow(controller).to receive(:doorkeeper_token) do | |||
Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') | |||
end | |||
end | |||
it 'shows bookmarks owned by the user' do | |||
bookmarked_by_user = Fabricate(:bookmark, account: user.account) | |||
bookmarked_by_others = Fabricate(:bookmark) | |||
get :index | |||
expect(assigns(:statuses)).to match_array [bookmarked_by_user.status] | |||
end | |||
it 'adds pagination headers if necessary' do | |||
bookmark = Fabricate(:bookmark, account: user.account) | |||
get :index, params: { limit: 1 } | |||
expect(response.headers['Link'].find_link(['rel', 'next']).href).to eq "http://test.host/api/v1/bookmarks?limit=1&max_id=#{bookmark.id}" | |||
expect(response.headers['Link'].find_link(['rel', 'prev']).href).to eq "http://test.host/api/v1/bookmarks?limit=1&since_id=#{bookmark.id}" | |||
end | |||
it 'does not add pagination headers if not necessary' do | |||
get :index | |||
expect(response.headers['Link']).to eq nil | |||
end | |||
end | |||
end | |||
end | |||
end |
@ -0,0 +1,57 @@ | |||
# frozen_string_literal: true | |||
require 'rails_helper' | |||
describe Api::V1::Statuses::BookmarksController do | |||
render_views | |||
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } | |||
let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } | |||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write', application: app) } | |||
context 'with an oauth token' do | |||
before do | |||
allow(controller).to receive(:doorkeeper_token) { token } | |||
end | |||
describe 'POST #create' do | |||
let(:status) { Fabricate(:status, account: user.account) } | |||
before do | |||
post :create, params: { status_id: status.id } | |||
end | |||
it 'returns http success' do | |||
expect(response).to have_http_status(:success) | |||
end | |||
it 'updates the bookmarked attribute' do | |||
expect(user.account.bookmarked?(status)).to be true | |||
end | |||
it 'return json with updated attributes' do | |||
hash_body = body_as_json | |||
expect(hash_body[:id]).to eq status.id.to_s | |||
expect(hash_body[:bookmarked]).to be true | |||
end | |||
end | |||
describe 'POST #destroy' do | |||
let(:status) { Fabricate(:status, account: user.account) } | |||
before do | |||
Bookmark.find_or_create_by!(account: user.account, status: status) | |||
post :destroy, params: { status_id: status.id } | |||
end | |||
it 'returns http success' do | |||
expect(response).to have_http_status(:success) | |||
end | |||
it 'updates the bookmarked attribute' do | |||
expect(user.account.bookmarked?(status)).to be false | |||
end | |||
end | |||
end | |||
end |
@ -0,0 +1,4 @@ | |||
Fabricator(:bookmark) do | |||
account | |||
status | |||
end |