diff --git a/backend/src/history/history-entry.dto.ts b/backend/src/history/history-entry.dto.ts index eaf2e1b5a..c5ae719fa 100644 --- a/backend/src/history/history-entry.dto.ts +++ b/backend/src/history/history-entry.dto.ts @@ -5,7 +5,13 @@ */ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsDate, IsString } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsDate, + IsOptional, + IsString, +} from 'class-validator'; import { BaseDto } from '../utils/base.dto.'; @@ -26,6 +32,16 @@ export class HistoryEntryDto extends BaseDto { @ApiProperty() title: string; + /** + * The username of the owner of the note + * Might be null for anonymous notes + * @example "alice" + */ + @IsOptional() + @IsString() + @ApiProperty() + owner: string | null; + /** * Datestring of the last time this note was updated * @example "2020-12-01 12:23:34" diff --git a/backend/src/history/history.service.ts b/backend/src/history/history.service.ts index 57928333c..93356e807 100644 --- a/backend/src/history/history.service.ts +++ b/backend/src/history/history.service.ts @@ -180,6 +180,7 @@ export class HistoryService { */ async toHistoryEntryDto(entry: HistoryEntry): Promise { const note = await entry.note; + const owner = await note.owner; const revision = await this.revisionsService.getLatestRevision(note); return { identifier: await getIdentifier(entry), @@ -187,6 +188,7 @@ export class HistoryService { tags: (await revision.tags).map((tag) => tag.name), title: revision.title ?? '', pinStatus: entry.pinStatus, + owner: owner ? owner.username : null, }; } } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b77d8a8ad..3bf141350 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -445,7 +445,9 @@ "title": "Delete note", "question": "Do you really want to delete this note?", "warning": "All users will lose their connection. This process is irreversible.", - "button": "Delete note" + "deleteButton": "Delete note and uploads", + "deleteButtonKeepMedia": "Delete note but keep uploads", + "keepMedia": "Keep uploads?" }, "permissions": { "title": "Permissions", diff --git a/frontend/src/api/history/types.ts b/frontend/src/api/history/types.ts index 9b8f0e2f1..9b1d972e3 100644 --- a/frontend/src/api/history/types.ts +++ b/frontend/src/api/history/types.ts @@ -17,6 +17,7 @@ export interface HistoryEntryPutDto { export interface HistoryEntry { identifier: string title: string + owner: string | null lastVisitedAt: string tags: string[] pinStatus: boolean diff --git a/frontend/src/api/notes/index.ts b/frontend/src/api/notes/index.ts index e49f80264..8f1dcab3a 100644 --- a/frontend/src/api/notes/index.ts +++ b/frontend/src/api/notes/index.ts @@ -79,14 +79,13 @@ export const createNoteWithPrimaryAlias = async (markdown: string, primaryAlias: * Deletes the specified note. * * @param noteIdOrAlias The id or alias of the note to delete. + * @param keepMedia Whether to keep the uploaded media associated with the note. * @throws {Error} when the api request wasn't successful. */ -export const deleteNote = async (noteIdOrAlias: string): Promise => { +export const deleteNote = async (noteIdOrAlias: string, keepMedia: boolean): Promise => { await new DeleteApiRequestBuilder('notes/' + noteIdOrAlias) .withJsonBody({ - keepMedia: false - // TODO Ask whether the user wants to keep the media uploaded to the note. - // https://github.com/hedgedoc/hedgedoc/issues/2928 + keepMedia }) .sendRequest() } diff --git a/frontend/src/components/common/modals/deletion-modal.tsx b/frontend/src/components/common/modals/deletion-modal.tsx index 96900c99a..43efffc2d 100644 --- a/frontend/src/components/common/modals/deletion-modal.tsx +++ b/frontend/src/components/common/modals/deletion-modal.tsx @@ -15,6 +15,7 @@ export interface DeletionModalProps extends CommonModalProps { onConfirm: () => void deletionButtonI18nKey: string disabled?: boolean + footerContent?: React.ReactNode } /** @@ -39,6 +40,7 @@ export const DeletionModal: React.FC> = ({ titleIcon, children, disabled = false, + footerContent, ...props }) => { useTranslation() @@ -52,6 +54,7 @@ export const DeletionModal: React.FC> = ({ {...props}> {children} + {footerContent} diff --git a/frontend/src/components/editor-page/hooks/use-update-local-history-entry.ts b/frontend/src/components/editor-page/hooks/use-update-local-history-entry.ts index 55fc596a5..d4b2e7270 100644 --- a/frontend/src/components/editor-page/hooks/use-update-local-history-entry.ts +++ b/frontend/src/components/editor-page/hooks/use-update-local-history-entry.ts @@ -20,7 +20,7 @@ export const useUpdateLocalHistoryEntry = (): void => { const userExists = useApplicationState((state) => !!state.user) const currentNoteTitle = useApplicationState((state) => state.noteDetails?.title ?? '') const currentNoteTags = useApplicationState((state) => state.noteDetails?.frontmatter.tags ?? []) - + const currentNoteOwner = useApplicationState((state) => state.noteDetails?.permissions.owner) const lastNoteTitle = useRef('') const lastNoteTags = useRef([]) @@ -38,7 +38,8 @@ export const useUpdateLocalHistoryEntry = (): void => { pinStatus: false, lastVisitedAt: '', tags: [], - origin: HistoryEntryOrigin.LOCAL + origin: HistoryEntryOrigin.LOCAL, + owner: null } if (entry.origin === HistoryEntryOrigin.REMOTE) { return @@ -46,9 +47,10 @@ export const useUpdateLocalHistoryEntry = (): void => { const updatedEntry = { ...entry } updatedEntry.title = currentNoteTitle updatedEntry.tags = currentNoteTags + updatedEntry.owner = currentNoteOwner updatedEntry.lastVisitedAt = new Date().toISOString() updateLocalHistoryEntry(id, updatedEntry) lastNoteTitle.current = currentNoteTitle lastNoteTags.current = currentNoteTags - }, [id, userExists, currentNoteTitle, currentNoteTags]) + }, [id, userExists, currentNoteTitle, currentNoteTags, currentNoteOwner]) } diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal.tsx index b08219025..e4c26ec69 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal.tsx @@ -7,20 +7,14 @@ import { useNoteTitle } from '../../../../../hooks/common/use-note-title' import { cypressId } from '../../../../../utils/cypress-attribute' import type { ModalVisibilityProps } from '../../../../common/modals/common-modal' import { DeletionModal } from '../../../../common/modals/deletion-modal' -import React from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { Trans } from 'react-i18next' import { useIsOwner } from '../../../../../hooks/common/use-is-owner' -export interface DeleteHistoryNoteModalProps { - modalTitleI18nKey?: string - modalQuestionI18nKey?: string - modalWarningI18nKey?: string - modalButtonI18nKey?: string -} - export interface DeleteNoteModalProps extends ModalVisibilityProps { optionalNoteTitle?: string - onConfirm: () => void + onConfirm: (keepMedia: boolean) => void + overrideIsOwner?: boolean } /** @@ -31,40 +25,53 @@ export interface DeleteNoteModalProps extends ModalVisibilityProps { * @param onHide A callback that fires if the modal should be hidden without confirmation * @param onConfirm A callback that fires if the user confirmed the request * @param modalTitleI18nKey optional i18nKey for the title - * @param modalQuestionI18nKey optional i18nKey for the question - * @param modalWarningI18nKey optional i18nKey for the warning - * @param modalButtonI18nKey optional i18nKey for the button */ -export const DeleteNoteModal: React.FC = ({ +export const DeleteNoteModal: React.FC = ({ optionalNoteTitle, show, onHide, onConfirm, - modalTitleI18nKey, - modalQuestionI18nKey, - modalWarningI18nKey, - modalButtonI18nKey + overrideIsOwner }) => { + const [keepMedia, setKeepMedia] = useState(false) const noteTitle = useNoteTitle() - const isOwner = useIsOwner() + const isOwnerOfCurrentEditedNote = useIsOwner() + + const deletionButtonI18nKey = useMemo(() => { + return keepMedia ? 'editor.modal.deleteNote.deleteButtonKeepMedia' : 'editor.modal.deleteNote.deleteButton' + }, [keepMedia]) + + const handleConfirm = useCallback(() => { + onConfirm(keepMedia) + }, [onConfirm, keepMedia]) + + const isOwner = overrideIsOwner ?? isOwnerOfCurrentEditedNote return ( + titleI18nKey={'editor.modal.deleteNote.title'} + footerContent={ + + }>
- +
  • {optionalNoteTitle ?? noteTitle}
- +
) diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-sidebar-entry.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-sidebar-entry.tsx index e877fab02..8cc534bb1 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-sidebar-entry.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-sidebar-entry.tsx @@ -32,15 +32,18 @@ export const DeleteNoteSidebarEntry: React.FC { - if (noteId === undefined) { - return - } - deleteNote(noteId) - .then(() => router.push('/history')) - .catch(showErrorNotification('landing.history.error.deleteNote.text')) - .finally(closeModal) - }, [closeModal, noteId, router, showErrorNotification]) + const deleteNoteAndCloseDialog = useCallback( + (keepMedia: boolean) => { + if (noteId === undefined) { + return + } + deleteNote(noteId, keepMedia) + .then(() => router.push('/history')) + .catch(showErrorNotification('landing.history.error.deleteNote.text')) + .finally(closeModal) + }, + [closeModal, noteId, router, showErrorNotification] + ) if (!userIsOwner) { return null diff --git a/frontend/src/components/history-page/entry-menu/delete-note-item.tsx b/frontend/src/components/history-page/entry-menu/delete-note-item.tsx index 7f67d68ee..9e4d70210 100644 --- a/frontend/src/components/history-page/entry-menu/delete-note-item.tsx +++ b/frontend/src/components/history-page/entry-menu/delete-note-item.tsx @@ -3,13 +3,18 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { DropdownItemWithDeletionModal } from './dropdown-item-with-deletion-modal' -import React from 'react' +import React, { Fragment } from 'react' import { Trash as IconTrash } from 'react-bootstrap-icons' +import { Dropdown } from 'react-bootstrap' +import { UiIcon } from '../../common/icons/ui-icon' +import { Trans } from 'react-i18next' +import { DeleteNoteModal } from '../../editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal' +import { useBooleanState } from '../../../hooks/common/use-boolean-state' export interface DeleteNoteItemProps { - onConfirm: () => void + onConfirm: (keepMedia: boolean) => void noteTitle: string + isOwner: boolean } /** @@ -18,13 +23,21 @@ export interface DeleteNoteItemProps { * @param noteTitle The title of the note to delete to show it in the deletion confirmation modal * @param onConfirm The callback that is fired when the deletion is confirmed */ -export const DeleteNoteItem: React.FC = ({ noteTitle, onConfirm }) => { +export const DeleteNoteItem: React.FC = ({ noteTitle, onConfirm, isOwner }) => { + const [isModalVisible, showModal, hideModal] = useBooleanState() return ( - + + + + + + + ) } diff --git a/frontend/src/components/history-page/entry-menu/dropdown-item-with-deletion-modal.tsx b/frontend/src/components/history-page/entry-menu/dropdown-item-with-deletion-modal.tsx deleted file mode 100644 index 8299d75b8..000000000 --- a/frontend/src/components/history-page/entry-menu/dropdown-item-with-deletion-modal.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { useBooleanState } from '../../../hooks/common/use-boolean-state' -import { UiIcon } from '../../common/icons/ui-icon' -import type { DeleteHistoryNoteModalProps } from '../../editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal' -import { DeleteNoteModal } from '../../editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal' -import React, { Fragment, useCallback } from 'react' -import { Dropdown } from 'react-bootstrap' -import type { Icon } from 'react-bootstrap-icons' -import { Trans, useTranslation } from 'react-i18next' - -export interface DropdownItemWithDeletionModalProps { - onConfirm: () => void - itemI18nKey: string - modalIcon: Icon - noteTitle: string - className?: string -} - -/** - * Renders a dropdown item and the corresponding deletion modal. - * - * @param onConfirm A callback that fires if the user confirmed the request - * @param noteTitle The note title to be displayed - * @param modalTitleI18nKey The i18nKey for title to be shown in the modal - * @param modalButtonI18nKey The i18nKey for button to be shown in the modal - * @param itemI18nKey The i18nKey for the dropdown item - * @param modalIcon The icon for the dropdown item - * @param modalQuestionI18nKey The i18nKey for question to be shown in the modal - * @param modalWarningI18nKey The i18nKey for warning to be shown in the modal - * @param className Additional classes given to the dropdown item - */ -export const DropdownItemWithDeletionModal: React.FC< - DropdownItemWithDeletionModalProps & DeleteHistoryNoteModalProps -> = ({ - onConfirm, - noteTitle, - modalTitleI18nKey, - modalButtonI18nKey, - itemI18nKey, - modalIcon, - modalQuestionI18nKey, - modalWarningI18nKey, - className -}) => { - useTranslation() - const [modalVisibility, showModal, closeModal] = useBooleanState() - - const handleConfirm = useCallback(() => { - closeModal() - onConfirm() - }, [closeModal, onConfirm]) - - return ( - - - - - - - - ) -} diff --git a/frontend/src/components/history-page/entry-menu/entry-menu.tsx b/frontend/src/components/history-page/entry-menu/entry-menu.tsx index f591e3f2a..b4d7567e9 100644 --- a/frontend/src/components/history-page/entry-menu/entry-menu.tsx +++ b/frontend/src/components/history-page/entry-menu/entry-menu.tsx @@ -14,13 +14,15 @@ import { Dropdown } from 'react-bootstrap' import { Cloud as IconCloud, Laptop as IconLaptop, ThreeDots as IconThreeDots } from 'react-bootstrap-icons' import { Trans, useTranslation } from 'react-i18next' import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in' +import { useApplicationState } from '../../../hooks/common/use-application-state' export interface EntryMenuProps { id: string title: string origin: HistoryEntryOrigin + noteOwner: string | null onRemoveFromHistory: () => void - onDeleteNote: () => void + onDeleteNote: (keepMedia: boolean) => void className?: string } @@ -30,6 +32,7 @@ export interface EntryMenuProps { * @param id The unique identifier of the history entry. * @param title The title of the note of the history entry. * @param origin The origin of the entry. Must be either {@link HistoryEntryOrigin.LOCAL} or {@link HistoryEntryOrigin.REMOTE}. + * @param noteOwner The username of the note owner. * @param onRemoveFromHistory Callback that is fired when the entry should be removed from the history. * @param onDeleteNote Callback that is fired when the note should be deleted. * @param className Additional CSS classes to add to the dropdown. @@ -38,12 +41,14 @@ export const EntryMenu: React.FC = ({ id, title, origin, + noteOwner, onRemoveFromHistory, onDeleteNote, className }) => { useTranslation() const userExists = useIsLoggedIn() + const currentUsername = useApplicationState((state) => state.user?.username) return ( @@ -75,11 +80,10 @@ export const EntryMenu: React.FC = ({ - {/* TODO Check permissions (ownership) before showing option for delete (https://github.com/hedgedoc/hedgedoc/issues/5036)*/} - {userExists && ( + {userExists && currentUsername === noteOwner && ( <> - + )} diff --git a/frontend/src/components/history-page/entry-menu/remove-note-entry-item.tsx b/frontend/src/components/history-page/entry-menu/remove-note-entry-item.tsx index d47bfa848..a1898bf2d 100644 --- a/frontend/src/components/history-page/entry-menu/remove-note-entry-item.tsx +++ b/frontend/src/components/history-page/entry-menu/remove-note-entry-item.tsx @@ -3,10 +3,13 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { cypressId } from '../../../utils/cypress-attribute' -import { DropdownItemWithDeletionModal } from './dropdown-item-with-deletion-modal' -import React from 'react' +import React, { Fragment } from 'react' import { Archive as IconArchive } from 'react-bootstrap-icons' +import { useBooleanState } from '../../../hooks/common/use-boolean-state' +import { Dropdown } from 'react-bootstrap' +import { UiIcon } from '../../common/icons/ui-icon' +import { Trans } from 'react-i18next' +import { DeletionModal } from '../../common/modals/deletion-modal' export interface RemoveNoteEntryItemProps { onConfirm: () => void @@ -20,17 +23,29 @@ export interface RemoveNoteEntryItemProps { * @param onConfirm The callback to delete the note */ export const RemoveNoteEntryItem: React.FC = ({ noteTitle, onConfirm }) => { + const [isModalVisible, showModal, hideModal] = useBooleanState() return ( - + + + + + + +
+ +
+
    +
  • {noteTitle}
  • +
+
+ +
+
+
) } diff --git a/frontend/src/components/history-page/history-card/history-card.tsx b/frontend/src/components/history-page/history-card/history-card.tsx index e8569ed13..d04c3589e 100644 --- a/frontend/src/components/history-page/history-card/history-card.tsx +++ b/frontend/src/components/history-page/history-card/history-card.tsx @@ -36,9 +36,12 @@ export const HistoryCard: React.FC = ( onRemoveEntryClick(entry.identifier) }, [onRemoveEntryClick, entry.identifier]) - const onDeleteNote = useCallback(() => { - onDeleteNoteClick(entry.identifier) - }, [onDeleteNoteClick, entry.identifier]) + const onDeleteNote = useCallback( + (keepMedia: boolean) => { + onDeleteNoteClick(entry.identifier, keepMedia) + }, + [onDeleteNoteClick, entry.identifier] + ) const onPinEntry = useCallback(() => { onPinClick(entry.identifier) @@ -93,6 +96,7 @@ export const HistoryCard: React.FC = ( origin={entry.origin} onRemoveFromHistory={onRemoveEntry} onDeleteNote={onDeleteNote} + noteOwner={entry.owner} /> diff --git a/frontend/src/components/history-page/history-content/history-content.tsx b/frontend/src/components/history-page/history-content/history-content.tsx index f1bd332bc..236e116de 100644 --- a/frontend/src/components/history-page/history-content/history-content.tsx +++ b/frontend/src/components/history-page/history-content/history-content.tsx @@ -23,7 +23,7 @@ type OnEntryClick = (entryId: string) => void export interface HistoryEventHandlers { onPinClick: OnEntryClick onRemoveEntryClick: OnEntryClick - onDeleteNoteClick: OnEntryClick + onDeleteNoteClick: (entryId: string, keepMedia: boolean) => void } export interface HistoryEntryProps { @@ -61,8 +61,8 @@ export const HistoryContent: React.FC = () => { ) const onDeleteClick = useCallback( - (noteId: string) => { - deleteNote(noteId) + (noteId: string, keepMedia: boolean) => { + deleteNote(noteId, keepMedia) .then(() => removeHistoryEntry(noteId)) .catch(showErrorNotification('landing.history.error.deleteNote.text')) }, diff --git a/frontend/src/components/history-page/history-table/history-table-row.tsx b/frontend/src/components/history-page/history-table/history-table-row.tsx index 1693bffa1..6c816697f 100644 --- a/frontend/src/components/history-page/history-table/history-table-row.tsx +++ b/frontend/src/components/history-page/history-table/history-table-row.tsx @@ -37,9 +37,12 @@ export const HistoryTableRow: React.FC onRemoveEntryClick(entry.identifier) }, [onRemoveEntryClick, entry.identifier]) - const onDeleteNote = useCallback(() => { - onDeleteNoteClick(entry.identifier) - }, [onDeleteNoteClick, entry.identifier]) + const onDeleteNote = useCallback( + (keepMedia: boolean) => { + onDeleteNoteClick(entry.identifier, keepMedia) + }, + [onDeleteNoteClick, entry.identifier] + ) return ( @@ -63,6 +66,7 @@ export const HistoryTableRow: React.FC id={entry.identifier} title={entryTitle} origin={entry.origin} + noteOwner={entry.owner} onRemoveFromHistory={onEntryRemove} onDeleteNote={onDeleteNote} /> diff --git a/frontend/src/pages/api/private/me/history.ts b/frontend/src/pages/api/private/me/history.ts index 631690a3a..f9b83b76c 100644 --- a/frontend/src/pages/api/private/me/history.ts +++ b/frontend/src/pages/api/private/me/history.ts @@ -14,28 +14,32 @@ const handler = (req: NextApiRequest, res: NextApiResponse) => { title: 'Slide example', lastVisitedAt: '2020-05-30T15:20:36.088Z', pinStatus: true, - tags: ['features', 'cool', 'updated'] + tags: ['features', 'cool', 'updated'], + owner: null }, { identifier: 'features', title: 'Features', lastVisitedAt: '2020-05-31T15:20:36.088Z', pinStatus: true, - tags: ['features', 'cool', 'updated'] + tags: ['features', 'cool', 'updated'], + owner: null }, { identifier: 'ODakLc2MQkyyFc_Xmb53sg', title: 'Non existent', lastVisitedAt: '2020-05-25T19:48:14.025Z', pinStatus: false, - tags: [] + tags: [], + owner: null }, { identifier: 'l8JuWxApTR6Fqa0LCrpnLg', title: 'Non existent', lastVisitedAt: '2020-05-24T16:04:36.433Z', pinStatus: false, - tags: ['agenda', 'HedgeDoc community', 'community call'] + tags: ['agenda', 'HedgeDoc community', 'community call'], + owner: 'test' } ]) } diff --git a/frontend/src/redux/history/methods.ts b/frontend/src/redux/history/methods.ts index 013b4fa73..2c8a3ac2a 100644 --- a/frontend/src/redux/history/methods.ts +++ b/frontend/src/redux/history/methods.ts @@ -151,7 +151,8 @@ export const convertV1History = (oldHistory: V1HistoryEntry[]): HistoryEntryWith tags: entry.tags, lastVisitedAt: DateTime.fromMillis(entry.time).toISO(), pinStatus: entry.pinned, - origin: HistoryEntryOrigin.LOCAL + origin: HistoryEntryOrigin.LOCAL, + owner: null })) }