This commit is contained in:
Duarte Serrano 2026-06-09 07:47:25 -03:00 committed by GitHub
commit a375cb1c19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 2072 additions and 97 deletions

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
class Api::V1::BookmarkFolders::BookmarksController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:bookmarks' }
before_action :require_user!
after_action :insert_pagination_headers
def index
@folder = BookmarkFolder.where(account: current_account).find(params[:bookmark_folder_id])
@bookmarks = load_bookmarks(@folder.bookmarks)
render_bookmarks
end
def unfolded
@bookmarks = load_bookmarks(current_account.bookmarks.where(folder_id: nil))
render_bookmarks
end
private
def load_bookmarks(scope)
results = scope.joins(:status)
.eager_load(:status)
.to_a_paginated_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
preload_collection(results.map(&:status), Status)
end
def next_path
folder_api_v1_bookmarks_url(@folder, pagination_params(max_id: pagination_max_id)) if records_continue?
end
def prev_path
folder_api_v1_bookmarks_url(@folder, pagination_params(min_id: pagination_since_id)) unless @bookmarks.empty?
end
def render_bookmarks
render json: @bookmarks,
each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@bookmarks, current_account.id)
end
def pagination_collection
@bookmarks
end
def records_continue?
@bookmarks.size == limit_param(DEFAULT_STATUSES_LIMIT)
end
end

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
class Api::V1::BookmarkFoldersController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:bookmarks' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write, :'write:bookmarks' }, except: [:index, :show]
before_action :require_user!
before_action :set_folder, except: [:index, :create]
def index
@folders = BookmarkFolder.where(account: current_account).all
render json: @folders, each_serializer: REST::BookmarkFolderSerializer
end
def show
render json: @folder, serializer: REST::BookmarkFolderSerializer
end
def create
@folder = BookmarkFolder.create!(folder_params.merge(account: current_account))
render json: @folder, serializer: REST::BookmarkFolderSerializer
end
def update
@folder.update!(folder_params)
render json: @folder, serializer: REST::BookmarkFolderSerializer
end
def destroy
@folder.destroy!
render_empty
end
private
def set_folder
@folder = BookmarkFolder.where(account: current_account).find(params[:id])
end
def folder_params
params.permit(:title)
end
end

View File

@ -6,7 +6,8 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController
skip_before_action :set_status, only: [:destroy]
def create
current_account.bookmarks.find_or_create_by!(account: current_account, status: @status)
bookmark = current_account.bookmarks.find_or_initialize_by(status: @status)
bookmark.update!(bookmark_params) if bookmark_params.present? || bookmark.new_record?
render json: @status, serializer: REST::StatusSerializer
end
@ -26,4 +27,14 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end
private
def bookmark_params
permitted = params.permit(:folder_id)
raise ActiveRecord::RecordNotFound if permitted[:folder_id].present? && !current_account.bookmark_folders.exists?(id: permitted[:folder_id])
permitted
end
end

View File

@ -0,0 +1,34 @@
import {
apiCreateBookmarkFolder,
apiDeleteBookmarkFolder,
apiGetBookmarkFolder,
apiGetBookmarkFolders,
apiUpdateBookmarkFolder,
} from 'mastodon/api/bookmark_folders';
import type { BookmarkFolder } from 'mastodon/models/bookmark_folder';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
export const createBookmarkFolder = createDataLoadingThunk(
'bookmarkFolders/create',
(folder: Partial<BookmarkFolder>) => apiCreateBookmarkFolder(folder),
);
export const updateBookmarkFolder = createDataLoadingThunk(
'bookmarkFolders/update',
(folder: Partial<BookmarkFolder>) => apiUpdateBookmarkFolder(folder),
);
export const fetchBookmarkFolders = createDataLoadingThunk(
'bookmarkFolders/fetch',
() => apiGetBookmarkFolders(),
);
export const fetchBookmarkFolder = createDataLoadingThunk(
'bookmarkFolders/fetchOne',
({ id }: { id: string }) => apiGetBookmarkFolder(id),
);
export const deleteBookmarkFolder = createDataLoadingThunk(
'bookmarkFolders/delete',
({ id }: { id: string }) => apiDeleteBookmarkFolder(id).then(() => ({ id })),
);

View File

