diff --git a/src/actions/bookmarks.ts b/src/actions/bookmarks.ts index da09edfa0..9fd11d258 100644 --- a/src/actions/bookmarks.ts +++ b/src/actions/bookmarks.ts @@ -15,70 +15,77 @@ const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL'; const noOp = () => new Promise(f => f(undefined)); -const fetchBookmarkedStatuses = () => +const fetchBookmarkedStatuses = (folderId?: string) => (dispatch: AppDispatch, getState: () => RootState) => { - if (getState().status_lists.get('bookmarks')?.isLoading) { + if (getState().status_lists.get(folderId ? `bookmarks:${folderId}` : 'bookmarks')?.isLoading) { return dispatch(noOp); } - dispatch(fetchBookmarkedStatusesRequest()); + dispatch(fetchBookmarkedStatusesRequest(folderId)); - return api(getState).get('/api/v1/bookmarks').then(response => { + return api(getState).get(`/api/v1/bookmarks${folderId ? `?folder_id=${folderId}` : ''}`).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); - return dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + return dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null, folderId)); }).catch(error => { - dispatch(fetchBookmarkedStatusesFail(error)); + dispatch(fetchBookmarkedStatusesFail(error, folderId)); }); }; -const fetchBookmarkedStatusesRequest = () => ({ +const fetchBookmarkedStatusesRequest = (folderId?: string) => ({ type: BOOKMARKED_STATUSES_FETCH_REQUEST, + folderId, }); -const fetchBookmarkedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ +const fetchBookmarkedStatusesSuccess = (statuses: APIEntity[], next: string | null, folderId?: string) => ({ type: BOOKMARKED_STATUSES_FETCH_SUCCESS, statuses, next, + folderId, }); -const fetchBookmarkedStatusesFail = (error: unknown) => ({ +const fetchBookmarkedStatusesFail = (error: unknown, folderId?: string) => ({ type: BOOKMARKED_STATUSES_FETCH_FAIL, error, + folderId, }); -const expandBookmarkedStatuses = () => +const expandBookmarkedStatuses = (folderId?: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const url = getState().status_lists.get('bookmarks')?.next || null; + const list = folderId ? `bookmarks:${folderId}` : 'bookmarks'; + const url = getState().status_lists.get(list)?.next || null; - if (url === null || getState().status_lists.get('bookmarks')?.isLoading) { + if (url === null || getState().status_lists.get(list)?.isLoading) { return dispatch(noOp); } - dispatch(expandBookmarkedStatusesRequest()); + dispatch(expandBookmarkedStatusesRequest(folderId)); return api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); - return dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + return dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null, folderId)); }).catch(error => { - dispatch(expandBookmarkedStatusesFail(error)); + dispatch(expandBookmarkedStatusesFail(error, folderId)); }); }; -const expandBookmarkedStatusesRequest = () => ({ +const expandBookmarkedStatusesRequest = (folderId?: string) => ({ type: BOOKMARKED_STATUSES_EXPAND_REQUEST, + folderId, }); -const expandBookmarkedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ +const expandBookmarkedStatusesSuccess = (statuses: APIEntity[], next: string | null, folderId?: string) => ({ type: BOOKMARKED_STATUSES_EXPAND_SUCCESS, statuses, next, + folderId, }); -const expandBookmarkedStatusesFail = (error: unknown) => ({ +const expandBookmarkedStatusesFail = (error: unknown, folderId?: string) => ({ type: BOOKMARKED_STATUSES_EXPAND_FAIL, error, + folderId, }); export { diff --git a/src/actions/interactions.ts b/src/actions/interactions.ts index cab5cd740..60f1d686b 100644 --- a/src/actions/interactions.ts +++ b/src/actions/interactions.ts @@ -1,12 +1,14 @@ import { defineMessages } from 'react-intl'; -import toast from 'soapbox/toast'; +import toast, { type IToastOptions } from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; +import { getFeatures } from 'soapbox/utils/features'; import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; +import { openModal } from './modals'; import { expandGroupFeaturedTimeline } from './timelines'; import type { AppDispatch, RootState } from 'soapbox/store'; @@ -85,7 +87,9 @@ const ZAP_FAIL = 'ZAP_FAIL'; const messages = defineMessages({ bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' }, bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' }, + folderChanged: { id: 'status.bookmark_folder_changed', defaultMessage: 'Changed folder' }, view: { id: 'toast.view', defaultMessage: 'View' }, + selectFolder: { id: 'status.bookmark.select_folder', defaultMessage: 'Select folder' }, }); const reblog = (status: StatusEntity) => @@ -342,17 +346,35 @@ const zapFail = (status: StatusEntity, error: unknown) => ({ skipLoading: true, }); -const bookmark = (status: StatusEntity) => +const bookmark = (status: StatusEntity, folderId?: string) => (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + dispatch(bookmarkRequest(status)); - api(getState).post(`/api/v1/statuses/${status.id}/bookmark`).then(function(response) { + return api(getState).post(`/api/v1/statuses/${status.id}/bookmark`, { + folder_id: folderId, + }).then(function(response) { dispatch(importFetchedStatus(response.data)); dispatch(bookmarkSuccess(status, response.data)); - toast.success(messages.bookmarkAdded, { + + let opts: IToastOptions = { actionLabel: messages.view, - actionLink: '/bookmarks', - }); + actionLink: folderId ? `/bookmarks/${folderId}` : '/bookmarks/all', + }; + if (features.bookmarkFolders && typeof folderId !== 'string') { + opts = { + actionLabel: messages.selectFolder, + action: () => dispatch(openModal('SELECT_BOOKMARK_FOLDER', { + statusId: status.id, + })), + }; + } + + toast.success(typeof folderId === 'string' ? messages.folderChanged : messages.bookmarkAdded, opts); }).catch(function(error) { dispatch(bookmarkFail(status, error)); }); diff --git a/src/api/hooks/index.ts b/src/api/hooks/index.ts index ce9c9fad1..f085f3c4b 100644 --- a/src/api/hooks/index.ts +++ b/src/api/hooks/index.ts @@ -44,6 +44,13 @@ export { useUnmuteGroup } from './groups/useUnmuteGroup'; export { useUpdateGroup } from './groups/useUpdateGroup'; export { useUpdateGroupTag } from './groups/useUpdateGroupTag'; +// Statuses +export { useBookmarkFolders } from './statuses/useBookmarkFolders'; +export { useBookmarkFolder } from './statuses/useBookmarkFolder'; +export { useCreateBookmarkFolder } from './statuses/useCreateBookmarkFolder'; +export { useDeleteBookmarkFolder } from './statuses/useDeleteBookmarkFolder'; +export { useUpdateBookmarkFolder } from './statuses/useUpdateBookmarkFolder'; + // Streaming export { useUserStream } from './streaming/useUserStream'; export { useCommunityStream } from './streaming/useCommunityStream'; diff --git a/src/api/hooks/statuses/useBookmarkFolder.ts b/src/api/hooks/statuses/useBookmarkFolder.ts new file mode 100644 index 000000000..f81d9e7d1 --- /dev/null +++ b/src/api/hooks/statuses/useBookmarkFolder.ts @@ -0,0 +1,31 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { selectEntity } from 'soapbox/entity-store/selectors'; +import { useAppSelector } from 'soapbox/hooks'; +import { type BookmarkFolder } from 'soapbox/schemas/bookmark-folder'; + +import { useBookmarkFolders } from './useBookmarkFolders'; + +function useBookmarkFolder(folderId?: string) { + const { + isError, + isFetched, + isFetching, + isLoading, + invalidate, + } = useBookmarkFolders(); + + const bookmarkFolder = useAppSelector(state => folderId + ? selectEntity(state, Entities.BOOKMARK_FOLDERS, folderId) + : undefined); + + return { + bookmarkFolder, + isError, + isFetched, + isFetching, + isLoading, + invalidate, + }; +} + +export { useBookmarkFolder }; diff --git a/src/api/hooks/statuses/useBookmarkFolders.ts b/src/api/hooks/statuses/useBookmarkFolders.ts new file mode 100644 index 000000000..77538a97a --- /dev/null +++ b/src/api/hooks/statuses/useBookmarkFolders.ts @@ -0,0 +1,25 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { useFeatures } from 'soapbox/hooks/useFeatures'; +import { bookmarkFolderSchema, type BookmarkFolder } from 'soapbox/schemas/bookmark-folder'; + +function useBookmarkFolders() { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.BOOKMARK_FOLDERS], + () => api.get('/api/v1/pleroma/bookmark_folders'), + { enabled: features.bookmarkFolders, schema: bookmarkFolderSchema }, + ); + + const bookmarkFolders = entities; + + return { + ...result, + bookmarkFolders, + }; +} + +export { useBookmarkFolders }; diff --git a/src/api/hooks/statuses/useCreateBookmarkFolder.ts b/src/api/hooks/statuses/useCreateBookmarkFolder.ts new file mode 100644 index 000000000..ded24ff97 --- /dev/null +++ b/src/api/hooks/statuses/useCreateBookmarkFolder.ts @@ -0,0 +1,31 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useCreateEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { bookmarkFolderSchema } from 'soapbox/schemas/bookmark-folder'; + +interface CreateBookmarkFolderParams { + name: string; + emoji?: string; +} + +function useCreateBookmarkFolder() { + const api = useApi(); + + const { createEntity, ...rest } = useCreateEntity( + [Entities.BOOKMARK_FOLDERS], + (params: CreateBookmarkFolderParams) => + api.post('/api/v1/pleroma/bookmark_folders', params, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }), + { schema: bookmarkFolderSchema }, + ); + + return { + createBookmarkFolder: createEntity, + ...rest, + }; +} + +export { useCreateBookmarkFolder }; diff --git a/src/api/hooks/statuses/useDeleteBookmarkFolder.ts b/src/api/hooks/statuses/useDeleteBookmarkFolder.ts new file mode 100644 index 000000000..cd8018c2d --- /dev/null +++ b/src/api/hooks/statuses/useDeleteBookmarkFolder.ts @@ -0,0 +1,16 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntityActions } from 'soapbox/entity-store/hooks'; + +function useDeleteBookmarkFolder() { + const { deleteEntity, isSubmitting } = useEntityActions( + [Entities.BOOKMARK_FOLDERS], + { delete: '/api/v1/pleroma/bookmark_folders/:id' }, + ); + + return { + deleteBookmarkFolder: deleteEntity, + isSubmitting, + }; +} + +export { useDeleteBookmarkFolder }; diff --git a/src/api/hooks/statuses/useUpdateBookmarkFolder.ts b/src/api/hooks/statuses/useUpdateBookmarkFolder.ts new file mode 100644 index 000000000..c27dd089e --- /dev/null +++ b/src/api/hooks/statuses/useUpdateBookmarkFolder.ts @@ -0,0 +1,31 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useCreateEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { bookmarkFolderSchema } from 'soapbox/schemas/bookmark-folder'; + +interface UpdateBookmarkFolderParams { + name: string; + emoji?: string; +} + +function useUpdateBookmarkFolder(folderId: string) { + const api = useApi(); + + const { createEntity, ...rest } = useCreateEntity( + [Entities.BOOKMARK_FOLDERS], + (params: UpdateBookmarkFolderParams) => + api.patch(`/api/v1/pleroma/bookmark_folders/${folderId}`, params, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }), + { schema: bookmarkFolderSchema }, + ); + + return { + updateBookmarkFolder: createEntity, + ...rest, + }; +} + +export { useUpdateBookmarkFolder }; diff --git a/src/entity-store/entities.ts b/src/entity-store/entities.ts index f81239dca..d5c308446 100644 --- a/src/entity-store/entities.ts +++ b/src/entity-store/entities.ts @@ -2,6 +2,7 @@ import type * as Schemas from 'soapbox/schemas'; enum Entities { ACCOUNTS = 'Accounts', + BOOKMARK_FOLDERS = 'BookmarkFolders', GROUPS = 'Groups', GROUP_MEMBERSHIPS = 'GroupMemberships', GROUP_MUTES = 'GroupMutes', @@ -14,6 +15,7 @@ enum Entities { interface EntityTypes { [Entities.ACCOUNTS]: Schemas.Account; + [Entities.BOOKMARK_FOLDERS]: Schemas.BookmarkFolder; [Entities.GROUPS]: Schemas.Group; [Entities.GROUP_MEMBERSHIPS]: Schemas.GroupMember; [Entities.GROUP_RELATIONSHIPS]: Schemas.GroupRelationship; diff --git a/src/features/bookmark-folders/components/new-folder-form.tsx b/src/features/bookmark-folders/components/new-folder-form.tsx new file mode 100644 index 000000000..a46527632 --- /dev/null +++ b/src/features/bookmark-folders/components/new-folder-form.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import { useCreateBookmarkFolder } from 'soapbox/api/hooks'; +import { Button, Form, HStack, Input } from 'soapbox/components/ui'; +import { useTextField } from 'soapbox/hooks/forms'; +import toast from 'soapbox/toast'; + +const messages = defineMessages({ + label: { id: 'bookmark_folders.new.title_placeholder', defaultMessage: 'New folder title' }, + createSuccess: { id: 'bookmark_folders.add.success', defaultMessage: 'Bookmark folder created successfully' }, + createFail: { id: 'bookmark_folders.add.fail', defaultMessage: 'Failed to create bookmark folder' }, +}); + +const NewFolderForm: React.FC = () => { + const intl = useIntl(); + + const name = useTextField(); + + const { createBookmarkFolder, isSubmitting } = useCreateBookmarkFolder(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + createBookmarkFolder({ + name: name.value, + }).then(() => { + toast.success(messages.createSuccess); + }).catch(() => { + toast.success(messages.createFail); + }); + }; + + const label = intl.formatMessage(messages.label); + + return ( +
+ + + + + +
+ ); +}; + +export default NewFolderForm; diff --git a/src/features/bookmark-folders/index.tsx b/src/features/bookmark-folders/index.tsx new file mode 100644 index 000000000..8ebed7b5c --- /dev/null +++ b/src/features/bookmark-folders/index.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { Redirect } from 'react-router-dom'; + +import { useBookmarkFolders } from 'soapbox/api/hooks'; +import List, { ListItem } from 'soapbox/components/list'; +import { Column, Emoji, HStack, Icon, Spinner, Stack } from 'soapbox/components/ui'; +import { useFeatures } from 'soapbox/hooks'; + +import NewFolderForm from './components/new-folder-form'; + + +const messages = defineMessages({ + heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, +}); + +const BookmarkFolders: React.FC = () => { + const intl = useIntl(); + const features = useFeatures(); + + const { bookmarkFolders, isFetching } = useBookmarkFolders(); + + if (!features.bookmarkFolders) return ; + + if (isFetching) { + return ( + + + + ); + } + + return ( + + + + + + + + + + } + /> + {bookmarkFolders?.map((folder) => ( + + {folder.emoji ? ( + + ) : } + {folder.name} + + } + /> + ))} + + + + ); +}; + +export default BookmarkFolders; diff --git a/src/features/bookmarks/index.tsx b/src/features/bookmarks/index.tsx index d949749cf..b6ced5a4d 100644 --- a/src/features/bookmarks/index.tsx +++ b/src/features/bookmarks/index.tsx @@ -1,48 +1,114 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; import debounce from 'lodash/debounce'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks'; +import { openModal } from 'soapbox/actions/modals'; +import { useBookmarkFolder, useDeleteBookmarkFolder } from 'soapbox/api/hooks'; +import DropdownMenu from 'soapbox/components/dropdown-menu'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; import StatusList from 'soapbox/components/status-list'; import { Column } from 'soapbox/components/ui'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; +import toast from 'soapbox/toast'; const messages = defineMessages({ heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, + editFolder: { id: 'bookmarks.edit_folder', defaultMessage: 'Edit folder' }, + deleteFolder: { id: 'bookmarks.delete_folder', defaultMessage: 'Delete folder' }, + deleteFolderHeading: { id: 'confirmations.delete_bookmark_folder.heading', defaultMessage: 'Delete "{name}" folder?' }, + deleteFolderMessage: { id: 'confirmations.delete_bookmark_folder.message', defaultMessage: 'Are you sure you want to delete the folder? The bookmarks will still be stored.' }, + deleteFolderConfirm: { id: 'confirmations.delete_bookmark_folder.confirm', defaultMessage: 'Delete folder' }, + deleteFolderSuccess: { id: 'bookmarks.delete_folder.success', defaultMessage: 'Folder deleted' }, + deleteFolderFail: { id: 'bookmarks.delete_folder.fail', defaultMessage: 'Failed to delete folder' }, }); -const handleLoadMore = debounce((dispatch) => { - dispatch(expandBookmarkedStatuses()); +const handleLoadMore = debounce((dispatch, folderId) => { + dispatch(expandBookmarkedStatuses(folderId)); }, 300, { leading: true }); -const Bookmarks: React.FC = () => { +interface IBookmarks { + params?: { + id?: string; + }; +} + +const Bookmarks: React.FC = ({ params }) => { const dispatch = useAppDispatch(); const intl = useIntl(); + const history = useHistory(); - const statusIds = useAppSelector((state) => state.status_lists.get('bookmarks')!.items); - const isLoading = useAppSelector((state) => state.status_lists.get('bookmarks')!.isLoading); - const hasMore = useAppSelector((state) => !!state.status_lists.get('bookmarks')!.next); + const folderId = params?.id; + + const { bookmarkFolder: folder } = useBookmarkFolder(folderId); + const { deleteBookmarkFolder } = useDeleteBookmarkFolder(); + + const bookmarksKey = folderId ? `bookmarks:${folderId}` : 'bookmarks'; + + const statusIds = useAppSelector((state) => state.status_lists.get(bookmarksKey)?.items || ImmutableOrderedSet()); + const isLoading = useAppSelector((state) => state.status_lists.get(bookmarksKey)?.isLoading === true); + const hasMore = useAppSelector((state) => !!state.status_lists.get(bookmarksKey)?.next); React.useEffect(() => { - dispatch(fetchBookmarkedStatuses()); - }, []); + dispatch(fetchBookmarkedStatuses(folderId)); + }, [folderId]); const handleRefresh = () => { - return dispatch(fetchBookmarkedStatuses()); + return dispatch(fetchBookmarkedStatuses(folderId)); + }; + + const handleEditFolder = () => { + dispatch(openModal('EDIT_BOOKMARK_FOLDER', { folderId })); + }; + + const handleDeleteFolder = () => { + dispatch(openModal('CONFIRM', { + heading: intl.formatMessage(messages.deleteFolderHeading, { name: folder?.name }), + message: intl.formatMessage(messages.deleteFolderMessage), + confirm: intl.formatMessage(messages.deleteFolderConfirm), + onConfirm: () => { + deleteBookmarkFolder(folderId!).then(() => { + toast.success(messages.deleteFolderSuccess); + history.push('/bookmarks'); + }).catch(() => { + toast.error(messages.deleteFolderFail); + }); + }, + })); }; const emptyMessage = ; + const items = folderId ? [ + { + text: intl.formatMessage(messages.editFolder), + action: handleEditFolder, + icon: require('@tabler/icons/edit.svg'), + }, + { + text: intl.formatMessage(messages.deleteFolder), + action: handleDeleteFolder, + icon: require('@tabler/icons/trash.svg'), + }, + ] : []; + return ( - + + } + transparent + > handleLoadMore(dispatch)} + onLoadMore={() => handleLoadMore(dispatch, folderId)} emptyMessage={emptyMessage} divideType='space' /> diff --git a/src/features/list-adder/index.tsx b/src/features/list-adder/index.tsx index 821298208..555ad2724 100644 --- a/src/features/list-adder/index.tsx +++ b/src/features/list-adder/index.tsx @@ -16,7 +16,6 @@ import type { RootState } from 'soapbox/store'; import type { List as ListEntity } from 'soapbox/types/entities'; const messages = defineMessages({ - close: { id: 'lightbox.close', defaultMessage: 'Close' }, subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' }, add: { id: 'lists.new.create', defaultMessage: 'Add list' }, }); diff --git a/src/features/list-editor/index.tsx b/src/features/list-editor/index.tsx index 69b96e0f9..a1fd5f43c 100644 --- a/src/features/list-editor/index.tsx +++ b/src/features/list-editor/index.tsx @@ -10,7 +10,6 @@ import EditListForm from './components/edit-list-form'; import Search from './components/search'; const messages = defineMessages({ - close: { id: 'lightbox.close', defaultMessage: 'Close' }, changeTitle: { id: 'lists.edit.submit', defaultMessage: 'Change title' }, addToList: { id: 'lists.account.add', defaultMessage: 'Add to list' }, removeFromList: { id: 'lists.account.remove', defaultMessage: 'Remove from list' }, diff --git a/src/features/ui/components/modal-root.tsx b/src/features/ui/components/modal-root.tsx index 36b099216..88fd910c7 100644 --- a/src/features/ui/components/modal-root.tsx +++ b/src/features/ui/components/modal-root.tsx @@ -14,6 +14,7 @@ import { CryptoDonateModal, DislikesModal, EditAnnouncementModal, + EditBookmarkFolderModal, EditFederationModal, EmbedModal, EventMapModal, @@ -36,6 +37,7 @@ import { ReblogsModal, ReplyMentionsModal, ReportModal, + SelectBookmarkFolderModal, UnauthorizedModal, VideoModal, } from 'soapbox/features/ui/util/async-components'; @@ -57,6 +59,7 @@ const MODAL_COMPONENTS: Record> = { 'CRYPTO_DONATE': CryptoDonateModal, 'DISLIKES': DislikesModal, 'EDIT_ANNOUNCEMENT': EditAnnouncementModal, + 'EDIT_BOOKMARK_FOLDER': EditBookmarkFolderModal, 'EDIT_FEDERATION': EditFederationModal, 'EMBED': EmbedModal, 'EVENT_MAP': EventMapModal, @@ -78,6 +81,7 @@ const MODAL_COMPONENTS: Record> = { 'REBLOGS': ReblogsModal, 'REPLY_MENTIONS': ReplyMentionsModal, 'REPORT': ReportModal, + 'SELECT_BOOKMARK_FOLDER': SelectBookmarkFolderModal, 'UNAUTHORIZED': UnauthorizedModal, 'VIDEO': VideoModal, }; diff --git a/src/features/ui/components/modals/compose-modal.tsx b/src/features/ui/components/modals/compose-modal.tsx index aca1302ad..83e471475 100644 --- a/src/features/ui/components/modals/compose-modal.tsx +++ b/src/features/ui/components/modals/compose-modal.tsx @@ -12,7 +12,6 @@ import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles } from 'soa import ComposeForm from '../../../compose/components/compose-form'; const messages = defineMessages({ - close: { id: 'lightbox.close', defaultMessage: 'Close' }, confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' }, cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' }, }); diff --git a/src/features/ui/components/modals/edit-bookmark-folder-modal.tsx b/src/features/ui/components/modals/edit-bookmark-folder-modal.tsx new file mode 100644 index 000000000..700823d0a --- /dev/null +++ b/src/features/ui/components/modals/edit-bookmark-folder-modal.tsx @@ -0,0 +1,160 @@ +import { useFloating, shift } from '@floating-ui/react'; +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import { closeModal } from 'soapbox/actions/modals'; +import { useBookmarkFolder, useUpdateBookmarkFolder } from 'soapbox/api/hooks'; +import { Emoji, HStack, Icon, Input, Modal } from 'soapbox/components/ui'; +import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown'; +import { messages as emojiMessages } from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container'; +import { useAppDispatch, useClickOutside } from 'soapbox/hooks'; +import { useTextField } from 'soapbox/hooks/forms'; +import toast from 'soapbox/toast'; + +import type { Emoji as EmojiType } from 'soapbox/features/emoji'; + +const messages = defineMessages({ + label: { id: 'bookmark_folders.new.title_placeholder', defaultMessage: 'New folder title' }, + editSuccess: { id: 'bookmark_folders.edit.success', defaultMessage: 'Bookmark folder edited successfully' }, + editFail: { id: 'bookmark_folders.edit.fail', defaultMessage: 'Failed to edit bookmark folder' }, +}); + +interface IEmojiPicker { + emoji?: string; + emojiUrl?: string; + onPickEmoji?: (emoji: EmojiType) => void; +} + +const EmojiPicker: React.FC = ({ emoji, emojiUrl, ...props }) => { + const intl = useIntl(); + const title = intl.formatMessage(emojiMessages.emoji); + const [visible, setVisible] = useState(false); + + const { x, y, strategy, refs, update } = useFloating({ + middleware: [shift()], + }); + + useClickOutside(refs, () => { + setVisible(false); + }); + + const handleToggle: React.KeyboardEventHandler & React.MouseEventHandler = (e) => { + e.stopPropagation(); + setVisible(!visible); + }; + + return ( +
+ + + {createPortal( +
+ +
, + document.body, + )} +
+ ); +}; + +interface IEditBookmarkFolderModal { + folderId: string; + onClose: (type: string) => void; +} + +const EditBookmarkFolderModal: React.FC = ({ folderId, onClose }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const { bookmarkFolder } = useBookmarkFolder(folderId); + const { updateBookmarkFolder, isSubmitting } = useUpdateBookmarkFolder(folderId); + + const [emoji, setEmoji] = useState(bookmarkFolder?.emoji); + const [emojiUrl, setEmojiUrl] = useState(bookmarkFolder?.emoji_url); + const name = useTextField(bookmarkFolder?.name); + + const handleEmojiPick = (data: EmojiType) => { + if (data.custom) { + setEmojiUrl(data.imageUrl); + setEmoji(data.colons); + } else { + setEmoji(data.native); + } + }; + + const onClickClose = () => { + onClose('EDIT_BOOKMARK_FOLDER'); + }; + + const handleSubmit = () => { + updateBookmarkFolder({ + name: name.value, + emoji, + }).then(() => { + toast.success(intl.formatMessage(messages.editSuccess)); + dispatch(closeModal('EDIT_BOOKMARK_FOLDER')); + }) + .catch(() => { + toast.success(intl.formatMessage(messages.editFail)); + }); + }; + + const label = intl.formatMessage(messages.label); + + return ( + } + onClose={onClickClose} + confirmationAction={handleSubmit} + confirmationText={} + > + + + + + + ); +}; + +export default EditBookmarkFolderModal; diff --git a/src/features/ui/components/modals/reactions-modal.tsx b/src/features/ui/components/modals/reactions-modal.tsx index 6985bf971..8b9c923cf 100644 --- a/src/features/ui/components/modals/reactions-modal.tsx +++ b/src/features/ui/components/modals/reactions-modal.tsx @@ -13,7 +13,6 @@ import { ReactionRecord } from 'soapbox/reducers/user-lists'; import type { Item } from 'soapbox/components/ui/tabs/tabs'; const messages = defineMessages({ - close: { id: 'lightbox.close', defaultMessage: 'Close' }, all: { id: 'reactions.all', defaultMessage: 'All' }, }); diff --git a/src/features/ui/components/modals/select-bookmark-folder-modal.tsx b/src/features/ui/components/modals/select-bookmark-folder-modal.tsx new file mode 100644 index 000000000..756e34d62 --- /dev/null +++ b/src/features/ui/components/modals/select-bookmark-folder-modal.tsx @@ -0,0 +1,96 @@ +import React, { useCallback, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { bookmark } from 'soapbox/actions/interactions'; +import { useBookmarkFolders } from 'soapbox/api/hooks'; +import { RadioGroup, RadioItem } from 'soapbox/components/radio'; +import { Emoji, HStack, Icon, Modal, Spinner, Stack } from 'soapbox/components/ui'; +import NewFolderForm from 'soapbox/features/bookmark-folders/components/new-folder-form'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { makeGetStatus } from 'soapbox/selectors'; + +import type { Status as StatusEntity } from 'soapbox/types/entities'; + +interface ISelectBookmarkFolderModal { + statusId: string; + onClose: (type: string) => void; +} + +const SelectBookmarkFolderModal: React.FC = ({ statusId, onClose }) => { + const getStatus = useCallback(makeGetStatus(), []); + const status = useAppSelector(state => getStatus(state, { id: statusId })) as StatusEntity; + const dispatch = useAppDispatch(); + + const [selectedFolder, setSelectedFolder] = useState(status.pleroma.get('bookmark_folder')); + + const { isFetching, bookmarkFolders } = useBookmarkFolders(); + + const onChange: React.ChangeEventHandler = e => { + const folderId = e.target.value; + setSelectedFolder(folderId); + + dispatch(bookmark(status, folderId)).then(() => { + onClose('SELECT_BOOKMARK_FOLDER'); + }).catch(() => {}); + }; + + const onClickClose = () => { + onClose('SELECT_BOOKMARK_FOLDER'); + }; + + const items = [ + + + + + } + checked={selectedFolder === null} + value={''} + />, + ]; + + if (!isFetching) { + items.push(...(bookmarkFolders.map((folder) => ( + + {folder.emoji ? ( + + ) : } + {folder.name} + + } + checked={selectedFolder === folder.id} + value={folder.id} + /> + )))); + } + + const body = isFetching ? : ( + + + + + {items} + + + ); + + return ( + } + onClose={onClickClose} + > + {body} + + ); +}; + +export default SelectBookmarkFolderModal; diff --git a/src/features/ui/components/modals/unauthorized-modal.tsx b/src/features/ui/components/modals/unauthorized-modal.tsx index 36f411a87..02c03fb9f 100644 --- a/src/features/ui/components/modals/unauthorized-modal.tsx +++ b/src/features/ui/components/modals/unauthorized-modal.tsx @@ -9,7 +9,6 @@ import { selectAccount } from 'soapbox/selectors'; import toast from 'soapbox/toast'; const messages = defineMessages({ - close: { id: 'lightbox.close', defaultMessage: 'Close' }, accountPlaceholder: { id: 'remote_interaction.account_placeholder', defaultMessage: 'Enter your username@domain you want to act from' }, userNotFoundError: { id: 'remote_interaction.user_not_found_error', defaultMessage: 'Couldn\'t find given user' }, }); diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index 3fafe2a00..993931985 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -136,6 +136,7 @@ import { RegisterInvite, ExternalLogin, LandingTimeline, + BookmarkFolders, } from './util/async-components'; import GlobalHotkeys from './util/global-hotkeys'; import { WrappedRoute } from './util/react-router-helpers'; @@ -243,7 +244,9 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.lists && } {features.lists && } - {features.bookmarks && } + {features.bookmarks && } + {features.bookmarks && } + {features.bookmarkFolders && } diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 1364a89f2..4660eb047 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -164,3 +164,6 @@ export const AccountNotePanel = lazy(() => import('soapbox/features/ui/component export const ComposeEditor = lazy(() => import('soapbox/features/compose/editor')); export const NostrSignupModal = lazy(() => import('soapbox/features/ui/components/modals/nostr-signup-modal/nostr-signup-modal')); export const NostrLoginModal = lazy(() => import('soapbox/features/ui/components/modals/nostr-login-modal/nostr-login-modal')); +export const BookmarkFolders = lazy(() => import('soapbox/features/bookmark-folders')); +export const EditBookmarkFolderModal = lazy(() => import('soapbox/features/ui/components/modals/edit-bookmark-folder-modal')); +export const SelectBookmarkFolderModal = lazy(() => import('soapbox/features/ui/components/modals/select-bookmark-folder-modal')); diff --git a/src/features/video/index.tsx b/src/features/video/index.tsx index 6d224cc0c..1918fdef0 100644 --- a/src/features/video/index.tsx +++ b/src/features/video/index.tsx @@ -19,9 +19,6 @@ const messages = defineMessages({ pause: { id: 'video.pause', defaultMessage: 'Pause' }, mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, - hide: { id: 'video.hide', defaultMessage: 'Hide video' }, - expand: { id: 'video.expand', defaultMessage: 'Expand video' }, - close: { id: 'video.close', defaultMessage: 'Close video' }, fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' }, exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, }); diff --git a/src/hooks/forms/useTextField.ts b/src/hooks/forms/useTextField.ts index a8043611d..aceac23c5 100644 --- a/src/hooks/forms/useTextField.ts +++ b/src/hooks/forms/useTextField.ts @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; * Returns props for ``. * If `initialValue` changes from undefined to a string, it will set the value. */ -function useTextField(initialValue: string | undefined) { +function useTextField(initialValue?: string | undefined) { const [value, setValue] = useState(initialValue); const hasInitialValue = typeof initialValue === 'string'; diff --git a/src/locales/en.json b/src/locales/en.json index 6739b3bc7..1ecc4704d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -197,6 +197,17 @@ "badge_input.placeholder": "Enter a badge…", "birthday_panel.title": "Birthdays", "birthdays_modal.empty": "None of your friends have birthday today.", + "bookmark_folders.add.fail": "Failed to create bookmark folder", + "bookmark_folders.add.success": "Bookmark folder created successfully", + "bookmark_folders.all_bookmarks": "All bookmarks", + "bookmark_folders.edit.fail": "Failed to edit bookmark folder", + "bookmark_folders.edit.success": "Bookmark folder edited successfully", + "bookmark_folders.new.create_title": "Add folder", + "bookmark_folders.new.title_placeholder": "New folder title", + "bookmarks.delete_folder": "Delete folder", + "bookmarks.delete_folder.fail": "Failed to delete folder", + "bookmarks.delete_folder.success": "Folder deleted", + "bookmarks.edit_folder": "Edit folder", "boost_modal.combo": "You can press {combo} to skip this next time", "boost_modal.title": "Repost?", "bundle_column_error.body": "Something went wrong while loading this page.", @@ -484,6 +495,9 @@ "confirmations.delete.confirm": "Delete", "confirmations.delete.heading": "Delete post", "confirmations.delete.message": "Are you sure you want to delete this post?", + "confirmations.delete_bookmark_folder.confirm": "Delete folder", + "confirmations.delete_bookmark_folder.heading": "Delete \"{name}\" folder?", + "confirmations.delete_bookmark_folder.message": "Are you sure you want to delete the folder? The bookmarks will still be stored.", "confirmations.delete_event.confirm": "Delete", "confirmations.delete_event.heading": "Delete event", "confirmations.delete_event.message": "Are you sure you want to delete this event?", @@ -565,6 +579,8 @@ "directory.local": "From {domain} only", "directory.new_arrivals": "New arrivals", "directory.recently_active": "Recently active", + "edit_bookmark_folder_modal.confirm": "Save", + "edit_bookmark_folder_modal.header_title": "Edit folder", "edit_email.header": "Change Email", "edit_email.placeholder": "me@example.com", "edit_federation.followers_only": "Hide posts except to followers", @@ -1322,6 +1338,7 @@ "security.update_email.success": "Email successfully updated.", "security.update_password.fail": "Update password failed.", "security.update_password.success": "Password successfully updated.", + "select_bookmark_folder_modal.header_title": "Select folder", "settings.account_migration": "Move Account", "settings.blocks": "Blocks", "settings.change_email": "Change Email", @@ -1397,6 +1414,8 @@ "status.approval.pending": "Pending approval", "status.approval.rejected": "Rejected", "status.bookmark": "Bookmark", + "status.bookmark.select_folder": "Select folder", + "status.bookmark_folder_changed": "Changed folder", "status.bookmarked": "Bookmark added.", "status.cancel_reblog_private": "Un-repost", "status.cannot_reblog": "This post cannot be reposted", @@ -1525,12 +1544,9 @@ "upload_form.preview": "Preview", "upload_form.undo": "Delete", "upload_progress.label": "Uploading…", - "video.close": "Close video", "video.download": "Download file", "video.exit_fullscreen": "Exit full screen", - "video.expand": "Expand video", "video.fullscreen": "Full screen", - "video.hide": "Hide video", "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", diff --git a/src/reducers/status-lists.ts b/src/reducers/status-lists.ts index 38e19b009..d8339296f 100644 --- a/src/reducers/status-lists.ts +++ b/src/reducers/status-lists.ts @@ -67,7 +67,7 @@ import { } from '../actions/scheduled-statuses'; import type { AnyAction } from 'redux'; -import type { APIEntity } from 'soapbox/types/entities'; +import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities'; export const StatusListRecord = ImmutableRecord({ next: null as string | null, @@ -94,7 +94,8 @@ const getStatusIds = (statuses: APIEntity[] = []) => ( ImmutableOrderedSet(statuses.map(getStatusId)) ); -const setLoading = (state: State, listType: string, loading: boolean) => state.setIn([listType, 'isLoading'], loading); +const setLoading = (state: State, listType: string, loading: boolean) => + state.update(listType, StatusListRecord(), listMap => listMap.set('isLoading', loading)); const normalizeList = (state: State, listType: string, statuses: APIEntity[], next: string | null) => { return state.update(listType, StatusListRecord(), listMap => listMap.withMutations(map => { @@ -117,14 +118,14 @@ const appendToList = (state: State, listType: string, statuses: APIEntity[], nex const prependOneToList = (state: State, listType: string, status: APIEntity) => { const statusId = getStatusId(status); - return state.updateIn([listType, 'items'], ImmutableOrderedSet(), items => { + return state.update(listType, StatusListRecord(), listMap => listMap.update('items', items => { return ImmutableOrderedSet([statusId]).union(items as ImmutableOrderedSet); - }); + })); }; const removeOneFromList = (state: State, listType: string, status: APIEntity) => { const statusId = getStatusId(status); - return state.updateIn([listType, 'items'], ImmutableOrderedSet(), items => (items as ImmutableOrderedSet).delete(statusId)); + return state.update(listType, StatusListRecord(), listMap => listMap.update('items', items => items.delete(statusId))); }; const maybeAppendScheduledStatus = (state: State, status: APIEntity) => { @@ -132,6 +133,24 @@ const maybeAppendScheduledStatus = (state: State, status: APIEntity) => { return prependOneToList(state, 'scheduled_statuses', getStatusId(status)); }; +const addBookmarkToLists = (state: State, status: APIEntity) => { + state = prependOneToList(state, 'bookmarks', status); + const folderId = status.pleroma.bookmark_folder; + if (folderId) { + return prependOneToList(state, `bookmarks:${folderId}`, status); + } + return state; +}; + +const removeBookmarkFromLists = (state: State, status: StatusEntity) => { + state = removeOneFromList(state, 'bookmarks', status); + const folderId = status.pleroma.get('bookmark_folder'); + if (folderId) { + return removeOneFromList(state, `bookmarks:${folderId}`, status); + } + return state; +}; + export default function statusLists(state = initialState, action: AnyAction) { switch (action.type) { case FAVOURITED_STATUSES_FETCH_REQUEST: @@ -156,22 +175,22 @@ export default function statusLists(state = initialState, action: AnyAction) { return appendToList(state, `favourites:${action.accountId}`, action.statuses, action.next); case BOOKMARKED_STATUSES_FETCH_REQUEST: case BOOKMARKED_STATUSES_EXPAND_REQUEST: - return setLoading(state, 'bookmarks', true); + return setLoading(state, action.folderId ? `bookmarks:${action.folderId}` : 'bookmarks', true); case BOOKMARKED_STATUSES_FETCH_FAIL: case BOOKMARKED_STATUSES_EXPAND_FAIL: - return setLoading(state, 'bookmarks', false); + return setLoading(state, action.folderId ? `bookmarks:${action.folderId}` : 'bookmarks', false); case BOOKMARKED_STATUSES_FETCH_SUCCESS: - return normalizeList(state, 'bookmarks', action.statuses, action.next); + return normalizeList(state, action.folderId ? `bookmarks:${action.folderId}` : 'bookmarks', action.statuses, action.next); case BOOKMARKED_STATUSES_EXPAND_SUCCESS: - return appendToList(state, 'bookmarks', action.statuses, action.next); + return appendToList(state, action.folderId ? `bookmarks:${action.folderId}` : 'bookmarks', action.statuses, action.next); case FAVOURITE_SUCCESS: return prependOneToList(state, 'favourites', action.status); case UNFAVOURITE_SUCCESS: return removeOneFromList(state, 'favourites', action.status); case BOOKMARK_SUCCESS: - return prependOneToList(state, 'bookmarks', action.status); + return addBookmarkToLists(state, action.response); case UNBOOKMARK_SUCCESS: - return removeOneFromList(state, 'bookmarks', action.status); + return removeBookmarkFromLists(state, action.status); case PINNED_STATUSES_FETCH_SUCCESS: return normalizeList(state, 'pins', action.statuses, action.next); case PIN_SUCCESS: diff --git a/src/schemas/bookmark-folder.ts b/src/schemas/bookmark-folder.ts new file mode 100644 index 000000000..552e12e25 --- /dev/null +++ b/src/schemas/bookmark-folder.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +/** Pleroma bookmark folder. */ +const bookmarkFolderSchema = z.object({ + emoji: z.string().optional().catch(undefined), + emoji_url: z.string().optional().catch(undefined), + name: z.string().catch(''), + id: z.string(), +}); + +type BookmarkFolder = z.infer; + +export { bookmarkFolderSchema, type BookmarkFolder }; \ No newline at end of file diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 2fd3cc3de..6dc39e15a 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,5 +1,6 @@ export { accountSchema, type Account } from './account'; export { attachmentSchema, type Attachment } from './attachment'; +export { bookmarkFolderSchema, type BookmarkFolder } from './bookmark-folder'; export { cardSchema, type Card } from './card'; export { chatMessageSchema, type ChatMessage } from './chat-message'; export { customEmojiSchema, type CustomEmoji } from './custom-emoji'; diff --git a/src/schemas/status.ts b/src/schemas/status.ts index cc370871a..865b3a02e 100644 --- a/src/schemas/status.ts +++ b/src/schemas/status.ts @@ -30,6 +30,7 @@ const baseStatusSchema = z.object({ name: z.string(), website: z.string().url().nullable().catch(null), }).nullable().catch(null), + bookmark_folder: z.string().nullable().catch(null), bookmarked: z.coerce.boolean(), card: cardSchema.nullable().catch(null), content: contentSchema, diff --git a/src/toast.tsx b/src/toast.tsx index 49791b0fa..897f97429 100644 --- a/src/toast.tsx +++ b/src/toast.tsx @@ -9,7 +9,7 @@ import { httpErrorMessages } from './utils/errors'; export type ToastText = string | MessageDescriptor export type ToastType = 'success' | 'error' | 'info' -interface IToastOptions { +export interface IToastOptions { action?(): void; actionLink?: string; actionLabel?: ToastText; diff --git a/src/utils/features.ts b/src/utils/features.ts index 66aa66854..510f7c840 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -231,6 +231,15 @@ const getInstanceFeatures = (instance: Instance) => { /** Whether people who blocked you are visible through the API. */ blockersVisible: features.includes('blockers_visible'), + /** + * Can group bookmarks in folders. + * @see GET /api/v1/pleroma/bookmark_folders + * @see POST /api/v1/pleroma/bookmark_folders + * @see PATCH /api/v1/pleroma/bookmark_folders/:id + * @see DELETE /api/v1/pleroma/bookmark_folders/:id + */ + bookmarkFolders: features.includes('pleroma:bookmark_folders'), + /** * Can bookmark statuses. * @see POST /api/v1/statuses/:id/bookmark