commit
b7804028c2
@ -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 |
Loading…
Reference in new issue