diff --git a/app/controllers/api/v1/bookmark_folders/bookmarks_controller.rb b/app/controllers/api/v1/bookmark_folders/bookmarks_controller.rb new file mode 100644 index 00000000000..a4cfaed9c29 --- /dev/null +++ b/app/controllers/api/v1/bookmark_folders/bookmarks_controller.rb @@ -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 diff --git a/app/controllers/api/v1/bookmark_folders_controller.rb b/app/controllers/api/v1/bookmark_folders_controller.rb new file mode 100644 index 00000000000..5e1232834f4 --- /dev/null +++ b/app/controllers/api/v1/bookmark_folders_controller.rb @@ -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 diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb index b4b976ac3c5..fc200926d81 100644 --- a/app/controllers/api/v1/statuses/bookmarks_controller.rb +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -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 diff --git a/app/javascript/mastodon/actions/bookmark_folders_typed.ts b/app/javascript/mastodon/actions/bookmark_folders_typed.ts new file mode 100644 index 00000000000..95fbc5f2e5f --- /dev/null +++ b/app/javascript/mastodon/actions/bookmark_folders_typed.ts @@ -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) => apiCreateBookmarkFolder(folder), +); + +export const updateBookmarkFolder = createDataLoadingThunk( + 'bookmarkFolders/update', + (folder: Partial) => 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 })), +); diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js index 89716b224c5..a2c6f5e2110 100644 --- a/app/javascript/mastodon/actions/bookmarks.js +++ b/app/javascript/mastodon/actions/bookmarks.js @@ -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, diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 60df9abc530..b5c4c2d56b9 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -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) { diff --git a/app/javascript/mastodon/api/bookmark_folders.ts b/app/javascript/mastodon/api/bookmark_folders.ts new file mode 100644 index 00000000000..326486ee16f --- /dev/null +++ b/app/javascript/mastodon/api/bookmark_folders.ts @@ -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, +) => apiRequestPost('v1/bookmark_folders', folder); + +export const apiUpdateBookmarkFolder = ( + folder: Partial, +) => + apiRequestPut( + `v1/bookmark_folders/${folder.id}`, + folder, + ); + +export const apiGetBookmarkFolders = () => + apiRequestGet('v1/bookmark_folders'); + +export const apiGetBookmarkFolder = (id: string) => + apiRequestGet(`v1/bookmark_folders/${id}`); + +export const apiDeleteBookmarkFolder = (id: string) => + apiRequestDelete(`v1/bookmark_folders/${id}`); diff --git a/app/javascript/mastodon/api_types/bookmark_folders.ts b/app/javascript/mastodon/api_types/bookmark_folders.ts new file mode 100644 index 00000000000..cf282a6405d --- /dev/null +++ b/app/javascript/mastodon/api_types/bookmark_folders.ts @@ -0,0 +1,4 @@ +export interface ApiBookmarkFolderJSON { + id: string; + title: string; +} diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index d61d8ceed06..cae6a13f7d9 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -102,6 +102,7 @@ export interface ApiStatusJSON { reblogged?: boolean; muted?: boolean; bookmarked?: boolean; + bookmark_folder_id?: string | null; pinned?: boolean; filtered?: ApiFilterResultJSON[]; diff --git a/app/javascript/mastodon/components/status_action_bar/index.jsx b/app/javascript/mastodon/components/status_action_bar/index.jsx index 1ad8b2002f9..4a7757e3afc 100644 --- a/app/javascript/mastodon/components/status_action_bar/index.jsx +++ b/app/javascript/mastodon/components/status_action_bar/index.jsx @@ -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); diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 6b0261c6563..b7ad032d7e9 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -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)); diff --git a/app/javascript/mastodon/features/bookmark_folder_adder/__tests__/adder-test.tsx b/app/javascript/mastodon/features/bookmark_folder_adder/__tests__/adder-test.tsx new file mode 100644 index 00000000000..1d8dd6a156e --- /dev/null +++ b/app/javascript/mastodon/features/bookmark_folder_adder/__tests__/adder-test.tsx @@ -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; +} + +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(); +}; + +const getFolderRadio = (name: string) => + screen.getByLabelText(name); + +const getNewFolderInput = () => + screen.getByPlaceholderText('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('', () => { + 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'); + }); + }); +}); diff --git a/app/javascript/mastodon/features/bookmark_folder_adder/index.tsx b/app/javascript/mastodon/features/bookmark_folder_adder/index.tsx new file mode 100644 index 00000000000..ff74453341d --- /dev/null +++ b/app/javascript/mastodon/features/bookmark_folder_adder/index.tsx @@ -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 + + ); +}; + +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) => { + 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 ( +
+ + +
+ + + ); +}; + +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 ( + + + +
+ {isLoading ? ( + + ) : ( + + )} +
+ + + + {intl.formatMessage(id ? messages.edit : messages.create)} + + + +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default NewBookmarkFolderWrapper; diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.tsx b/app/javascript/mastodon/features/bookmarked_statuses/index.tsx index b8fc9e24487..a0819408ee9 100644 --- a/app/javascript/mastodon/features/bookmarked_statuses/index.tsx +++ b/app/javascript/mastodon/features/bookmarked_statuses/index.tsx @@ -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(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 ? ( + + ) : ( + extraButton={ + !folderId ? ( + + + + ) : null + } + > + {folderId && ( +
+
+ + {' '} + + + + +
+
+ )} +
- {intl.formatMessage(messages.heading)} + + {folderId + ? `${currentFolderLabel} - ${intl.formatMessage(messages.heading)}` + : currentFolderLabel} + diff --git a/app/javascript/mastodon/features/navigation_panel/components/bookmark_folders_panel.tsx b/app/javascript/mastodon/features/navigation_panel/components/bookmark_folders_panel.tsx new file mode 100644 index 00000000000..7b8484fe361 --- /dev/null +++ b/app/javascript/mastodon/features/navigation_panel/components/bookmark_folders_panel.tsx @@ -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 ? ( + + ) : null; + const folderLinks = hasFolders + ? folders.map((folder) => ( + + )) + : null; + + useEffect(() => { + void dispatch(fetchBookmarkFolders()).then(() => { + setLoading(false); + + return ''; + }); + }, [dispatch]); + + return ( + + {allBookmarksLink} + {folderLinks} + + ); +}; diff --git a/app/javascript/mastodon/features/navigation_panel/components/collapsible_panel.tsx b/app/javascript/mastodon/features/navigation_panel/components/collapsible_panel.tsx index 217e15b5ee0..13062a3d804 100644 --- a/app/javascript/mastodon/features/navigation_panel/components/collapsible_panel.tsx +++ b/app/javascript/mastodon/features/navigation_panel/components/collapsible_panel.tsx @@ -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<{ - {(loading || children.length > 0) && ( + {(loading || childCount > 0) && ( <>
@@ -70,7 +75,7 @@ export const CollapsiblePanel: React.FC<{ )}
- {children.length > 0 && expanded && ( + {childCount > 0 && expanded && (
{ ); }; -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)} /> -
  • - -
  • +
  • { + 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 }); diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index db18964a3b9..75bf59485be 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -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} diff --git a/app/javascript/mastodon/features/ui/components/column_link.tsx b/app/javascript/mastodon/features/ui/components/column_link.tsx index 1d46f44a84c..098eecb8571 100644 --- a/app/javascript/mastodon/features/ui/components/column_link.tsx +++ b/app/javascript/mastodon/features/ui/components/column_link.tsx @@ -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 ( @@ -71,7 +77,7 @@ export const ColumnLink: React.FC<{ ); } else if (to) { return ( - + {active ? activeIconElement : iconElement} {text} {badgeElement} diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/delete_bookmark_folder.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/delete_bookmark_folder.tsx new file mode 100644 index 00000000000..5da2113ef4e --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/delete_bookmark_folder.tsx @@ -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 ( + + ); +}; diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts b/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts index c27597fb52f..f96b432ceb9 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts @@ -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, diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index b26387cee1b..1ae56a1fb39 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -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, diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 73ff427e6f6..13b78a26ca1 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -59,6 +59,8 @@ import { FollowRequests, FavouritedStatuses, BookmarkedStatuses, + BookmarkFolders, + BookmarkFolderEdit, FollowedTags, LinkTimeline, ListTimeline, @@ -219,7 +221,11 @@ class SwitchingColumnsArea extends PureComponent { - + + + + + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index a6685587e92..041723c7789 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -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'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 511ae3503cb..52a30b247de 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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": "Hotkey {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", diff --git a/app/javascript/mastodon/models/bookmark_folder.ts b/app/javascript/mastodon/models/bookmark_folder.ts new file mode 100644 index 00000000000..381630431ea --- /dev/null +++ b/app/javascript/mastodon/models/bookmark_folder.ts @@ -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; +export type BookmarkFolder = RecordOf; + +const BookmarkFolderFactory = Record({ + id: '', + title: '', +}); + +export function createBookmarkFolder(attributes: Partial) { + return BookmarkFolderFactory(attributes); +} diff --git a/app/javascript/mastodon/reducers/bookmark_folders.ts b/app/javascript/mastodon/reducers/bookmark_folders.ts new file mode 100644 index 00000000000..36e03622e13 --- /dev/null +++ b/app/javascript/mastodon/reducers/bookmark_folders.ts @@ -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(); +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 = 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; +}; diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 2d2c8fbab55..bab86d0a323 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -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, diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js index 447dde6ecb4..df4e7b6c2cc 100644 --- a/app/javascript/mastodon/reducers/status_lists.js +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -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} */ 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); diff --git a/app/javascript/mastodon/selectors/bookmark_folders.ts b/app/javascript/mastodon/selectors/bookmark_folders.ts new file mode 100644 index 00000000000..994c93ba71f --- /dev/null +++ b/app/javascript/mastodon/selectors/bookmark_folders.ts @@ -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, + ): ImmutableList => + 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(), +); diff --git a/app/javascript/mastodon/selectors/statuses.ts b/app/javascript/mastodon/selectors/statuses.ts index 4d045e924a7..223cc8a5b50 100644 --- a/app/javascript/mastodon/selectors/statuses.ts +++ b/app/javascript/mastodon/selectors/statuses.ts @@ -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(), + ) as ImmutableOrderedSet, + ], + (items) => items.toList(), +); diff --git a/app/lib/status_cache_hydrator.rb b/app/lib/status_cache_hydrator.rb index ece88c04c1b..8d46d6a3183 100644 --- a/app/lib/status_cache_hydrator.rb +++ b/app/lib/status_cache_hydrator.rb @@ -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] diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb index 147d87b5649..04b53a4d42f 100644 --- a/app/models/bookmark.rb +++ b/app/models/bookmark.rb @@ -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 } diff --git a/app/models/bookmark_folder.rb b/app/models/bookmark_folder.rb new file mode 100644 index 00000000000..c1704f6e72b --- /dev/null +++ b/app/models/bookmark_folder.rb @@ -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 diff --git a/app/models/concerns/account/associations.rb b/app/models/concerns/account/associations.rb index 8c26a4da648..6c9c72934cb 100644 --- a/app/models/concerns/account/associations.rb +++ b/app/models/concerns/account/associations.rb @@ -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 diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 060a0a8ed6d..49e0b50b8e6 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -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] || {} diff --git a/app/serializers/rest/bookmark_folder_serializer.rb b/app/serializers/rest/bookmark_folder_serializer.rb new file mode 100644 index 00000000000..d027b922400 --- /dev/null +++ b/app/serializers/rest/bookmark_folder_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::BookmarkFolderSerializer < ActiveModel::Serializer + attributes :id, :title + + def id + object.id.to_s + end +end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 8468029f7db..ab87e1cb2de 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 95631bd86b9..3d93d751cf5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/routes/api.rb b/config/routes/api.rb index a212685eb08..3cae690d5ec 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -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 diff --git a/config/routes/web_app.rb b/config/routes/web_app.rb index 22814f294c0..d875acf4be3 100644 --- a/config/routes/web_app.rb +++ b/config/routes/web_app.rb @@ -6,7 +6,7 @@ %w( /blocks - /bookmarks + /bookmarks/(*any) /collections/(*any) /conversations /deck/(*any) diff --git a/db/migrate/20260503121707_create_bookmark_folders.rb b/db/migrate/20260503121707_create_bookmark_folders.rb new file mode 100644 index 00000000000..56bcc10e16d --- /dev/null +++ b/db/migrate/20260503121707_create_bookmark_folders.rb @@ -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 diff --git a/db/migrate/20260503141932_add_folder_to_bookmarks.rb b/db/migrate/20260503141932_add_folder_to_bookmarks.rb new file mode 100644 index 00000000000..e462a2895b1 --- /dev/null +++ b/db/migrate/20260503141932_add_folder_to_bookmarks.rb @@ -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 diff --git a/db/migrate/20260503145304_add_foreign_key_to_bookmarks_folders.rb b/db/migrate/20260503145304_add_foreign_key_to_bookmarks_folders.rb new file mode 100644 index 00000000000..7f559023fe6 --- /dev/null +++ b/db/migrate/20260503145304_add_foreign_key_to_bookmarks_folders.rb @@ -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 diff --git a/db/migrate/20260503145333_validate_foreign_key_on_bookmarks_folders.rb b/db/migrate/20260503145333_validate_foreign_key_on_bookmarks_folders.rb new file mode 100644 index 00000000000..baf4f69f628 --- /dev/null +++ b/db/migrate/20260503145333_validate_foreign_key_on_bookmarks_folders.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 51e2cef103b..d9751eb14c0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/spec/fabricators/bookmark_folder_fabricator.rb b/spec/fabricators/bookmark_folder_fabricator.rb new file mode 100644 index 00000000000..4bbe827ee84 --- /dev/null +++ b/spec/fabricators/bookmark_folder_fabricator.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Fabricator(:bookmark_folder) do + account { Fabricate.build(:account) } + title 'MyString' +end diff --git a/spec/models/bookmark_folder_spec.rb b/spec/models/bookmark_folder_spec.rb new file mode 100644 index 00000000000..8111df8badd --- /dev/null +++ b/spec/models/bookmark_folder_spec.rb @@ -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 diff --git a/spec/models/bookmark_spec.rb b/spec/models/bookmark_spec.rb index e0d91000e77..07d44cd143c 100644 --- a/spec/models/bookmark_spec.rb +++ b/spec/models/bookmark_spec.rb @@ -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 diff --git a/spec/requests/api/v1/bookmark_folders_spec.rb b/spec/requests/api/v1/bookmark_folders_spec.rb new file mode 100644 index 00000000000..3c4b42c4b50 --- /dev/null +++ b/spec/requests/api/v1/bookmark_folders_spec.rb @@ -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