import { VariableUiElement } from "../Base/VariableUIElement" import Toggle from "../Input/Toggle" import Translations from "../i18n/Translations" import Svg from "../../Svg" import DeleteAction from "../../Logic/Osm/Actions/DeleteAction" import { Store, UIEventSource } from "../../Logic/UIEventSource" import { TagsFilter } from "../../Logic/Tags/TagsFilter" import Combine from "../Base/Combine" import { SubtleButton } from "../Base/SubtleButton" import { Translation } from "../i18n/Translation" import BaseUIElement from "../BaseUIElement" import Constants from "../../Models/Constants" import DeleteConfig from "../../Models/ThemeConfig/DeleteConfig" import { OsmObject } from "../../Logic/Osm/OsmObject" import { OsmConnection } from "../../Logic/Osm/OsmConnection" import OsmChangeAction from "../../Logic/Osm/Actions/OsmChangeAction" import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction" import { InputElement } from "../Input/InputElement" import { RadioButton } from "../Input/RadioButton" import { FixedInputElement } from "../Input/FixedInputElement" import Title from "../Base/Title" import { SubstitutedTranslation } from "../SubstitutedTranslation" import FeaturePipelineState from "../../Logic/State/FeaturePipelineState" import TagRenderingQuestion from "./TagRenderingQuestion" import { OsmId } from "../../Models/OsmFeature" import { LoginToggle } from "./LoginButton" export default class DeleteWizard extends Toggle { /** * The UI-element which triggers 'deletion' (either soft or hard). * * - A 'hard deletion' is if the point is actually deleted from the OSM database * - A 'soft deletion' is if the point is not deleted, but the tagging is modified which will result in the point not being picked up by the filters anymore. * Apart having needing theme-specific tags added (which must be supplied by the theme creator), fixme='marked for deletion' will be added too * * A deletion is only possible if the user is logged in. * A soft deletion is only possible if tags are provided * A hard deletion is only possible if the user has sufficient rigts * * There is also the possibility to have a 'trojan horse' option. If the user selects that option, it is NEVER removed, but the tags are applied. * Ideal for the case of "THIS PATH IS ON MY GROUND AND SHOULD BE DELETED IMMEDIATELY OR I WILL GET MY LAWYER" but to mark it as private instead. * (Note that _delete_reason is used as trigger to do actual deletion - setting such a tag WILL delete from the database with that as changeset comment) * * @param id: The id of the element to remove * @param state: the state of the application * @param options softDeletionTags: the tags to apply if the user doesn't have permission to delete, e.g. 'disused:amenity=public_bookcase', 'amenity='. After applying, the element should not be picked up on the map anymore. If undefined, the wizard will only show up if the point can be (hard) deleted */ constructor(id: OsmId, state: FeaturePipelineState, options: DeleteConfig) { const deleteAbility = new DeleteabilityChecker(id, state, options.neededChangesets) const tagsSource = state.allElements.getEventSourceById(id) const isDeleted = new UIEventSource(false) const allowSoftDeletion = !!options.softDeletionTags const confirm = new UIEventSource(false) /** * This function is the actual delete function */ function doDelete(selected: { deleteReason: string } | { retagTo: TagsFilter }) { let actionToTake: OsmChangeAction if (selected["retagTo"] !== undefined) { // no _delete_reason is given, which implies that this is _not_ a deletion but merely a retagging via a nonDeleteMapping actionToTake = new ChangeTagAction(id, selected["retagTo"], tagsSource.data, { theme: state?.layoutToUse?.id ?? "unkown", changeType: "special-delete", }) } else { actionToTake = new DeleteAction( id, options.softDeletionTags, { theme: state?.layoutToUse?.id ?? "unkown", specialMotivation: selected["deleteReason"], }, deleteAbility.canBeDeleted.data.canBeDeleted ) } state.changes?.applyAction(actionToTake) isDeleted.setData(true) } const t = Translations.t.delete const cancelButton = t.cancel .SetClass("block btn btn-secondary") .onClick(() => confirm.setData(false)) /** * The button which is shown first. Opening it will trigger the check for deletions */ const deleteButton = new SubtleButton( Svg.delete_icon_svg().SetStyle("width: 1.5rem; height: 1.5rem;"), t.delete ).onClick(() => { deleteAbility.CheckDeleteability(true) confirm.setData(true) }) const isShown: Store = tagsSource.map((tgs) => tgs.id.indexOf("-") < 0) const deleteOptionPicker = DeleteWizard.constructMultipleChoice(options, tagsSource, state) const deleteDialog = new Combine([ new Title( new SubstitutedTranslation(t.whyDelete, tagsSource, state).SetClass( "question-text" ), 3 ), deleteOptionPicker, new Combine([ DeleteWizard.constructExplanation( deleteOptionPicker.GetValue(), deleteAbility, tagsSource, state ), new Combine([ cancelButton, DeleteWizard.constructConfirmButton(deleteOptionPicker.GetValue()).onClick(() => doDelete(deleteOptionPicker.GetValue().data) ), ]).SetClass("flex justify-end flex-wrap-reverse"), ]).SetClass("flex mt-2 justify-between"), ]).SetClass("question") const deleteFlow = new Toggle( new Toggle( new Toggle( deleteDialog, new SubtleButton(Svg.envelope_ui(), t.readMessages), state.osmConnection.userDetails.map( (ud) => ud.csCount > Constants.userJourney.addNewPointWithUnreadMessagesUnlock || ud.unreadMessages == 0 ) ), deleteButton, confirm ), new VariableUiElement( deleteAbility.canBeDeleted.map((cbd) => new Combine([ Svg.delete_not_allowed_svg() .SetStyle("height: 2rem; width: auto") .SetClass("mr-2"), new Combine([ t.cannotBeDeleted, cbd.reason.SetClass("subtle"), t.useSomethingElse.SetClass("subtle"), ]).SetClass("flex flex-col"), ]).SetClass("flex m-2 p-2 rounded-lg bg-gray-200 bg-gray-200") ) ), deleteAbility.canBeDeleted.map((cbd) => allowSoftDeletion || cbd.canBeDeleted !== false) ) super( new Toggle( new Combine([ Svg.delete_icon_svg().SetClass( "h-16 w-16 p-2 m-2 block bg-gray-300 rounded-full" ), t.isDeleted, ]).SetClass("flex m-2 rounded-full"), new LoginToggle(deleteFlow, undefined, state), isDeleted ), undefined, isShown ) const self = this confirm.addCallbackAndRunD((dialogIsOpened) => { if (dialogIsOpened) { self.ScrollIntoView() } }) } private static constructConfirmButton( deleteReasons: UIEventSource ): BaseUIElement { const t = Translations.t.delete const btn = new Combine([ Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"), t.delete, ]).SetClass("flex btn bg-red-500") const btnNonActive = new Combine([ Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"), t.delete, ]).SetClass("flex btn btn-disabled bg-red-200") return new Toggle( btn, btnNonActive, deleteReasons.map((reason) => reason !== undefined) ) } private static constructExplanation( selectedOption: UIEventSource<{ deleteReason: string } | { retagTo: TagsFilter }>, deleteAction: DeleteabilityChecker, currentTags: UIEventSource, state?: { osmConnection?: OsmConnection } ) { const t = Translations.t.delete return new VariableUiElement( selectedOption.map( (selectedOption) => { if (selectedOption === undefined) { return t.explanations.selectReason.SetClass("subtle") } const retag: TagsFilter | undefined = selectedOption["retagTo"] if (retag !== undefined) { // This is a retagging, not a deletion of any kind return new Combine([ t.explanations.retagNoOtherThemes, TagRenderingQuestion.CreateTagExplanation( new UIEventSource(retag), currentTags, state ).SetClass("subtle"), ]) } const deleteReason = selectedOption["deleteReason"] if (deleteReason !== undefined) { return new VariableUiElement( deleteAction.canBeDeleted.map(({ canBeDeleted, reason }) => { if (canBeDeleted) { // This is a hard delete for which we give an explanation return t.explanations.hardDelete } // This is a soft deletion: we explain _why_ the deletion is soft return t.explanations.softDelete.Subs({ reason: reason }) }) ) } }, [deleteAction.canBeDeleted] ) ).SetClass("block") } private static constructMultipleChoice( config: DeleteConfig, tagsSource: UIEventSource>, state: FeaturePipelineState ): InputElement<{ deleteReason: string } | { retagTo: TagsFilter }> { const elements: InputElement<{ deleteReason: string } | { retagTo: TagsFilter }>[] = [] for (const nonDeleteOption of config.nonDeleteMappings) { elements.push( new FixedInputElement( new SubstitutedTranslation(nonDeleteOption.then, tagsSource, state), { retagTo: nonDeleteOption.if, } ) ) } for (const extraDeleteReason of config.deleteReasons ?? []) { elements.push( new FixedInputElement( new SubstitutedTranslation(extraDeleteReason.explanation, tagsSource, state), { deleteReason: extraDeleteReason.changesetMessage, } ) ) } return new RadioButton(elements, { selectFirstAsDefault: false }) } } class DeleteabilityChecker { public readonly canBeDeleted: UIEventSource<{ canBeDeleted?: boolean; reason: Translation }> private readonly _id: string private readonly _allowDeletionAtChangesetCount: number private readonly _state: { osmConnection: OsmConnection } constructor( id: string, state: { osmConnection: OsmConnection }, allowDeletionAtChangesetCount?: number ) { this._id = id this._state = state this._allowDeletionAtChangesetCount = allowDeletionAtChangesetCount ?? Number.MAX_VALUE this.canBeDeleted = new UIEventSource<{ canBeDeleted?: boolean; reason: Translation }>({ canBeDeleted: undefined, reason: Translations.t.delete.loading, }) this.CheckDeleteability(false) } /** * Checks if the currently logged in user can delete the current point. * State is written into this._canBeDeleted * @constructor * @private */ public CheckDeleteability(useTheInternet: boolean): void { const t = Translations.t.delete const id = this._id const state = this.canBeDeleted const self = this if (!id.startsWith("node")) { this.canBeDeleted.setData({ canBeDeleted: false, reason: t.isntAPoint, }) return } // Does the currently logged in user have enough experience to delete this point? const deletingPointsOfOtherAllowed = this._state.osmConnection.userDetails.map((ud) => { if (ud === undefined) { return undefined } if (!ud.loggedIn) { return false } return ( ud.csCount >= Math.min( Constants.userJourney.deletePointsOfOthersUnlock, this._allowDeletionAtChangesetCount ) ) }) const previousEditors = new UIEventSource(undefined) const allByMyself = previousEditors.map( (previous) => { if (previous === null || previous === undefined) { // Not yet downloaded return null } const userId = self._state.osmConnection.userDetails.data.uid return !previous.some((editor) => editor !== userId) }, [self._state.osmConnection.userDetails] ) // User allowed OR only edited by self? const deletetionAllowed = deletingPointsOfOtherAllowed.map( (isAllowed) => { if (isAllowed === undefined) { // No logged in user => definitively not allowed to delete! return false } if (isAllowed === true) { return true } // At this point, the logged in user is not allowed to delete points created/edited by _others_ // however, we query OSM and if it turns out the current point has only be edited by the current user, deletion is allowed after all! if (allByMyself.data === null && useTheInternet) { // We kickoff the download here as it hasn't yet been downloaded. Note that this is mapped onto 'all by myself' above const hist = OsmObject.DownloadHistory(id).map((versions) => versions.map((version) => Number(version.tags["_last_edit:contributor:uid"]) ) ) hist.addCallbackAndRunD((hist) => previousEditors.setData(hist)) } if (allByMyself.data === true) { // Yay! We can download! return true } if (allByMyself.data === false) { // Nope, downloading not allowed... return false } // At this point, we don't have enough information yet to decide if the user is allowed to delete the current point... return undefined }, [allByMyself] ) const hasRelations: UIEventSource = new UIEventSource(null) const hasWays: UIEventSource = new UIEventSource(null) deletetionAllowed.addCallbackAndRunD((deletetionAllowed) => { if (deletetionAllowed === false) { // Nope, we are not allowed to delete state.setData({ canBeDeleted: false, reason: t.notEnoughExperience, }) return true // unregister this caller! } if (!useTheInternet) { return } // All right! We have arrived at a point that we should query OSM again to check that the point isn't a part of ways or relations OsmObject.DownloadReferencingRelations(id).then((rels) => { hasRelations.setData(rels.length > 0) }) OsmObject.DownloadReferencingWays(id).then((ways) => { hasWays.setData(ways.length > 0) }) return true // unregister to only run once }) const hasWaysOrRelations = hasRelations.map( (hasRelationsData) => { if (hasRelationsData === true) { return true } if (hasWays.data === true) { return true } if (hasWays.data === null || hasRelationsData === null) { return null } if (hasWays.data === false && hasRelationsData === false) { return false } return null }, [hasWays] ) hasWaysOrRelations.addCallbackAndRun((waysOrRelations) => { if (waysOrRelations == null) { // Not yet loaded - we still wait a little bit return } if (waysOrRelations) { // not deleteable by mapcomplete state.setData({ canBeDeleted: false, reason: t.partOfOthers, }) } else { // alright, this point can be safely deleted! state.setData({ canBeDeleted: true, reason: allByMyself.data === true ? t.onlyEditedByLoggedInUser : t.safeDelete, }) } }) } }