@ -6,35 +6,69 @@ export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQU
export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS';
export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL';
export const BOOKMARK_FOLDER_STATUSES_FETCH_REQUEST = 'BOOKMARK_FOLDER_STATUSES_FETCH_REQUEST';
export const BOOKMARK_FOLDER_STATUSES_FETCH_SUCCESS = 'BOOKMARK_FOLDER_STATUSES_FETCH_SUCCESS';
export const BOOKMARK_FOLDER_STATUSES_FETCH_FAIL = 'BOOKMARK_FOLDER_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() {
export const BOOKMARK_FOLDER_STATUSES_EXPAND_REQUEST = 'BOOKMARK_FOLDER_STATUSES_EXPAND_REQUEST';
export const BOOKMARK_FOLDER_STATUSES_EXPAND_SUCCESS = 'BOOKMARK_FOLDER_STATUSES_EXPAND_SUCCESS';
export const BOOKMARK_FOLDER_STATUSES_EXPAND_FAIL = 'BOOKMARK_FOLDER_STATUSES_EXPAND_FAIL';
const getBookmarksListPath = (folderId) => (
folderId ? ['status_lists', 'bookmark_folders', folderId] : ['status_lists', 'bookmarks']
);
const getBookmarksListUrl = (folderId) => (
folderId ? `/api/v1/bookmarks/folders/${folderId}` : '/api/v1/bookmarks'
);
export function fetchBookmarkedStatuses(folderId) {
return (dispatch, getState) => {
if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
const listPath = getBookmarksListPath(folderId);
if (getState().getIn([...listPath, 'isLoading'])) {
return;
}
dispatch(fetchBookmarkedStatusesRequest());
dispatch(fetchBookmarkedStatusesRequest(folderId));
api().get('/api/v1/bookmarks').then(response => {
api().get(getBookmarksListUrl(folderId)).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null, folderId));
}).catch(error => {
dispatch(fetchBookmarkedStatusesFail(error));
dispatch(fetchBookmarkedStatusesFail(error, folderId));
});
};
}
export function fetchBookmarkedStatusesRequest() {
export function fetchBookmarkedStatusesRequest(folderId) {
if (folderId) {
return {
type: BOOKMARK_FOLDER_STATUSES_FETCH_REQUEST,
folderId,
};
}
return {
type: BOOKMARKED_STATUSES_FETCH_REQUEST,
};
}
export function fetchBookmarkedStatusesSuccess(statuses, next) {
export function fetchBookmarkedStatusesSuccess(statuses, next, folderId) {
if (folderId) {
return {
type: BOOKMARK_FOLDER_STATUSES_FETCH_SUCCESS,
statuses,
next,
folderId,
};
}
return {
type: BOOKMARKED_STATUSES_FETCH_SUCCESS,
statuses,
@ -42,40 +76,65 @@ export function fetchBookmarkedStatusesSuccess(statuses, next) {
};
}
export function fetchBookmarkedStatusesFail(error) {
export function fetchBookmarkedStatusesFail(error, folderId) {
if (folderId) {
return {
type: BOOKMARK_FOLDER_STATUSES_FETCH_FAIL,
folderId,
error,
};
}
return {
type: BOOKMARKED_STATUSES_FETCH_FAIL,
error,
};
}
export function expandBookmarkedStatuses() {
export function expandBookmarkedStatuses(folderId) {
return (dispatch, getState) => {
const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null);
const listPath = getBookmarksListPath(folderId);
const url = getState().getIn([...listPath, 'next'], null);
if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
if (url === null || getState().getIn([...listPath, 'isLoading'])) {
return;
}
dispatch(expandBookmarkedStatusesRequest());
dispatch(expandBookmarkedStatusesRequest(folderId));
api().get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null, folderId));
}).catch(error => {
dispatch(expandBookmarkedStatusesFail(error));
dispatch(expandBookmarkedStatusesFail(error, folderId));
});
};
}
export function expandBookmarkedStatusesRequest() {
export function expandBookmarkedStatusesRequest(folderId) {
if (folderId) {
return {
type: BOOKMARK_FOLDER_STATUSES_EXPAND_REQUEST,
folderId,
};
}
return {
type: BOOKMARKED_STATUSES_EXPAND_REQUEST,
};
}
export function expandBookmarkedStatusesSuccess(statuses, next) {
export function expandBookmarkedStatusesSuccess(statuses, next, folderId) {
if (folderId) {
return {
type: BOOKMARK_FOLDER_STATUSES_EXPAND_SUCCESS,
statuses,
next,
folderId,
};
}
return {
type: BOOKMARKED_STATUSES_EXPAND_SUCCESS,
statuses,
@ -83,7 +142,15 @@ export function expandBookmarkedStatusesSuccess(statuses, next) {
};
}
export function expandBookmarkedStatusesFail(error) {
export function expandBookmarkedStatusesFail(error, folderId) {
if (folderId) {
return {
type: BOOKMARK_FOLDER_STATUSES_EXPAND_FAIL,
folderId,
error,
};
}
return {
type: BOOKMARKED_STATUSES_EXPAND_FAIL,
error,

View File

@ -129,11 +129,14 @@ export function unfavouriteFail(status, error) {
};
}
export function bookmark(status) {
export function bookmark(status, folderId) {
return function (dispatch) {
dispatch(bookmarkRequest(status));
api().post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) {
// undefined omits body; null explicitly clears the folder
const data = typeof folderId === 'undefined' ? undefined : { folder_id: folderId };
api().post(`/api/v1/statuses/${status.get('id')}/bookmark`, data).then(function (response) {
dispatch(importFetchedStatus(response.data));
dispatch(bookmarkSuccess(status, response.data));
}).catch(function (error) {

View File

@ -0,0 +1,28 @@
import {
apiRequestDelete,
apiRequestGet,
apiRequestPost,
apiRequestPut,
} from 'mastodon/api';
import type { ApiBookmarkFolderJSON } from 'mastodon/api_types/bookmark_folders';
export const apiCreateBookmarkFolder = (
folder: Partial<ApiBookmarkFolderJSON>,
) => apiRequestPost<ApiBookmarkFolderJSON>('v1/bookmark_folders', folder);
export const apiUpdateBookmarkFolder = (
folder: Partial<ApiBookmarkFolderJSON>,
) =>
apiRequestPut<ApiBookmarkFolderJSON>(
`v1/bookmark_folders/${folder.id}`,
folder,
);
export const apiGetBookmarkFolders = () =>
apiRequestGet<ApiBookmarkFolderJSON[]>('v1/bookmark_folders');
export const apiGetBookmarkFolder = (id: string) =>
apiRequestGet<ApiBookmarkFolderJSON>(`v1/bookmark_folders/${id}`);
export const apiDeleteBookmarkFolder = (id: string) =>
apiRequestDelete(`v1/bookmark_folders/${id}`);

View File

@ -0,0 +1,4 @@
export interface ApiBookmarkFolderJSON {
id: string;
title: string;
}

View File

@ -102,6 +102,7 @@ export interface ApiStatusJSON {
reblogged?: boolean;
muted?: boolean;
bookmarked?: boolean;
bookmark_folder_id?: string | null;
pinned?: boolean;
filtered?: ApiFilterResultJSON[];

View File

@ -45,6 +45,7 @@ const messages = defineMessages({
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
addToBookmarkFolder: { id: 'status.add_to_bookmark_folder', defaultMessage: 'Add to bookmark folder' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
@ -101,6 +102,7 @@ class StatusActionBar extends ImmutablePureComponent {
onMuteConversation: PropTypes.func,
onPin: PropTypes.func,
onBookmark: PropTypes.func,
onBookmarkFolder: PropTypes.func,
onFilter: PropTypes.func,
onAddFilter: PropTypes.func,
onInteractionModal: PropTypes.func,
@ -156,6 +158,10 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onBookmark(this.props.status);
};
handleBookmarkFolderClick = () => {
this.props.onBookmarkFolder?.(this.props.status);
};
handleDeleteClick = () => {
this.props.onDelete(this.props.status);
};
@ -295,6 +301,9 @@ class StatusActionBar extends ImmutablePureComponent {
if (signedIn) {
menu.push(null);
menu.push({ text: intl.formatMessage(messages.addToBookmarkFolder), action: this.handleBookmarkFolderClick });
menu.push(null);
if (writtenByMe && pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);

View File

@ -93,6 +93,13 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
}
},
onBookmarkFolder (status) {
dispatch(openModal({
modalType: 'BOOKMARK_FOLDER_ADDER',
modalProps: { status },
}));
},
onPin (status) {
if (status.get('pinned')) {
dispatch(unpin(status));

View File

@ -0,0 +1,216 @@
import { Map as ImmutableMap } from 'immutable';
import type { Map as ImmutableMapType } from 'immutable';
import { fireEvent, render, screen, waitFor } from '@/testing/rendering';
import type * as BookmarkFoldersTypedModule from 'mastodon/actions/bookmark_folders_typed';
import type * as InteractionsModule from 'mastodon/actions/interactions';
import BookmarkFolderAdder from '..';
import type * as StoreModule from 'mastodon/store';
const status = ImmutableMap({ bookmark_folder_id: '1' });
interface CreateBookmarkFolderAction {
type: 'bookmarkFolders/create';
payload: {
title: string;
};
}
interface FetchBookmarkFoldersAction {
type: 'bookmarkFolders/fetch';
}
interface BookmarkAction {
type: 'bookmarks/bookmark';
payload: {
statusValue: typeof status;
folderId: string | null;
};
}
interface CreateBookmarkFolderFulfilledAction {
type: 'bookmarkFolders/create/fulfilled';
meta: { requestStatus: 'fulfilled' };
payload: { id: string; title: string };
}
interface BookmarkFoldersState {
bookmark_folders: ImmutableMapType<string, { id: string; title: string } | null>;
}
type BookmarkFolderDispatchAction =
| CreateBookmarkFolderAction
| FetchBookmarkFoldersAction;
const folders = [
{ id: '2', title: 'Zulu' },
{ id: '1', title: 'Alpha' },
];
const mocks = vi.hoisted(() => ({
dispatchMock: vi.fn(),
useAppDispatchMock: vi.fn(),
useAppSelectorMock: vi.fn(),
createBookmarkFolderMock: vi.fn(),
fetchBookmarkFoldersMock: vi.fn(),
bookmarkMock: vi.fn(),
}));
const renderComponent = () => {
return render(<BookmarkFolderAdder status={status} onClose={vi.fn()} />);
};
const getFolderRadio = (name: string) =>
screen.getByLabelText<HTMLInputElement>(name);
const getNewFolderInput = () =>
screen.getByPlaceholderText<HTMLInputElement>('New folder name');
const submitNewFolderForm = () => {
fireEvent.submit(getNewFolderInput().closest('form') as HTMLFormElement);
};
const createFolderFulfilledAction = (
title: string,
): CreateBookmarkFolderFulfilledAction => ({
type: 'bookmarkFolders/create/fulfilled',
meta: { requestStatus: 'fulfilled' },
payload: { id: '3', title },
});
vi.mock('mastodon/store', async (importOriginal) => {
const actual: typeof StoreModule = await importOriginal();
return {
...actual,
useAppDispatch: mocks.useAppDispatchMock,
useAppSelector: mocks.useAppSelectorMock,
};
});
vi.mock('mastodon/actions/bookmark_folders_typed', async (importOriginal) => {
const actual: typeof BookmarkFoldersTypedModule = await importOriginal();
const createBookmarkFolder = Object.assign(
mocks.createBookmarkFolderMock,
actual.createBookmarkFolder,
);
const fetchBookmarkFolders = Object.assign(
mocks.fetchBookmarkFoldersMock,
actual.fetchBookmarkFolders,
);
return {
...actual,
createBookmarkFolder,
fetchBookmarkFolders,
};
});
vi.mock('mastodon/actions/interactions', async (importOriginal) => {
const actual: typeof InteractionsModule = await importOriginal();
return {
...actual,
bookmark: mocks.bookmarkMock,
};
});
describe('<BookmarkFolderAdder />', () => {
beforeEach(() => {
mocks.createBookmarkFolderMock.mockImplementation(
(payload: CreateBookmarkFolderAction['payload']) => ({
type: 'bookmarkFolders/create',
payload,
}),
);
mocks.fetchBookmarkFoldersMock.mockImplementation(() => ({
type: 'bookmarkFolders/fetch',
}));
mocks.bookmarkMock.mockImplementation(
(statusValue: typeof status, folderId: string | null): BookmarkAction => ({
type: 'bookmarks/bookmark',
payload: { statusValue, folderId },
}),
);
mocks.dispatchMock.mockImplementation(
(action: BookmarkFolderDispatchAction) => {
if (action.type === 'bookmarkFolders/create') {
const fulfilledAction = createFolderFulfilledAction(
action.payload.title,
);
mocks.bookmarkMock(status, fulfilledAction.payload.id);
return Promise.resolve(fulfilledAction);
}
return action;
},
);
mocks.useAppDispatchMock.mockReturnValue(mocks.dispatchMock);
mocks.useAppSelectorMock.mockImplementation(
(selector: (state: BookmarkFoldersState) => unknown) =>
selector({
bookmark_folders: ImmutableMap(
folders.map((folder) => [folder.id, folder]),
),
}),
);
});
afterEach(() => {
vi.clearAllMocks();
});
it('fetches folders on mount and renders them in title order', () => {
renderComponent();
expect(mocks.fetchBookmarkFoldersMock).toHaveBeenCalledTimes(1);
const titles = screen
.getAllByRole('radio')
.map((radio) => radio.closest('label')?.querySelector('span')?.textContent);
expect(titles).toEqual(['No folder', 'Alpha', 'Zulu']);
});
it('keeps the current folder selected and can move back to no folder', () => {
renderComponent();
expect(getFolderRadio('Alpha').checked).toBe(true);
fireEvent.click(screen.getByLabelText('No folder'));
expect(mocks.bookmarkMock).toHaveBeenCalledWith(status, null);
});
it('selects another existing folder', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Zulu'));
expect(mocks.bookmarkMock).toHaveBeenCalledWith(status, '2');
});
it('creates a new folder and bookmarks the status into it', async () => {
renderComponent();
fireEvent.change(getNewFolderInput(), {
target: { value: 'Work' },
});
submitNewFolderForm();
expect(mocks.createBookmarkFolderMock).toHaveBeenCalledWith({
title: 'Work',
});
await waitFor(() => {
expect(mocks.bookmarkMock).toHaveBeenCalledWith(status, '3');
});
});
});

View File

@ -0,0 +1,197 @@
import { useEffect, useState, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { isFulfilled } from '@reduxjs/toolkit';
import type { Map as ImmutableMap } from 'immutable';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import {
createBookmarkFolder,
fetchBookmarkFolders,
} from 'mastodon/actions/bookmark_folders_typed';
import { bookmark } from 'mastodon/actions/interactions';
import type { ApiBookmarkFolderJSON } from 'mastodon/api_types/bookmark_folders';
import { Button } from 'mastodon/components/button';
import { RadioButton } from 'mastodon/components/form_fields';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { getOrderedBookmarkFolders } from 'mastodon/selectors/bookmark_folders';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
newFolder: {
id: 'bookmark_folders.new_folder_name',
defaultMessage: 'New folder name',
},
createFolder: {
id: 'bookmark_folders.create',
defaultMessage: 'Create folder',
},
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
noFolder: {
id: 'bookmark_folders.no_folder',
defaultMessage: 'No folder',
},
});
const FolderItem: React.FC<{
id: string;
title: string;
checked: boolean;
onChange: (id: string) => void;
}> = ({ id, title, checked, onChange }) => {
const handleChange = useCallback(() => {
onChange(id);
}, [id, onChange]);
return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label className='lists__item'>
<div className='lists__item__title'>
<Icon id='bookmark' icon={BookmarkBorderIcon} />
<span>{title}</span>
</div>
<RadioButton
name='bookmark-folder'
value={id}
checked={checked}
onChange={handleChange}
/>
</label>
);
};
const NewFolderItem: React.FC<{
onCreate: (folder: ApiBookmarkFolderJSON) => void;
}> = ({ onCreate }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [title, setTitle] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
if (title.trim().length === 0) {
return;
}
void dispatch(createBookmarkFolder({ title })).then((result) => {
if (isFulfilled(result)) {
onCreate(result.payload);
setTitle('');
}
return '';
});
}, [title, dispatch, onCreate]);
return (
<form className='lists__item' onSubmit={handleSubmit}>
<label className='lists__item__title'>
<Icon id='bookmark' icon={BookmarkBorderIcon} />
<input
type='text'
value={title}
onChange={handleChange}
maxLength={30}
required
placeholder={intl.formatMessage(messages.newFolder)}
/>
</label>
<Button text={intl.formatMessage(messages.createFolder)} type='submit' />
</form>
);
};
const BookmarkFolderAdder: React.FC<{
status: ImmutableMap<string, unknown>;
onClose: () => void;
}> = ({ status, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const folders = useAppSelector((state) => getOrderedBookmarkFolders(state));
const [localFolderId, setLocalFolderId] = useState<string | null>(null);
const propFolderId =
(status.get('bookmark_folder_id') as string | null | undefined) ?? 'none';
const selectedFolderId = localFolderId ?? propFolderId;
useEffect(() => {
void dispatch(fetchBookmarkFolders());
}, [dispatch]);
const handleSelect = useCallback(
(selectedId: string) => {
setLocalFolderId(selectedId);
dispatch(bookmark(status, selectedId === 'none' ? null : selectedId));
},
[dispatch, status],
);
const handleCreate = useCallback(
(folder: ApiBookmarkFolderJSON) => {
setLocalFolderId(folder.id);
dispatch(bookmark(status, folder.id));
},
[dispatch, status],
);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
className='dialog-modal__header__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<span className='dialog-modal__header__title'>
<FormattedMessage
id='bookmark_folders.add_to_folder'
defaultMessage='Add to bookmark folder'
/>
</span>
</div>
<div className='dialog-modal__content'>
<div className='lists-scrollable'>
<NewFolderItem onCreate={handleCreate} />
<FolderItem
id='none'
title={intl.formatMessage(messages.noFolder)}
checked={selectedFolderId === 'none'}
onChange={handleSelect}
/>
{folders.map((folder) => (
<FolderItem
key={folder.id}
id={folder.id}
title={folder.title}
checked={selectedFolderId === folder.id}
onChange={handleSelect}
/>
))}
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default BookmarkFolderAdder;

View File

@ -0,0 +1,91 @@
import { render, screen, fireEvent } from '@/testing/rendering';
import { ConfirmDeleteBookmarkFolderModal } from '../../ui/components/confirmation_modals/delete_bookmark_folder';
import type * as BookmarkFoldersTypedModule from 'mastodon/actions/bookmark_folders_typed';
import type * as ReactRouterModule from 'react-router';
import type * as StoreModule from 'mastodon/store';
interface DeleteBookmarkFolderAction {
type: 'bookmarkFolders/delete';
payload: {
id: string;
};
}
type DeleteBookmarkFolderDispatchAction = DeleteBookmarkFolderAction;
const mocks = vi.hoisted(() => ({
dispatchMock: vi.fn(),
useAppDispatchMock: vi.fn(),
useAppSelectorMock: vi.fn(),
deleteBookmarkFolderMock: vi.fn(),
pushMock: vi.fn(),
}));
vi.mock('mastodon/store', async () => {
const actual = await vi.importActual<typeof StoreModule>('mastodon/store');
return {
...actual,
useAppDispatch: mocks.useAppDispatchMock,
useAppSelector: mocks.useAppSelectorMock,
};
});
vi.mock('mastodon/actions/bookmark_folders_typed', async () => {
const actual = await vi.importActual<typeof BookmarkFoldersTypedModule>(
'mastodon/actions/bookmark_folders_typed',
);
const deleteBookmarkFolder = Object.assign(
mocks.deleteBookmarkFolderMock,
actual.deleteBookmarkFolder,
);
return {
...actual,
deleteBookmarkFolder,
};
});
vi.mock('react-router', async () => {
const actual = await vi.importActual<typeof ReactRouterModule>('react-router');
return {
...actual,
useHistory: () => ({ push: mocks.pushMock }),
};
});
describe('<ConfirmDeleteBookmarkFolderModal />', () => {
beforeEach(() => {
mocks.deleteBookmarkFolderMock.mockImplementation(
({ id }: DeleteBookmarkFolderAction['payload']) => ({
type: 'bookmarkFolders/delete',
payload: { id },
}),
);
mocks.dispatchMock.mockImplementation(
(action: DeleteBookmarkFolderDispatchAction) => action,
);
mocks.useAppDispatchMock.mockReturnValue(mocks.dispatchMock);
});
afterEach(() => {
vi.clearAllMocks();
});
it('dispatches delete and navigates back', () => {
const onClose = vi.fn();
render(<ConfirmDeleteBookmarkFolderModal id='42' onClose={onClose} />);
fireEvent.submit(screen.getByRole('button', { name: 'Delete' }).closest('form') as HTMLFormElement,);
expect(mocks.deleteBookmarkFolderMock).toHaveBeenCalledWith({ id: '42' });
expect(mocks.pushMock).toHaveBeenCalledWith('/bookmarks/folders');
});
});

View File

@ -0,0 +1,154 @@
import { Map as ImmutableMap } from 'immutable';
import type { Map as ImmutableMapType } from 'immutable';
import { fireEvent, render, screen, waitFor } from '@/testing/rendering';
import NewBookmarkFolderWrapper from '../new';
import type * as BookmarkFoldersTypedModule from 'mastodon/actions/bookmark_folders_typed';
import type * as ReactRouterDomModule from 'react-router-dom';
import type * as StoreModule from 'mastodon/store';
const folder = { id: '1', title: 'Alpha' };
interface BookmarkFoldersState {
bookmark_folders: ImmutableMapType<string, typeof folder>;
}
interface UpdateBookmarkFolderAction {
type: 'bookmarkFolders/update';
payload: {
id: string;
title: string;
};
}
interface UpdateBookmarkFolderFulfilledAction {
type: 'bookmarkFolders/update/fulfilled';
meta: { requestStatus: 'fulfilled' };
payload: { id: string; title: string };
}
interface FetchBookmarkFolderAction {
type: 'bookmarkFolders/fetchOne';
payload: { id: string };
}
type BookmarkFolderDispatchAction =
| UpdateBookmarkFolderAction
| FetchBookmarkFolderAction;
const mocks = vi.hoisted(() => ({
dispatchMock: vi.fn(),
useAppDispatchMock: vi.fn(),
useAppSelectorMock: vi.fn(),
updateBookmarkFolderMock: vi.fn(),
fetchBookmarkFolderMock: vi.fn(),
}));
vi.mock('mastodon/store', async (importOriginal) => {
const actual: typeof StoreModule = await importOriginal();
return {
...actual,
useAppDispatch: mocks.useAppDispatchMock,
useAppSelector: mocks.useAppSelectorMock,
};
});
vi.mock('@/mastodon/store', async (importOriginal) => {
const actual: typeof StoreModule = await importOriginal();
return {
...actual,
useAppDispatch: mocks.useAppDispatchMock,
useAppSelector: mocks.useAppSelectorMock,
};
});
vi.mock('mastodon/actions/bookmark_folders_typed', async (importOriginal) => {
const actual: typeof BookmarkFoldersTypedModule = await importOriginal();
const updateBookmarkFolder = Object.assign(
mocks.updateBookmarkFolderMock,
actual.updateBookmarkFolder,
);
const fetchBookmarkFolder = Object.assign(
mocks.fetchBookmarkFolderMock,
actual.fetchBookmarkFolder,
);
return {
...actual,
updateBookmarkFolder,
fetchBookmarkFolder,
};
});
vi.mock('react-router-dom', async (importOriginal) => {
const actual: typeof ReactRouterDomModule = await importOriginal();
return {
...actual,
useParams: () => ({ id: '1' }),
};
});
describe('<NewBookmarkFolder (update) />', () => {
beforeEach(() => {
mocks.updateBookmarkFolderMock.mockImplementation(
(payload: UpdateBookmarkFolderAction['payload']) => ({
type: 'bookmarkFolders/update',
payload,
}),
);
mocks.fetchBookmarkFolderMock.mockImplementation(
({ id }: FetchBookmarkFolderAction['payload']) => ({
type: 'bookmarkFolders/fetchOne',
payload: { id },
}),
);
mocks.dispatchMock.mockImplementation(
(action: BookmarkFolderDispatchAction) => {
if (action.type === 'bookmarkFolders/update') {
return Promise.resolve({
type: 'bookmarkFolders/update/fulfilled',
meta: { requestStatus: 'fulfilled' },
payload: { id: action.payload.id, title: action.payload.title },
} satisfies UpdateBookmarkFolderFulfilledAction);
}
return action;
},
);
mocks.useAppDispatchMock.mockReturnValue(mocks.dispatchMock);
mocks.useAppSelectorMock.mockImplementation(
(selector: (state: BookmarkFoldersState) => typeof folder | undefined) =>
selector({
bookmark_folders: ImmutableMap([[folder.id, folder]]),
}),
);
});
afterEach(() => {
vi.clearAllMocks();
});
it('submits an update when editing a folder', async () => {
render(<NewBookmarkFolderWrapper />);
const input = screen.getByDisplayValue('Alpha');
fireEvent.change(input, { target: { value: 'Work' } });
fireEvent.submit(input.closest('form') as HTMLFormElement);
await waitFor(() => {
expect(mocks.updateBookmarkFolderMock).toHaveBeenCalledWith({
id: '1',
title: 'Work',
});
});
});
});

View File

@ -0,0 +1,148 @@
import { useEffect, useMemo, useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Helmet } from '@unhead/react/helmet';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { fetchBookmarkFolders } from 'mastodon/actions/bookmark_folders_typed';
import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import { getOrderedBookmarkFolders } from 'mastodon/selectors/bookmark_folders';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: {
id: 'column.bookmark_folders',
defaultMessage: 'Bookmark folders',
},
create: { id: 'bookmark_folders.create', defaultMessage: 'Create folder' },
edit: { id: 'bookmark_folders.edit', defaultMessage: 'Edit folder' },
delete: { id: 'bookmark_folders.delete', defaultMessage: 'Delete folder' },
more: { id: 'status.more', defaultMessage: 'More' },
});
const FolderItem: React.FC<{
id: string;
title: string;
}> = ({ id, title }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_BOOKMARK_FOLDER',
modalProps: { id },
}),
);
}, [dispatch, id]);
const menu = useMemo(
() => [
{
text: intl.formatMessage(messages.edit),
to: `/bookmarks/folders/${id}/edit`,
},
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='lists__item'>
<Link to={`/bookmarks/folders/${id}`} className='lists__item__title'>
<Icon id='bookmarks' icon={BookmarksIcon} />
<span>{title}</span>
</Link>
<Dropdown
scrollKey='bookmark_folders'
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
const AllBookmarksItem: React.FC = () => {
const intl = useIntl();
return (
<div className='lists__item'>
<Link to='/bookmarks' className='lists__item__title'>
<Icon id='bookmarks' icon={BookmarksIcon} />
<span>
{intl.formatMessage({
id: 'bookmarks.all',
defaultMessage: 'All Bookmarks',
})}
</span>
</Link>
</div>
);
};
const BookmarkFolders: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const folders = useAppSelector((state) => getOrderedBookmarkFolders(state));
useEffect(() => {
void dispatch(fetchBookmarkFolders());
}, [dispatch]);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='bookmarks'
iconComponent={BookmarksIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/bookmarks/folders/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='bookmark_folders'
bindToDocument={!multiColumn}
>
<AllBookmarksItem />
{folders.map((folder) => (
<FolderItem key={folder.id} id={folder.id} title={folder.title} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default BookmarkFolders;

View File

@ -0,0 +1,160 @@
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useParams, useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import { Helmet } from '@unhead/react/helmet';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import {
createBookmarkFolder,
fetchBookmarkFolder,
updateBookmarkFolder,
} from 'mastodon/actions/bookmark_folders_typed';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { TextInputField } from 'mastodon/components/form_fields';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import type { BookmarkFolder } from 'mastodon/models/bookmark_folder';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
edit: { id: 'column.edit_bookmark_folder', defaultMessage: 'Edit folder' },
create: {
id: 'column.create_bookmark_folder',
defaultMessage: 'Create folder',
},
});
const NewBookmarkFolder: React.FC<{
folder?: BookmarkFolder | null;
}> = ({ folder }) => {
const dispatch = useAppDispatch();
const history = useHistory();
const { id, title: initialTitle = '' } = folder ?? {};
const [title, setTitle] = useState(initialTitle);
const [submitting, setSubmitting] = useState(false);
const handleTitleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
setSubmitting(true);
if (id) {
void dispatch(updateBookmarkFolder({ id, title })).then(() => {
setSubmitting(false);
return '';
});
} else {
void dispatch(createBookmarkFolder({ title })).then((result) => {
setSubmitting(false);
if (isFulfilled(result)) {
history.push(`/bookmarks/folders`);
}
return '';
});
}
}, [dispatch, history, id, title]);
return (
<form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'>
<TextInputField
required
maxLength={30}
label={
<FormattedMessage
id='bookmark_folders.folder_name'
defaultMessage='Folder name'
/>
}
value={title}
onChange={handleTitleChange}
id='bookmark_folder_title'
/>
</div>
<div className='actions'>
<button className='button' type='submit'>
{submitting ? (
<LoadingIndicator />
) : id ? (
<FormattedMessage
id='bookmark_folders.save'
defaultMessage='Save'
/>
) : (
<FormattedMessage
id='bookmark_folders.create'
defaultMessage='Create folder'
/>
)}
</button>
</div>
</form>
);
};
const NewBookmarkFolderWrapper: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const folder = useAppSelector((state) =>
id ? state.bookmark_folders.get(id) : undefined,
);
useEffect(() => {
if (id) {
void dispatch(fetchBookmarkFolder({ id }));
}
}, [dispatch, id]);
const isLoading = id && !folder;
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(id ? messages.edit : messages.create)}
>
<ColumnHeader
title={intl.formatMessage(id ? messages.edit : messages.create)}
icon='bookmarks'
iconComponent={BookmarksIcon}
multiColumn={multiColumn}
showBackButton
/>
<div className='scrollable'>
{isLoading ? (
<LoadingIndicator />
) : (
<NewBookmarkFolder folder={folder} />
)}
</div>
<Helmet>
<title>
{intl.formatMessage(id ? messages.edit : messages.create)}
</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default NewBookmarkFolderWrapper;

View File

@ -3,22 +3,36 @@ import { useEffect, useRef, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from '@unhead/react/helmet';
import { Link, useParams } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import { fetchBookmarkFolders } from 'mastodon/actions/bookmark_folders_typed';
import {
fetchBookmarkedStatuses,
expandBookmarkedStatuses,
} from 'mastodon/actions/bookmarks';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
import { getOrderedBookmarkFolders } from 'mastodon/selectors/bookmark_folders';
import { getBookmarkFolderStatusList } from 'mastodon/selectors/statuses';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
allBookmarks: { id: 'bookmarks.all', defaultMessage: 'All Bookmarks' },
createFolder: {
id: 'bookmark_folders.create',
defaultMessage: 'Create folder',
},
});
const Bookmarks: React.FC<{
@ -28,19 +42,38 @@ const Bookmarks: React.FC<{
const intl = useIntl();
const dispatch = useAppDispatch();
const columnRef = useRef<ColumnRef>(null);
const { folderId } = useParams<{ folderId?: string }>();
const statusIds = useAppSelector((state) =>
getStatusList(state, 'bookmarks'),
folderId
? getBookmarkFolderStatusList(state, folderId)
: getStatusList(state, 'bookmarks'),
);
const folders = useAppSelector((state) => getOrderedBookmarkFolders(state));
const isLoading = useAppSelector(
(state) =>
state.status_lists.getIn(['bookmarks', 'isLoading'], true) as boolean,
(folderId
? state.status_lists.getIn(
['bookmark_folders', folderId, 'isLoading'],
true,
)
: state.status_lists.getIn(
['bookmarks', 'isLoading'],
true,
)) as boolean,
);
const hasMore = useAppSelector(
(state) => !!state.status_lists.getIn(['bookmarks', 'next']),
(state) =>
!!(folderId
? state.status_lists.getIn(['bookmark_folders', folderId, 'next'])
: state.status_lists.getIn(['bookmarks', 'next'])),
);
useEffect(() => {
dispatch(fetchBookmarkedStatuses());
dispatch(fetchBookmarkedStatuses(folderId));
}, [dispatch, folderId]);
useEffect(() => {
void dispatch(fetchBookmarkFolders());
}, [dispatch]);
const handlePin = useCallback(() => {
@ -62,13 +95,35 @@ const Bookmarks: React.FC<{
columnRef.current?.scrollTop();
}, []);
const handleDeleteClick = useCallback(() => {
if (folderId) {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_BOOKMARK_FOLDER',
modalProps: { id: folderId },
}),
);
}
}, [dispatch, folderId]);
const handleLoadMore = useCallback(() => {
dispatch(expandBookmarkedStatuses());
}, [dispatch]);
dispatch(expandBookmarkedStatuses(folderId));
}, [dispatch, folderId]);
const pinned = !!columnId;
const currentFolder = folderId
? folders.find((folder) => folder.id === folderId)
: null;
const currentFolderLabel = folderId
? (currentFolder?.title ?? folderId)
: intl.formatMessage(messages.allBookmarks);
const emptyMessage = (
const emptyMessage = folderId ? (
<FormattedMessage
id='empty_column.bookmark_folder'
defaultMessage='No bookmarks in this folder yet.'
/>
) : (
<FormattedMessage
id='empty_column.bookmarked_statuses'
defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here."
@ -79,23 +134,65 @@ const Bookmarks: React.FC<{
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.heading)}
label={currentFolderLabel}
>
<ColumnHeader
icon='bookmarks'
iconComponent={BookmarksIcon}
title={intl.formatMessage(messages.heading)}
title={currentFolderLabel}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
extraButton={
!folderId ? (
<Link
to='/bookmarks/folders/new'
className='column-header__button'
title={intl.formatMessage(messages.createFolder)}
aria-label={intl.formatMessage(messages.createFolder)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
) : null
}
>
{folderId && (
<div className='column-settings'>
<section className='column-header__links'>
<Link
to={`/bookmarks/folders/${folderId}/edit`}
className='text-btn column-header__setting-btn'
>
<Icon id='pencil' icon={EditIcon} />{' '}
<FormattedMessage
id='bookmark_folders.edit'
defaultMessage='Edit folder'
/>
</Link>
<button
type='button'
className='text-btn column-header__setting-btn'
tabIndex={0}
onClick={handleDeleteClick}
>
<Icon id='trash' icon={DeleteIcon} />{' '}
<FormattedMessage
id='bookmark_folders.delete'
defaultMessage='Delete folder'
/>
</button>
</section>
</div>
)}
</ColumnHeader>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmarked_statuses-${columnId}`}
scrollKey={`bookmarked_statuses-${folderId ?? 'all'}-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
@ -105,7 +202,11 @@ const Bookmarks: React.FC<{
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<title>
{folderId
? `${currentFolderLabel} - ${intl.formatMessage(messages.heading)}`
: currentFolderLabel}
</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>

View File

@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
import { fetchBookmarkFolders } from 'mastodon/actions/bookmark_folders_typed';
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
import { getOrderedBookmarkFolders } from 'mastodon/selectors/bookmark_folders';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollapsiblePanel } from './collapsible_panel';
const messages = defineMessages({
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
expand: {
id: 'navigation_panel.expand_bookmark_folders',
defaultMessage: 'Expand bookmark folders',
},
collapse: {
id: 'navigation_panel.collapse_bookmark_folders',
defaultMessage: 'Collapse bookmark folders',
},
});
export const BookmarkFoldersPanel: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const folders = useAppSelector((state) => getOrderedBookmarkFolders(state));
const [loading, setLoading] = useState(true);
const hasFolders = folders.length > 0;
const allBookmarksLink = hasFolders ? (
<ColumnLink
icon='bookmarks'
iconComponent={BookmarksIcon}
activeIconComponent={BookmarksActiveIcon}
text={intl.formatMessage({
id: 'bookmarks.all',
defaultMessage: 'All Bookmarks',
})}
to='/bookmarks'
exact
transparent
/>
) : null;
const folderLinks = hasFolders
? folders.map((folder) => (
<ColumnLink
icon='bookmarks'
key={folder.id}
iconComponent={BookmarksIcon}
activeIconComponent={BookmarksActiveIcon}
text={folder.title}
to={`/bookmarks/folders/${folder.id}`}
transparent
/>
))
: null;
useEffect(() => {
void dispatch(fetchBookmarkFolders()).then(() => {
setLoading(false);
return '';
});
}, [dispatch]);
return (
<CollapsiblePanel
to={hasFolders ? '/bookmarks/folders' : '/bookmarks'}
activePath={
hasFolders ? ['/bookmarks', '/bookmarks/folders'] : '/bookmarks'
}
icon='bookmarks'
iconComponent={BookmarksIcon}
activeIconComponent={BookmarksActiveIcon}
title={intl.formatMessage(messages.bookmarks)}
collapseTitle={intl.formatMessage(messages.collapse)}
expandTitle={intl.formatMessage(messages.expand)}
loading={loading}
>
{allBookmarksLink}
{folderLinks}
</CollapsiblePanel>
);
};

View File

@ -1,4 +1,5 @@
import { useState, useCallback, useId } from 'react';
import { Children, useState, useCallback, useId } from 'react';
import type { ReactNode } from 'react';
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react';
@ -8,8 +9,9 @@ import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
export const CollapsiblePanel: React.FC<{
children: React.ReactNode[];
children: ReactNode;
to: string;
activePath?: string | string[];
title: string;
collapseTitle: string;
expandTitle: string;
@ -20,6 +22,7 @@ export const CollapsiblePanel: React.FC<{
}> = ({
children,
to,
activePath,
icon,
iconComponent,
activeIconComponent,
@ -30,6 +33,7 @@ export const CollapsiblePanel: React.FC<{
}) => {
const [expanded, setExpanded] = useState(false);
const accessibilityId = useId();
const childCount = Children.toArray(children).length;
const handleClick = useCallback(() => {
setExpanded((value) => !value);
@ -41,6 +45,7 @@ export const CollapsiblePanel: React.FC<{
<ColumnLink
transparent
to={to}
activePath={activePath}
icon={icon}
iconComponent={iconComponent}
activeIconComponent={activeIconComponent}
@ -48,7 +53,7 @@ export const CollapsiblePanel: React.FC<{
id={`${accessibilityId}-title`}
/>
{(loading || children.length > 0) && (
{(loading || childCount > 0) && (
<>
<div className='navigation-panel__list-panel__header__sep' />
@ -70,7 +75,7 @@ export const CollapsiblePanel: React.FC<{
)}
</div>
{children.length > 0 && expanded && (
{childCount > 0 && expanded && (
<div
className='navigation-panel__list-panel__items'
role='region'

View File

@ -13,8 +13,6 @@ import { useDrag } from '@use-gesture/react';
import { useAccount } from '@/mastodon/hooks/useAccount';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
import CollectionsActiveIcon from '@/material-icons/400-24px/category-fill.svg?react';
import CollectionsIcon from '@/material-icons/400-24px/category.svg?react';
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
@ -52,6 +50,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { AnnualReportNavItem } from '../annual_report/nav_item';
import { BookmarkFoldersPanel } from './components/bookmark_folders_panel';
import { DisabledAccountBanner } from './components/disabled_account_banner';
import { FollowedTagsPanel } from './components/followed_tags_panel';
import { ListPanel } from './components/list_panel';
@ -79,7 +78,6 @@ const messages = defineMessages({
},
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
collections: {
id: 'navigation_bar.collections',
defaultMessage: 'Collections',
@ -202,13 +200,6 @@ const ProfileCard: React.FC = () => {
);
};
const isFirehoseActive = (
match: unknown,
{ pathname }: { pathname: string },
) => {
return !!match || pathname.startsWith('/public');
};
const MENU_WIDTH = 284;
export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
@ -309,7 +300,7 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
}
icon='globe'
iconComponent={PublicIcon}
isActive={isFirehoseActive}
activePath={['/public', '/public/local', '/public/remote']}
text={intl.formatMessage(
canViewFeed(signedIn, permissions, localLiveFeedAccess) &&
canViewFeed(signedIn, permissions, remoteLiveFeedAccess)
@ -350,16 +341,7 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
text={intl.formatMessage(messages.favourites)}
/>
</li>
<li>
<ColumnLink
transparent
to='/bookmarks'
icon='bookmarks'
iconComponent={BookmarksIcon}
activeIconComponent={BookmarksActiveIcon}
text={intl.formatMessage(messages.bookmarks)}
/>
</li>
<BookmarkFoldersPanel />
<li>
<ColumnLink
transparent

View File

@ -34,6 +34,7 @@ const messages = defineMessages({
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
addToBookmarkFolder: { id: 'status.add_to_bookmark_folder', defaultMessage: 'Add to bookmark folder' },
more: { id: 'status.more', defaultMessage: 'More' },
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
@ -77,6 +78,7 @@ class ActionBar extends PureComponent {
onReblog: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onBookmarkFolder: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onRevokeQuote: PropTypes.func,
onQuotePolicyChange: PropTypes.func,
@ -112,6 +114,10 @@ class ActionBar extends PureComponent {
this.props.onBookmark(this.props.status, e);
};
handleBookmarkFolderClick = () => {
this.props.onBookmarkFolder(this.props.status);
};
handleDeleteClick = () => {
this.props.onDelete(this.props.status);
};
@ -250,6 +256,9 @@ class ActionBar extends PureComponent {
if (signedIn) {
menu.push(null);
menu.push({ text: intl.formatMessage(messages.addToBookmarkFolder), action: this.handleBookmarkFolderClick });
menu.push(null);
if (writtenByMe) {
if (pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });

View File

@ -242,6 +242,13 @@ class Status extends ImmutablePureComponent {
}
};
handleBookmarkFolderClick = (status) => {
this.props.dispatch(openModal({
modalType: 'BOOKMARK_FOLDER_ADDER',
modalProps: { status },
}));
};
handleDeleteClick = (status, withRedraft = false) => {
const { dispatch, history } = this.props;
@ -608,6 +615,7 @@ class Status extends ImmutablePureComponent {
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
onBookmark={this.handleBookmarkClick}
onBookmarkFolder={this.handleBookmarkFolderClick}
onDelete={this.handleDeleteClick}
onRevokeQuote={this.handleRevokeQuoteClick}
onQuotePolicyChange={this.handleQuotePolicyChange}

View File

@ -1,5 +1,5 @@
import classNames from 'classnames';
import { useRouteMatch, NavLink } from 'react-router-dom';
import { matchPath, useLocation, NavLink } from 'react-router-dom';
import { Icon } from 'mastodon/components/icon';
import type { IconProp } from 'mastodon/components/icon';
@ -9,13 +9,14 @@ export const ColumnLink: React.FC<{
iconComponent?: IconProp;
activeIcon?: React.ReactNode;
activeIconComponent?: IconProp;
isActive?: (match: unknown, location: { pathname: string }) => boolean;
text: string;
to?: string;
activePath?: string | string[];
href?: string;
method?: string;
badge?: React.ReactNode;
transparent?: boolean;
exact?: boolean;
className?: string;
id?: string;
}> = ({
@ -25,15 +26,22 @@ export const ColumnLink: React.FC<{
activeIconComponent,
text,
to,
activePath,
href,
method,
badge,
transparent,
exact,
...other
}) => {
const match = useRouteMatch(to ?? '');
const location = useLocation();
const match = to
? matchPath(location.pathname, { path: activePath ?? to, exact })
: null;
const active = !!match;
const className = classNames('column-link', {
'column-link--transparent': transparent,
active,
});
const badgeElement =
typeof badge !== 'undefined' ? (
@ -59,8 +67,6 @@ export const ColumnLink: React.FC<{
) : (
iconElement
));
const active = !!match;
if (href) {
return (
<a href={href} className={className} data-method={method} {...other}>
@ -71,7 +77,7 @@ export const ColumnLink: React.FC<{
);
} else if (to) {
return (
<NavLink to={to} className={className} {...other}>
<NavLink to={to} className={className} exact={exact} {...other}>
{active ? activeIconElement : iconElement}
<span>{text}</span>
{badgeElement}

View File

@ -0,0 +1,52 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router';
import { deleteBookmarkFolder } from 'mastodon/actions/bookmark_folders_typed';
import { useAppDispatch } from 'mastodon/store';
import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
deleteBookmarkFolderTitle: {
id: 'confirmations.delete_bookmark_folder.title',
defaultMessage: 'Delete bookmark folder?',
},
deleteBookmarkFolderMessage: {
id: 'confirmations.delete_bookmark_folder.message',
defaultMessage:
'Are you sure you want to permanently delete this bookmark folder?',
},
deleteBookmarkFolderConfirm: {
id: 'confirmations.delete_bookmark_folder.confirm',
defaultMessage: 'Delete',
},
});
export const ConfirmDeleteBookmarkFolderModal: React.FC<
{
id: string;
} & BaseConfirmationModalProps
> = ({ id, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const history = useHistory();
const onConfirm = useCallback(() => {
void dispatch(deleteBookmarkFolder({ id }));
history.push('/bookmarks/folders');
}, [dispatch, history, id]);
return (
<ConfirmationModal
title={intl.formatMessage(messages.deleteBookmarkFolderTitle)}
message={intl.formatMessage(messages.deleteBookmarkFolderMessage)}
confirm={intl.formatMessage(messages.deleteBookmarkFolderConfirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};

View File

@ -2,6 +2,7 @@ export type { BaseConfirmationModalProps } from './confirmation_modal';
export { ConfirmationModal } from './confirmation_modal';
export { ConfirmDeleteStatusModal } from './delete_status';
export { ConfirmDeleteListModal } from './delete_list';
export { ConfirmDeleteBookmarkFolderModal } from './delete_bookmark_folder';
export { ConfirmDeleteCollectionModal } from './delete_collection';
export {
ConfirmReplyModal,

View File

@ -13,6 +13,7 @@ import {
ReportCollectionModal,
EmbedModal,
ListAdder,
BookmarkFolderAdder,
CompareHistoryModal,
FilterModal,
InteractionModal,
@ -30,6 +31,7 @@ import {
ConfirmationModal,
ConfirmDeleteStatusModal,
ConfirmDeleteListModal,
ConfirmDeleteBookmarkFolderModal,
ConfirmDeleteCollectionModal,
ConfirmReplyModal,
ConfirmEditStatusModal,
@ -59,6 +61,7 @@ export const MODAL_COMPONENTS = {
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
'CONFIRM_DELETE_STATUS': () => Promise.resolve({ default: ConfirmDeleteStatusModal }),
'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }),
'CONFIRM_DELETE_BOOKMARK_FOLDER': () => Promise.resolve({ default: ConfirmDeleteBookmarkFolderModal }),
'CONFIRM_DELETE_COLLECTION': () => Promise.resolve({ default: ConfirmDeleteCollectionModal }),
'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }),
'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }),
@ -84,6 +87,7 @@ export const MODAL_COMPONENTS = {
'EMBED': EmbedModal,
'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }),
'LIST_ADDER': ListAdder,
'BOOKMARK_FOLDER_ADDER': BookmarkFolderAdder,
'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal,
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,

View File

@ -59,6 +59,8 @@ import {
FollowRequests,
FavouritedStatuses,
BookmarkedStatuses,
BookmarkFolders,
BookmarkFolderEdit,
FollowedTags,
LinkTimeline,
ListTimeline,
@ -219,7 +221,11 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} exact />
<WrappedRoute path='/bookmarks/folders/new' component={BookmarkFolderEdit} content={children} />
<WrappedRoute path='/bookmarks/folders/:id/edit' component={BookmarkFolderEdit} content={children} />
<WrappedRoute path='/bookmarks/folders' component={BookmarkFolders} content={children} exact />
<WrappedRoute path='/bookmarks/folders/:folderId' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/start/profile' exact component={OnboardingProfile} content={children} />

View File

@ -134,6 +134,14 @@ export function BookmarkedStatuses () {
return import('../../bookmarked_statuses');
}
export function BookmarkFolders () {
return import('../../bookmark_folders');
}
export function BookmarkFolderEdit () {
return import('../../bookmark_folders/new');
}
export function Blocks () {
return import('../../blocks');
}
@ -187,6 +195,10 @@ export function ListAdder () {
return import('../../list_adder');
}
export function BookmarkFolderAdder () {
return import('../../bookmark_folder_adder');
}
export function Tesseract () {
return import('tesseract.js');
}

View File

@ -337,6 +337,15 @@
"block_modal.they_will_know": "They can see that they're blocked.",
"block_modal.title": "Block user?",
"block_modal.you_wont_see_mentions": "You won't see posts from others that mention them.",
"bookmark_folders.add_to_folder": "Add to bookmark folder",
"bookmark_folders.create": "Create folder",
"bookmark_folders.delete": "Delete folder",
"bookmark_folders.edit": "Edit folder",
"bookmark_folders.folder_name": "Folder name",
"bookmark_folders.new_folder_name": "New folder name",
"bookmark_folders.no_folder": "No folder",
"bookmark_folders.save": "Save",
"bookmarks.all": "All bookmarks",
"boost_modal.combo": "You can press {combo} to skip this next time",
"boost_modal.reblog": "Boost post?",
"boost_modal.undo_reblog": "Unboost post?",
@ -448,12 +457,15 @@
"collections.visibility_unlisted_hint": "Visible to anyone with a link. Hidden from search results and recommendations.",
"column.about": "About",
"column.blocks": "Blocked users",
"column.bookmark_folders": "Bookmark folders",
"column.bookmarks": "Bookmarks",
"column.community": "Local timeline",
"column.create_bookmark_folder": "Create folder",
"column.create_list": "Create list",
"column.direct": "Private mentions",
"column.directory": "Browse profiles",
"column.domain_blocks": "Blocked domains",
"column.edit_bookmark_folder": "Edit folder",
"column.edit_list": "Edit list",
"column.favourites": "Favorites",
"column.firehose": "Live feeds",
@ -515,6 +527,9 @@
"confirmations.delete.confirm": "Delete",
"confirmations.delete.message": "Are you sure you want to delete this post?",
"confirmations.delete.title": "Delete post?",
"confirmations.delete_bookmark_folder.confirm": "Delete",
"confirmations.delete_bookmark_folder.message": "Are you sure you want to permanently delete this bookmark folder?",
"confirmations.delete_bookmark_folder.title": "Delete bookmark folder?",
"confirmations.delete_collection.confirm": "Delete",
"confirmations.delete_collection.message": "This action cannot be undone.",
"confirmations.delete_collection.title": "Delete \"{name}\"?",
@ -645,6 +660,7 @@
"empty_column.account_timeline": "No posts here!",
"empty_column.account_unavailable": "Profile unavailable",
"empty_column.blocks": "You haven't blocked any users yet.",
"empty_column.bookmark_folder": "No bookmarks in this folder yet.",
"empty_column.bookmarked_statuses": "You don't have any bookmarked posts yet. When you bookmark one, it will show up here.",
"empty_column.collections": "{acct} has not created any collections yet.",
"empty_column.collections.featured_in": "You have not been added to any collections yet.",
@ -928,8 +944,10 @@
"navigation_bar.privacy_and_reach": "Privacy and reach",
"navigation_bar.search": "Search",
"navigation_bar.search_trends": "Search / Trending",
"navigation_panel.collapse_bookmark_folders": "Collapse bookmark folders",
"navigation_panel.collapse_followed_tags": "Collapse followed hashtags menu",
"navigation_panel.collapse_lists": "Collapse list menu",
"navigation_panel.expand_bookmark_folders": "Expand bookmark folders",
"navigation_panel.expand_followed_tags": "Expand followed hashtags menu",
"navigation_panel.expand_lists": "Expand list menu",
"not_signed_in_indicator.not_signed_in": "You need to login to access this resource.",
@ -1220,6 +1238,7 @@
"skip_links.hotkey": "<span>Hotkey</span> {hotkey}",
"skip_links.skip_to_content": "Skip to main content",
"skip_links.skip_to_navigation": "Skip to main navigation",
"status.add_to_bookmark_folder": "Add to bookmark folder",
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_domain": "Open moderation interface for {domain}",
"status.admin_status": "Open this post in the moderation interface",

View File

@ -0,0 +1,16 @@
import type { RecordOf } from 'immutable';
import { Record } from 'immutable';
import type { ApiBookmarkFolderJSON } from 'mastodon/api_types/bookmark_folders';
type BookmarkFolderShape = Required<ApiBookmarkFolderJSON>;
export type BookmarkFolder = RecordOf<BookmarkFolderShape>;
const BookmarkFolderFactory = Record<BookmarkFolderShape>({
id: '',
title: '',
});
export function createBookmarkFolder(attributes: Partial<BookmarkFolderShape>) {
return BookmarkFolderFactory(attributes);
}

View File

@ -0,0 +1,50 @@
import type { Reducer } from '@reduxjs/toolkit';
import { Map as ImmutableMap } from 'immutable';
import {
createBookmarkFolder,
deleteBookmarkFolder,
fetchBookmarkFolder,
fetchBookmarkFolders,
updateBookmarkFolder,
} from 'mastodon/actions/bookmark_folders_typed';
import type { ApiBookmarkFolderJSON } from 'mastodon/api_types/bookmark_folders';
import { createBookmarkFolder as createBookmarkFolderFromJSON } from 'mastodon/models/bookmark_folder';
import type { BookmarkFolder } from 'mastodon/models/bookmark_folder';
const initialState = ImmutableMap<string, BookmarkFolder | null>();
type State = typeof initialState;
const normalizeFolder = (state: State, folder: ApiBookmarkFolderJSON) =>
state.set(folder.id, createBookmarkFolderFromJSON(folder));
const normalizeFolders = (state: State, folders: ApiBookmarkFolderJSON[]) => {
folders.forEach((folder) => {
state = normalizeFolder(state, folder);
});
return state;
};
export const bookmarkFoldersReducer: Reducer<State> = (
state = initialState,
action,
) => {
if (
createBookmarkFolder.fulfilled.match(action) ||
updateBookmarkFolder.fulfilled.match(action)
) {
return normalizeFolder(state, action.payload);
}
if (fetchBookmarkFolder.fulfilled.match(action))
return normalizeFolder(state, action.payload);
if (fetchBookmarkFolders.fulfilled.match(action))
return normalizeFolders(state, action.payload);
if (deleteBookmarkFolder.fulfilled.match(action))
return state.delete(action.payload.id);
return state;
};

View File

@ -8,6 +8,7 @@ import { accountsFamiliarFollowersReducer } from './accounts_familiar_followers'
import { accountsMapReducer } from './accounts_map';
import { alertsReducer } from './alerts';
import announcements from './announcements';
import { bookmarkFoldersReducer } from './bookmark_folders';
import { composeReducer } from './compose';
import { contextsReducer } from './contexts';
import conversations from './conversations';
@ -51,6 +52,7 @@ const reducers = {
modal: modalReducer,
user_lists,
status_lists,
bookmark_folders: bookmarkFoldersReducer,
accounts: accountsReducer,
accounts_map: accountsMapReducer,
accounts_familiar_followers: accountsFamiliarFollowersReducer,

View File

@ -8,9 +8,15 @@ import {
BOOKMARKED_STATUSES_FETCH_REQUEST,
BOOKMARKED_STATUSES_FETCH_SUCCESS,
BOOKMARKED_STATUSES_FETCH_FAIL,
BOOKMARK_FOLDER_STATUSES_FETCH_REQUEST,
BOOKMARK_FOLDER_STATUSES_FETCH_SUCCESS,
BOOKMARK_FOLDER_STATUSES_FETCH_FAIL,
BOOKMARKED_STATUSES_EXPAND_REQUEST,
BOOKMARKED_STATUSES_EXPAND_SUCCESS,
BOOKMARKED_STATUSES_EXPAND_FAIL,
BOOKMARK_FOLDER_STATUSES_EXPAND_REQUEST,
BOOKMARK_FOLDER_STATUSES_EXPAND_SUCCESS,
BOOKMARK_FOLDER_STATUSES_EXPAND_FAIL,
} from '../actions/bookmarks';
import {
FAVOURITED_STATUSES_FETCH_REQUEST,
@ -31,6 +37,9 @@ import {
import {
fetchQuotes
} from '../actions/interactions_typed';
import {
deleteBookmarkFolder
} from '../actions/bookmark_folders_typed';
import {
PINNED_STATUSES_FETCH_SUCCESS,
} from '../actions/pin_statuses';
@ -54,6 +63,7 @@ const initialState = ImmutableMap({
loaded: false,
items: ImmutableOrderedSet(),
}),
bookmark_folders: ImmutableMap(),
pins: ImmutableMap({
next: null,
loaded: false,
@ -72,6 +82,13 @@ const initialState = ImmutableMap({
}),
});
const defaultListState = ImmutableMap({
next: null,
loaded: false,
items: ImmutableOrderedSet(),
isLoading: false,
});
const normalizeList = (state, listType, statuses, next) => {
return state.update(listType, listMap => listMap.withMutations(map => {
map.set('next', next);
@ -103,6 +120,72 @@ const removeOneFromList = (state, listType, status) => {
return state.updateIn([listType, 'items'], (list) => list.delete(status.get('id')));
};
const updateBookmarkFolderList = (state, folderId, updater) => (
state.update('bookmark_folders', (listMap) => (
listMap.update(folderId, defaultListState, updater)
))
);
const normalizeFolderList = (state, folderId, statuses, next) => (
updateBookmarkFolderList(state, folderId, listMap => listMap.withMutations(map => {
map.set('next', next);
map.set('loaded', true);
map.set('isLoading', false);
map.set('items', ImmutableOrderedSet(statuses.map(item => item.id)));
}))
);
const appendFolderList = (state, folderId, statuses, next) => (
updateBookmarkFolderList(state, folderId, listMap => listMap.withMutations(map => {
map.set('next', next);
map.set('isLoading', false);
map.set('items', map.get('items').union(statuses.map(item => item.id)));
}))
);
const prependOneToFolderList = (state, folderId, status) => (
updateBookmarkFolderList(state, folderId, listMap => (
listMap.update('items', (list) => {
if (list.includes(status.get('id'))) {
return list;
} else {
return ImmutableOrderedSet([status.get('id')]).union(list);
}
})
))
);
const removeOneFromFolderList = (state, folderId, status) => (
updateBookmarkFolderList(state, folderId, listMap => (
listMap.update('items', (list) => list.delete(status.get('id')))
))
);
const handleBookmarkSuccess = (state, action) => {
const previousFolderId = action.status.get('bookmark_folder_id');
const nextFolderId = action.response?.bookmark_folder_id ?? null;
let nextState = prependOneToList(state, 'bookmarks', action.status);
if (previousFolderId && previousFolderId !== nextFolderId)
nextState = removeOneFromFolderList(nextState, previousFolderId, action.status);
if (nextFolderId)
nextState = prependOneToFolderList(nextState, nextFolderId, action.status);
return nextState;
};
const handleUnbookmarkSuccess = (state, action) => {
const previousFolderId = action.status.get('bookmark_folder_id');
let nextState = removeOneFromList(state, 'bookmarks', action.status);
if (previousFolderId)
nextState = removeOneFromFolderList(nextState, previousFolderId, action.status);
return nextState;
};
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
export default function statusLists(state = initialState, action) {
switch(action.type) {
@ -119,13 +202,23 @@ export default function statusLists(state = initialState, action) {
case BOOKMARKED_STATUSES_FETCH_REQUEST:
case BOOKMARKED_STATUSES_EXPAND_REQUEST:
return state.setIn(['bookmarks', 'isLoading'], true);
case BOOKMARK_FOLDER_STATUSES_FETCH_REQUEST:
case BOOKMARK_FOLDER_STATUSES_EXPAND_REQUEST:
return updateBookmarkFolderList(state, action.folderId, listMap => listMap.set('isLoading', true));
case BOOKMARKED_STATUSES_FETCH_FAIL:
case BOOKMARKED_STATUSES_EXPAND_FAIL:
return state.setIn(['bookmarks', 'isLoading'], false);
case BOOKMARK_FOLDER_STATUSES_FETCH_FAIL:
case BOOKMARK_FOLDER_STATUSES_EXPAND_FAIL:
return updateBookmarkFolderList(state, action.folderId, listMap => listMap.set('isLoading', false));
case BOOKMARKED_STATUSES_FETCH_SUCCESS:
return normalizeList(state, 'bookmarks', action.statuses, action.next);
case BOOKMARK_FOLDER_STATUSES_FETCH_SUCCESS:
return normalizeFolderList(state, action.folderId, action.statuses, action.next);
case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
return appendToList(state, 'bookmarks', action.statuses, action.next);
case BOOKMARK_FOLDER_STATUSES_EXPAND_SUCCESS:
return appendFolderList(state, action.folderId, action.statuses, action.next);
case TRENDS_STATUSES_FETCH_REQUEST:
case TRENDS_STATUSES_EXPAND_REQUEST:
return state.setIn(['trending', 'isLoading'], true);
@ -141,9 +234,9 @@ export default function statusLists(state = initialState, action) {
case UNFAVOURITE_SUCCESS:
return removeOneFromList(state, 'favourites', action.status);
case BOOKMARK_SUCCESS:
return prependOneToList(state, 'bookmarks', action.status);
return handleBookmarkSuccess(state, action);
case UNBOOKMARK_SUCCESS:
return removeOneFromList(state, 'bookmarks', action.status);
return handleUnbookmarkSuccess(state, action);
case PINNED_STATUSES_FETCH_SUCCESS:
return normalizeList(state, 'pins', action.statuses, action.next);
case PIN_SUCCESS:
@ -154,7 +247,9 @@ export default function statusLists(state = initialState, action) {
case muteAccountSuccess.type:
return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.payload.statuses.getIn([statusId, 'account']) === action.payload.relationship.id));
default:
if (fetchQuotes.fulfilled.match(action))
if (deleteBookmarkFolder.fulfilled.match(action))
return state.update('bookmark_folders', map => map.delete(action.payload.id));
else if (fetchQuotes.fulfilled.match(action))
return normalizeList(state, 'quotes', action.payload.statuses, action.payload.next).set('statusId', action.meta.arg.statusId);
else if (fetchQuotes.pending.match(action))
return state.setIn(['quotes', 'isLoading'], true).setIn(['quotes', 'statusId'], action.meta.arg.statusId);

View File

@ -0,0 +1,17 @@
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import type { BookmarkFolder } from 'mastodon/models/bookmark_folder';
import { createAppSelector } from 'mastodon/store';
const getBookmarkFolders = createAppSelector(
[(state) => state.bookmark_folders],
(
folders: ImmutableMap<string, BookmarkFolder | null>,
): ImmutableList<BookmarkFolder> =>
folders.toList().filter((item): item is BookmarkFolder => !!item),
);
export const getOrderedBookmarkFolders = createAppSelector(
[(state) => getBookmarkFolders(state)],
(folders) => folders.sort((a, b) => a.title.localeCompare(b.title)).toArray(),
);

View File

@ -1,5 +1,5 @@
import { createSelector } from '@reduxjs/toolkit';
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import type { RootState } from 'mastodon/store';
@ -13,3 +13,15 @@ export const getStatusList = createSelector(
],
(items) => items.toList(),
);
// Per-folder bookmark list selector
export const getBookmarkFolderStatusList = createSelector(
[
(state: RootState, folderId: string) =>
state.status_lists.getIn(
['bookmark_folders', folderId, 'items'],
ImmutableOrderedSet<string>(),
) as ImmutableOrderedSet<string>,
],
(items) => items.toList(),
);

View File

@ -34,9 +34,10 @@ class StatusCacheHydrator
def hydrate_reblog_payload(empty_payload, account, nested: false)
empty_payload.tap do |payload|
payload[:muted] = false
payload[:bookmarked] = false
payload[:pinned] = false if @status.account_id == account.id
payload[:muted] = false
payload[:bookmarked] = false
payload[:bookmark_folder_id] = nil
payload[:pinned] = false if @status.account_id == account.id
# If the reblogged status is being delivered to the author who disabled the display of the application
# used to create the status, we need to hydrate it here too
@ -53,12 +54,13 @@ class StatusCacheHydrator
end
def fill_status_payload(payload, status, account, nested: false, fresh: true)
payload[:favourited] = Favourite.exists?(account_id: account.id, status_id: status.id)
payload[:reblogged] = Status.exists?(account_id: account.id, reblog_of_id: status.id)
payload[:muted] = ConversationMute.exists?(account_id: account.id, conversation_id: status.conversation_id)
payload[:bookmarked] = Bookmark.exists?(account_id: account.id, status_id: status.id)
payload[:pinned] = StatusPin.exists?(account_id: account.id, status_id: status.id) if status.account_id == account.id
payload[:filtered] = mapped_applied_custom_filter(account, status)
payload[:favourited] = Favourite.exists?(account_id: account.id, status_id: status.id)
payload[:reblogged] = Status.exists?(account_id: account.id, reblog_of_id: status.id)
payload[:muted] = ConversationMute.exists?(account_id: account.id, conversation_id: status.conversation_id)
payload[:bookmarked] = Bookmark.exists?(account_id: account.id, status_id: status.id)
payload[:bookmark_folder_id] = payload[:bookmarked] ? Bookmark.where(account_id: account.id, status_id: status.id).pick(:folder_id)&.to_s : nil
payload[:pinned] = StatusPin.exists?(account_id: account.id, status_id: status.id) if status.account_id == account.id
payload[:filtered] = mapped_applied_custom_filter(account, status)
payload[:quote_approval][:current_user] = status.quote_policy_for_account(account) if payload[:quote_approval]
payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account, nested:) if payload[:quote]

View File

@ -8,6 +8,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint(8) not null
# folder_id :bigint(8)
# status_id :bigint(8) not null
#
@ -18,6 +19,7 @@ class Bookmark < ApplicationRecord
belongs_to :account, inverse_of: :bookmarks
belongs_to :status, inverse_of: :bookmarks
belongs_to :bookmark_folder, foreign_key: :folder_id, optional: true, inverse_of: :bookmarks
validates :status_id, uniqueness: { scope: :account_id }

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: bookmark_folders
#
# id :bigint(8) not null, primary key
# title :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint(8) not null
#
class BookmarkFolder < ApplicationRecord
include Paginable
PER_ACCOUNT_LIMIT = 50
TITLE_LENGTH_LIMIT = 256
belongs_to :account
has_many :bookmarks, foreign_key: 'folder_id', dependent: :nullify, inverse_of: :bookmark_folder
validates :title, presence: true, length: { maximum: TITLE_LENGTH_LIMIT }
validate :validate_account_folders_limit, on: :create
private
def validate_account_folders_limit
errors.add(:base, I18n.t('bookmark_folders.errors.limit')) if account.bookmark_folders.count >= PER_ACCOUNT_LIMIT
end
end

View File

@ -15,6 +15,7 @@ module Account::Associations
has_many :action_logs, class_name: 'Admin::ActionLog'
has_many :aliases, class_name: 'AccountAlias'
has_many :bookmarks
has_many :bookmark_folders
has_many :collections
has_many :collection_items
has_many :curated_collection_items, through: :collections, class_name: 'CollectionItem', source: :collection_items

View File

@ -4,7 +4,8 @@ class StatusRelationshipsPresenter
PINNABLE_VISIBILITIES = %w(public unlisted private).freeze
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
:bookmarks_map, :filters_map, :attributes_map
:bookmark_folders_map, :bookmarks_map, :filters_map,
:attributes_map
def initialize(statuses, current_account_id = nil, **options)
@current_account_id = current_account_id
@ -15,13 +16,14 @@ class StatusRelationshipsPresenter
if current_account_id.nil?
@preloaded_account_relations = {}
@filters_map = {}
@reblogs_map = {}
@favourites_map = {}
@bookmarks_map = {}
@mutes_map = {}
@pins_map = {}
@attributes_map = {}
@filters_map = {}
@reblogs_map = {}
@favourites_map = {}
@bookmarks_map = {}
@bookmark_folders_map = {}
@mutes_map = {}
@pins_map = {}
@attributes_map = {}
else
@preloaded_account_relations = nil
@ -33,7 +35,10 @@ class StatusRelationshipsPresenter
@filters_map = build_filters_map(statuses.flat_map { |s| [s, s.proper.quote&.quoted_status] }.compact.uniq, current_account_id).merge(options[:filters_map] || {})
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
@bookmark_folders_map = Bookmark.where(account_id: current_account_id, status_id: status_ids).pluck(:status_id, :folder_id).to_h { |id, folder_id| [id, folder_id] }.merge(options[:bookmark_folders_map] || {})
@mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
@attributes_map = options[:attributes_map] || {}

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::BookmarkFolderSerializer < ActiveModel::Serializer
attributes :id, :title
def id
object.id.to_s
end
end

View File

@ -14,6 +14,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
attribute :reblogged, if: :current_user?
attribute :muted, if: :current_user?
attribute :bookmarked, if: :current_user?
attribute :bookmark_folder_id, if: :current_user?
attribute :pinned, if: :pinnable?
has_many :filtered, serializer: REST::FilterResultSerializer, if: :current_user?
@ -136,6 +137,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
def bookmark_folder_id
if relationships
relationships.bookmark_folders_map[object.id]&.to_s
else
current_user&.account&.bookmarks&.find_by(status_id: object.id)&.folder_id&.to_s
end
end
def pinned
if relationships
relationships.pins_map[object.id] || false

View File

@ -343,6 +343,9 @@ en:
unpublish: Unpublish
unpublished_msg: Announcement successfully unpublished!
updated_msg: Announcement successfully updated!
bookmark_folders:
errors:
limit: You have reached the maximum number of bookmark folders
collections:
accounts: Accounts
back_to_account: Back to account page

View File

@ -271,6 +271,16 @@ namespace :api, format: false do
resources :votes, only: :create, module: :polls
end
resources :bookmark_folders, only: [:index, :show, :create, :update, :destroy]
resources :bookmarks, only: [:index] do
collection do
scope module: :bookmark_folders, controller: :bookmarks do
get 'folders/:bookmark_folder_id', action: :index, as: :folder
end
end
end
namespace :push do
resource :subscription, only: [:create, :show, :update, :destroy]
end

View File

@ -6,7 +6,7 @@
%w(
/blocks
/bookmarks
/bookmarks/(*any)
/collections/(*any)
/conversations
/deck/(*any)

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreateBookmarkFolders < ActiveRecord::Migration[8.1]
def change
create_table :bookmark_folders do |t|
t.references :account, null: false, foreign_key: { on_delete: :cascade }
t.string :title, null: false, default: ''
t.timestamps
end
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddFolderToBookmarks < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
def change
add_reference :bookmarks, :folder, null: true, index: { algorithm: :concurrently }
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddForeignKeyToBookmarksFolders < ActiveRecord::Migration[8.1]
def change
add_foreign_key :bookmarks, :bookmark_folders, column: :folder_id, on_delete: :nullify, validate: false
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class ValidateForeignKeyOnBookmarksFolders < ActiveRecord::Migration[8.1]
def change
validate_foreign_key :bookmarks, :bookmark_folders, column: :folder_id
end
end

View File

@ -315,12 +315,22 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
t.index ["target_account_id"], name: "index_blocks_on_target_account_id"
end
create_table "bookmark_folders", force: :cascade do |t|
t.bigint "account_id"
t.datetime "created_at", null: false
t.string "title", default: "", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_bookmark_folders_on_account_id"
end
create_table "bookmarks", force: :cascade do |t|
t.bigint "account_id", null: false
t.datetime "created_at", precision: nil, null: false
t.bigint "folder_id"
t.bigint "status_id", null: false
t.datetime "updated_at", precision: nil, null: false
t.index ["account_id", "status_id"], name: "index_bookmarks_on_account_id_and_status_id", unique: true
t.index ["folder_id"], name: "index_bookmarks_on_folder_id"
t.index ["status_id"], name: "index_bookmarks_on_status_id"
end
@ -1483,7 +1493,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
add_foreign_key "backups", "users", on_delete: :nullify
add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade
add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
add_foreign_key "bookmark_folders", "accounts", on_delete: :cascade
add_foreign_key "bookmarks", "accounts", on_delete: :cascade
add_foreign_key "bookmarks", "bookmark_folders", column: "folder_id", on_delete: :nullify
add_foreign_key "bookmarks", "statuses", on_delete: :cascade
add_foreign_key "bulk_import_rows", "bulk_imports", on_delete: :cascade
add_foreign_key "bulk_imports", "accounts", on_delete: :cascade
@ -1623,9 +1635,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true
create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL
SELECT account_id,
sum(rank) AS rank,
array_agg(reason) AS reason
SELECT t0.account_id,
sum(t0.rank) AS rank,
array_agg(t0.reason) AS reason
FROM ( SELECT account_summaries.account_id,
((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank,
'most_followed'::text AS reason
@ -1649,8 +1661,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
WHERE (follow_recommendation_suppressions.account_id = statuses.account_id)))))
GROUP BY account_summaries.account_id
HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0
GROUP BY account_id
ORDER BY (sum(rank)) DESC;
GROUP BY t0.account_id
ORDER BY (sum(t0.rank)) DESC;
SQL
add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true
@ -1680,9 +1692,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true
create_view "user_ips", sql_definition: <<-SQL
SELECT user_id,
ip,
max(used_at) AS used_at
SELECT t0.user_id,
t0.ip,
max(t0.used_at) AS used_at
FROM ( SELECT users.id AS user_id,
users.sign_up_ip AS ip,
users.created_at AS used_at
@ -1699,6 +1711,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
login_activities.created_at
FROM login_activities
WHERE (login_activities.success = true)) t0
GROUP BY user_id, ip;
GROUP BY t0.user_id, t0.ip;
SQL
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
Fabricator(:bookmark_folder) do
account { Fabricate.build(:account) }
title 'MyString'
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe BookmarkFolder do
describe 'Associations' do
it { is_expected.to have_many(:bookmarks).with_foreign_key('folder_id').dependent(:nullify) }
end
describe 'Validations' do
subject { Fabricate.build :bookmark_folder }
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_length_of(:title).is_at_most(described_class::TITLE_LENGTH_LIMIT) }
context 'when account has hit max folder limit' do
let(:account) { Fabricate :account }
before do
stub_const 'BookmarkFolder::PER_ACCOUNT_LIMIT', 1
Fabricate(:bookmark_folder, account: account)
end
context 'when creating a new folder' do
it { is_expected.to_not allow_value(account).for(:account).against(:base).with_message(I18n.t('bookmark_folders.errors.limit')) }
end
context 'when updating an existing folder' do
before { subject.save(validate: false) }
it { is_expected.to allow_value(account).for(:account).against(:base) }
end
end
end
end

View File

@ -6,6 +6,7 @@ RSpec.describe Bookmark do
describe 'Associations' do
it { is_expected.to belong_to(:account).required }
it { is_expected.to belong_to(:status).required }
it { is_expected.to belong_to(:bookmark_folder).with_foreign_key(:folder_id).optional.inverse_of(:bookmarks) }
end
describe 'Validations' do

View File

@ -0,0 +1,103 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'BookmarkFolders' do
describe 'GET /api/v1/bookmark_folders' do
subject do
get '/api/v1/bookmark_folders', headers: headers, params: params
end
include_context 'with API authentication', oauth_scopes: 'read:bookmarks'
let(:params) { {} }
let!(:bookmark_folders) { Fabricate.times(2, :bookmark_folder, account: user.account) }
let(:expected_response) do
bookmark_folders.map do |folder|
a_hash_including(id: folder.id.to_s, title: folder.title)
end
end
it_behaves_like 'forbidden for wrong scope', 'write'
it 'returns http success and the bookmark folders' do
subject
expect(response).to have_http_status(200)
expect(response.content_type).to start_with('application/json')
expect(response.parsed_body).to match_array(expected_response)
end
context 'with an invalid authorization header' do
let(:headers) { { 'Authorization' => 'Bearer token_false' } }
it 'returns http unauthorized' do
subject
expect(response).to have_http_status(401)
expect(response.content_type).to start_with('application/json')
end
end
end
describe 'POST /api/v1/bookmark_folders' do
subject do
post '/api/v1/bookmark_folders', headers: headers, params: params
end
include_context 'with API authentication', oauth_scopes: 'write:bookmarks'
let(:params) { { title: 'New Folder' } }
it_behaves_like 'forbidden for wrong scope', 'read'
it 'returns http success and creates the folder' do
expect { subject }.to change(user.account.bookmark_folders, :count).by(1)
expect(response).to have_http_status(200)
expect(response.content_type).to start_with('application/json')
expect(response.parsed_body).to include('title' => 'New Folder')
end
end
describe 'PUT /api/v1/bookmark_folders/:id' do
subject do
put "/api/v1/bookmark_folders/#{folder.id}", headers: headers, params: params
end
include_context 'with API authentication', oauth_scopes: 'write:bookmarks'
let!(:folder) { Fabricate(:bookmark_folder, account: user.account, title: 'Old Name') }
let(:params) { { title: 'New Name' } }
it_behaves_like 'forbidden for wrong scope', 'read'
it 'returns http success and updates the folder' do
subject
expect(response).to have_http_status(200)
expect(folder.reload.title).to eq('New Name')
expect(response.parsed_body).to include('title' => 'New Name')
end
end
describe 'DELETE /api/v1/bookmark_folders/:id' do
subject do
delete "/api/v1/bookmark_folders/#{folder.id}", headers: headers
end
include_context 'with API authentication', oauth_scopes: 'write:bookmarks'
let!(:folder) { Fabricate(:bookmark_folder, account: user.account) }
it_behaves_like 'forbidden for wrong scope', 'read'
it 'returns http success and deletes the folder' do
expect { subject }.to change(user.account.bookmark_folders, :count).by(-1)
expect(response).to have_http_status(200)
expect(response.parsed_body).to be_empty
end
end
end