Feature(reviews): support deleting and editing reviews, fix #2129

This commit is contained in:
Pieter Vander Vennet 2025-07-11 01:22:08 +02:00
parent 3ee9ee9d88
commit 06ff00f7f7
8 changed files with 165 additions and 29 deletions

View file

@ -802,6 +802,11 @@
"affiliated_reviewer_warning": "(Affiliated review)", "affiliated_reviewer_warning": "(Affiliated review)",
"attribution": "By Mangrove.reviews", "attribution": "By Mangrove.reviews",
"averageRating": "Average rating of {n} stars", "averageRating": "Average rating of {n} stars",
"delete": "Delete review",
"deleteConfirm": "Permantently delete this review",
"deleteText": "This cannot be undone",
"deleteTitle": "Delete this review?",
"edit": "Edit review",
"i_am_affiliated": "I am affiliated with this object", "i_am_affiliated": "I am affiliated with this object",
"i_am_affiliated_explanation": "Check if you are an owner, creator, employee, …", "i_am_affiliated_explanation": "Check if you are an owner, creator, employee, …",
"no_reviews_yet": "There are no reviews yet. Be the first one!", "no_reviews_yet": "There are no reviews yet. Be the first one!",

9
package-lock.json generated
View file

@ -61,7 +61,7 @@
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"latlon2country": "^1.2.7", "latlon2country": "^1.2.7",
"libphonenumber-js": "^1.11.19", "libphonenumber-js": "^1.11.19",
"mangrove-reviews-typescript": "^1.3.1", "mangrove-reviews-typescript": "^1.4.6",
"maplibre-gl": "^5.1.0", "maplibre-gl": "^5.1.0",
"marked": "^12.0.2", "marked": "^12.0.2",
"monaco-editor": "^0.46.0", "monaco-editor": "^0.46.0",
@ -19525,9 +19525,10 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/mangrove-reviews-typescript": { "node_modules/mangrove-reviews-typescript": {
"version": "1.3.1", "version": "1.4.6",
"resolved": "https://registry.npmjs.org/mangrove-reviews-typescript/-/mangrove-reviews-typescript-1.3.1.tgz", "resolved": "https://registry.npmjs.org/mangrove-reviews-typescript/-/mangrove-reviews-typescript-1.4.6.tgz",
"integrity": "sha512-xgU4uvJ0zQTRMoA4exje509FS19jN3u/buGBSUGh1TcTQBKWBGSmaT0cnHzcH9lhg6J8LCTsgf3Mv07Y87O9/w==", "integrity": "sha512-ZUM6Dath7OQEBz2oL8MKC+kMAEjIsoY0mPgAzXBcDLdKafFFOx2j6YCrMVu5RAsBhZLT77isFmbv1Ty5jKxMag==",
"license": "GPL-2.0-only",
"dependencies": { "dependencies": {
"axios": "^1.2.3", "axios": "^1.2.3",
"jose": "^4.11.2", "jose": "^4.11.2",

View file

@ -225,7 +225,7 @@
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"latlon2country": "^1.2.7", "latlon2country": "^1.2.7",
"libphonenumber-js": "^1.11.19", "libphonenumber-js": "^1.11.19",
"mangrove-reviews-typescript": "^1.3.1", "mangrove-reviews-typescript": "^1.4.6",
"maplibre-gl": "^5.1.0", "maplibre-gl": "^5.1.0",
"marked": "^12.0.2", "marked": "^12.0.2",
"monaco-editor": "^0.46.0", "monaco-editor": "^0.46.0",

View file

@ -150,7 +150,7 @@ export default class FeatureReviews {
private readonly _reviews: UIEventSource< private readonly _reviews: UIEventSource<
(Review & { kid: string; signature: string; madeByLoggedInUser: Store<boolean> })[] (Review & { kid: string; signature: string; madeByLoggedInUser: Store<boolean> })[]
> = new UIEventSource([]) > = new UIEventSource([])
public readonly reviews: Store<(Review & { madeByLoggedInUser: Store<boolean> })[]> = public readonly reviews: Store<(Review & { signature: string, madeByLoggedInUser: Store<boolean> })[]> =
this._reviews this._reviews
private readonly _lat: number private readonly _lat: number
private readonly _lon: number private readonly _lon: number
@ -361,9 +361,10 @@ export default class FeatureReviews {
const keypair: CryptoKeyPair = await this._identity.getKeypair() const keypair: CryptoKeyPair = await this._identity.getKeypair()
const jwt = await MangroveReviews.signReview(keypair, r) const jwt = await MangroveReviews.signReview(keypair, r)
const kid = await MangroveReviews.publicToPem(keypair.publicKey) const kid = await MangroveReviews.publicToPem(keypair.publicKey)
if (!this._testmode.data) { if (!this._testmode.data) {
try { try {
await MangroveReviews.submitReview(jwt) await MangroveReviews.signAndSubmitReview(keypair, r)
} catch (e) { } catch (e) {
await this._reportError( await this._reportError(
e, e,
@ -375,10 +376,11 @@ export default class FeatureReviews {
console.log("Testmode enabled - not uploading review") console.log("Testmode enabled - not uploading review")
await Utils.waitFor(1000) await Utils.waitFor(1000)
} }
console.log("JWT IS", jwt)
const reviewWithKid = { const reviewWithKid = {
...r, ...r,
kid, kid,
signature: jwt, signature: "",
madeByLoggedInUser: new ImmutableStore(true), madeByLoggedInUser: new ImmutableStore(true),
} }
this._reviews.data.push(reviewWithKid) this._reviews.data.push(reviewWithKid)
@ -448,6 +450,13 @@ export default class FeatureReviews {
} }
} }
public async deleteReview(review: Review & {signature: string}){
await MangroveReviews.deleteReview(await this._identity.getKeypair(), review)
this._reviews.set(
this._reviews.data.filter(r => r !== review)
)
}
/** /**
* Gets an URI which represents the item in a mangrove-compatible way * Gets an URI which represents the item in a mangrove-compatible way
* *

View file

@ -8,15 +8,21 @@
import Tr from "../Base/Tr.svelte" import Tr from "../Base/Tr.svelte"
import ReviewPrivacyShield from "./ReviewPrivacyShield.svelte" import ReviewPrivacyShield from "./ReviewPrivacyShield.svelte"
import ThemeViewState from "../../Models/ThemeViewState" import ThemeViewState from "../../Models/ThemeViewState"
import type { Store } from "../../Logic/UIEventSource" import { Store, UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
/** /**
* An element showing all reviews * An element showing all reviews
*/ */
export let reviews: FeatureReviews export let reviews: FeatureReviews
export let state: ThemeViewState export let state: ThemeViewState
export let tags: UIEventSource<Record<string, string>> = undefined
export let feature: Feature = undefined
export let layer: LayerConfig =undefined
let average = reviews.average let average = reviews.average
let allReviews: Store< let allReviews: Store<
(Review & { (Review & {
signature: string,
madeByLoggedInUser: Store<boolean> madeByLoggedInUser: Store<boolean>
})[] })[]
> = reviews.reviews.map((r) => Utils.NoNull(r)) > = reviews.reviews.map((r) => Utils.NoNull(r))
@ -26,13 +32,13 @@
</script> </script>
<ReviewPrivacyShield {reviews} guistate={state.guistate}> <ReviewPrivacyShield {reviews} guistate={state.guistate}>
<div class="border-2 border-dashed border-gray-300 p-2"> <div class="border-2 border-dashed border-gray-300 p-2 flex flex-col gap-y-2">
{#if $allReviews.length > 1} {#if $allReviews.length > 1}
<StarsBar score={$average} /> <StarsBar score={$average} />
{/if} {/if}
{#if $allReviews.length > 0} {#if $allReviews.length > 0}
{#each $allReviews as review} {#each $allReviews as review}
<SingleReview {review} /> <SingleReview {review} {state} {tags} {feature} {layer} {reviews}/>
{/each} {/each}
{:else} {:else}
<div class="subtle m-2 italic"> <div class="subtle m-2 italic">

View file

@ -2,7 +2,7 @@
import FeatureReviews from "../../Logic/Web/MangroveReviews" import FeatureReviews from "../../Logic/Web/MangroveReviews"
import StarsBar from "./StarsBar.svelte" import StarsBar from "./StarsBar.svelte"
import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte" import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte"
import { Store, UIEventSource } from "../../Logic/UIEventSource" import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson" import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Translations from "../i18n/Translations" import Translations from "../i18n/Translations"
@ -10,7 +10,7 @@
import Tr from "../Base/Tr.svelte" import Tr from "../Base/Tr.svelte"
import If from "../Base/If.svelte" import If from "../Base/If.svelte"
import Loading from "../Base/Loading.svelte" import Loading from "../Base/Loading.svelte"
import { Review } from "mangrove-reviews-typescript" import { MangroveReviews, Review } from "mangrove-reviews-typescript"
import { placeholder } from "../../Utils/placeholder" import { placeholder } from "../../Utils/placeholder"
import { ExclamationTriangle } from "@babeard/svelte-heroicons/solid/ExclamationTriangle" import { ExclamationTriangle } from "@babeard/svelte-heroicons/solid/ExclamationTriangle"
import ReviewPrivacyShield from "./ReviewPrivacyShield.svelte" import ReviewPrivacyShield from "./ReviewPrivacyShield.svelte"
@ -26,11 +26,12 @@
* This is multi-stepped. * This is multi-stepped.
*/ */
export let reviews: FeatureReviews export let reviews: FeatureReviews
export let editReview: Review & { signature: string } = undefined
let score = 0 let subject: Store<string> = editReview !== undefined ? new ImmutableStore(editReview.sub) : reviews.subjectUri
let confirmedScore = undefined let score = editReview?.rating ?? 0
let isAffiliated = new UIEventSource(false) let confirmedScore = editReview?.rating
let opinion = new UIEventSource<string>(undefined) let isAffiliated = new UIEventSource(editReview?.metadata?.is_affiliated ?? false)
let opinion = new UIEventSource<string>(editReview?.opinion)
const t = Translations.t.reviews const t = Translations.t.reviews
@ -47,6 +48,7 @@
}) })
let uploadFailed: string = undefined let uploadFailed: string = undefined
let isTesting = state?.featureSwitchIsTesting
async function save() { async function save() {
if (hasError.data) { if (hasError.data) {
@ -62,18 +64,42 @@
opinion: opinion.data, opinion: opinion.data,
metadata: { nickname, is_affiliated: isAffiliated.data }, metadata: { nickname, is_affiliated: isAffiliated.data },
} }
if (!isTesting?.data) {
try { try {
if (editReview) {
const payload = {
opinion: opinion.data,
rating: confirmedScore,
iat: Math.floor(new Date().getTime()),
sub: editReview.sub,
metadata: {
nickname,
},
}
if (editReview.images) {
review.images = editReview.images
}
if (isAffiliated.data) {
review.metadata.is_affiliated = isAffiliated.data
}
await reviews.createReview(payload)
await reviews.deleteReview(editReview)
} else {
await reviews.createReview(review) await reviews.createReview(review)
}
} catch (e) { } catch (e) {
console.error("Could not create review due to", e) console.error("Could not create review due to", e)
uploadFailed = "" + e uploadFailed = "" + e
} }
} else {
console.log("Not actually saving, testing mode is on")
}
_state = "done" _state = "done"
} }
let test = state.featureSwitches.featureSwitchIsTesting let test = state.featureSwitches.featureSwitchIsTesting
let debug = state.featureSwitches.featureSwitchIsDebugging let debug = state.featureSwitches.featureSwitchIsDebugging
let subject = reviews.subjectUri
</script> </script>
<ReviewPrivacyShield hiddenIfNotAllowed {reviews} guistate={state.guistate}> <ReviewPrivacyShield hiddenIfNotAllowed {reviews} guistate={state.guistate}>
@ -156,7 +182,7 @@
<Tr slot="else" t={t.reviewing_as_anonymous} /> <Tr slot="else" t={t.reviewing_as_anonymous} />
</If> </If>
<button class="primary" class:disabled={$hasError !== undefined} on:click={save}> <button class="primary" class:disabled={$hasError !== undefined} on:click={save}>
<Tr t={t.save} /> <Tr t={ editReview === undefined ? t.save : t.edit} />
</button> </button>
</div> </div>

View file

@ -4,11 +4,12 @@
*/ */
import FeatureReviews from "../../Logic/Web/MangroveReviews" import FeatureReviews from "../../Logic/Web/MangroveReviews"
import { MenuState } from "../../Models/MenuState" import { MenuState } from "../../Models/MenuState"
import { ImmutableStore } from "../../Logic/UIEventSource"
export let guistate: MenuState export let guistate: MenuState
export let reviews: FeatureReviews export let reviews: FeatureReviews
export let hiddenIfNotAllowed: boolean = false export let hiddenIfNotAllowed: boolean = false
let allowed = reviews.loadingAllowed let allowed = reviews?.loadingAllowed ?? new ImmutableStore(true)
</script> </script>
{#if $allowed} {#if $allowed}

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Review } from "mangrove-reviews-typescript" import { MangroveReviews, Review } from "mangrove-reviews-typescript"
import { ImmutableStore, Store } from "../../Logic/UIEventSource" import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import StarsBar from "./StarsBar.svelte" import StarsBar from "./StarsBar.svelte"
import Translations from "../i18n/Translations" import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte" import Tr from "../Base/Tr.svelte"
@ -9,8 +9,22 @@
import Markdown from "../Base/Markdown.svelte" import Markdown from "../Base/Markdown.svelte"
import AccordionSingle from "../Flowbite/AccordionSingle.svelte" import AccordionSingle from "../Flowbite/AccordionSingle.svelte"
import AttributedImage from "../Image/AttributedImage.svelte" import AttributedImage from "../Image/AttributedImage.svelte"
import DotMenu from "../Base/DotMenu.svelte"
import { TrashIcon } from "@rgossiaux/svelte-heroicons/solid"
import Popup from "../Base/Popup.svelte"
import NextButton from "../Base/NextButton.svelte"
import { PencilIcon } from "@babeard/svelte-heroicons/solid"
import ReviewForm from "./ReviewForm.svelte"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import FeatureReviews from "../../Logic/Web/MangroveReviews"
export let state: ThemeViewState = undefined export let state: ThemeViewState = undefined
export let tags: UIEventSource<Record<string, string>> = undefined
export let feature: Feature = undefined
export let layer: LayerConfig = undefined
export let reviews: FeatureReviews
export let review: Review & { export let review: Review & {
kid: string kid: string
signature: string signature: string
@ -35,8 +49,61 @@
state?.guistate?.closeAll() state?.guistate?.closeAll()
} }
let isTesting = state?.featureSwitchIsTesting ?? new ImmutableStore(false)
function report() {
MangroveReviews.reportAbuseReview(identity, review, "This review was reported by a MapComplete user")
}
let deleted = new UIEventSource(false)
async function deleteR() {
const identity: MangroveIdentity = await state?.userRelatedState?.mangroveIdentity.getKeypair()
console.log("Deleting review...", identity)
if (!isTesting.data) {
try {
reviews.deleteReview(review)
} catch (e) {
console.log("Could not delete review... ", e)
}
} else {
console.log("Not really deleting the review, testing")
}
showDelete.setData(false)
deleted.setData(true)
}
let showDelete = new UIEventSource(false)
let showEdit = new UIEventSource(false)
let isDebugging = state?.featureSwitches?.featureSwitchIsDebugging
const t = Translations.t.reviews
</script> </script>
<Popup shown={showDelete}>
<svelte:fragment slot="header">
<Tr t={t.deleteTitle} />
</svelte:fragment>
<Tr t={t.deleteText} />
<NextButton clss="primary" on:click={() => deleteR()}>
<TrashIcon class="w-12 text-red-600" />
<Tr t={t.deleteConfirm} />
</NextButton>
</Popup>
<Popup shown={showEdit}>
<svelte:fragment slot="header">
<Tr t={t.edit} />
</svelte:fragment>
<ReviewForm {state} editReview={review} {reviews} />
</Popup>
<div <div
class={"low-interaction flex flex-col rounded-lg p-1 px-2" + class={"low-interaction flex flex-col rounded-lg p-1 px-2" +
($byLoggedInUser ? "border-interactive" : "")} ($byLoggedInUser ? "border-interactive" : "")}
@ -79,10 +146,26 @@
{date} {date}
</a> </a>
</div> </div>
{#if review.signature}
<div class="self-start">
<DotMenu>
{#if byLoggedInUser}
<button on:click={() => showEdit.set(true)}>
<PencilIcon />
<Tr t={t.edit} />
</button>
<button on:click={() => showDelete.set(true)}>
<TrashIcon />
<Tr t={t.delete} />
</button>
{/if}
</DotMenu>
</div>
{/if}
</div> </div>
{#if review.opinion} {#if review.opinion}
<div class="disable-links"> <div class="disable-links" class:strikethrough={$deleted}>
<Markdown src={review.opinion} /> <Markdown src={review.opinion} />
</div> </div>
{/if} {/if}
@ -99,4 +182,9 @@
{#if review.metadata.is_affiliated} {#if review.metadata.is_affiliated}
<Tr t={Translations.t.reviews.affiliated_reviewer_warning} /> <Tr t={Translations.t.reviews.affiliated_reviewer_warning} />
{/if} {/if}
{#if $isDebugging}
<div class="subtle">
Maresi: {(review.signature)}
</div>
{/if}
</div> </div>