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)",
"attribution": "By Mangrove.reviews",
"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_explanation": "Check if you are an owner, creator, employee, …",
"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",
"latlon2country": "^1.2.7",
"libphonenumber-js": "^1.11.19",
"mangrove-reviews-typescript": "^1.3.1",
"mangrove-reviews-typescript": "^1.4.6",
"maplibre-gl": "^5.1.0",
"marked": "^12.0.2",
"monaco-editor": "^0.46.0",
@ -19525,9 +19525,10 @@
"license": "ISC"
},
"node_modules/mangrove-reviews-typescript": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/mangrove-reviews-typescript/-/mangrove-reviews-typescript-1.3.1.tgz",
"integrity": "sha512-xgU4uvJ0zQTRMoA4exje509FS19jN3u/buGBSUGh1TcTQBKWBGSmaT0cnHzcH9lhg6J8LCTsgf3Mv07Y87O9/w==",
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/mangrove-reviews-typescript/-/mangrove-reviews-typescript-1.4.6.tgz",
"integrity": "sha512-ZUM6Dath7OQEBz2oL8MKC+kMAEjIsoY0mPgAzXBcDLdKafFFOx2j6YCrMVu5RAsBhZLT77isFmbv1Ty5jKxMag==",
"license": "GPL-2.0-only",
"dependencies": {
"axios": "^1.2.3",
"jose": "^4.11.2",

View file

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

View file

@ -150,7 +150,7 @@ export default class FeatureReviews {
private readonly _reviews: UIEventSource<
(Review & { kid: string; signature: string; madeByLoggedInUser: Store<boolean> })[]
> = new UIEventSource([])
public readonly reviews: Store<(Review & { madeByLoggedInUser: Store<boolean> })[]> =
public readonly reviews: Store<(Review & { signature: string, madeByLoggedInUser: Store<boolean> })[]> =
this._reviews
private readonly _lat: number
private readonly _lon: number
@ -361,9 +361,10 @@ export default class FeatureReviews {
const keypair: CryptoKeyPair = await this._identity.getKeypair()
const jwt = await MangroveReviews.signReview(keypair, r)
const kid = await MangroveReviews.publicToPem(keypair.publicKey)
if (!this._testmode.data) {
try {
await MangroveReviews.submitReview(jwt)
await MangroveReviews.signAndSubmitReview(keypair, r)
} catch (e) {
await this._reportError(
e,
@ -375,10 +376,11 @@ export default class FeatureReviews {
console.log("Testmode enabled - not uploading review")
await Utils.waitFor(1000)
}
console.log("JWT IS", jwt)
const reviewWithKid = {
...r,
kid,
signature: jwt,
signature: "",
madeByLoggedInUser: new ImmutableStore(true),
}
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
*

View file

@ -8,15 +8,21 @@
import Tr from "../Base/Tr.svelte"
import ReviewPrivacyShield from "./ReviewPrivacyShield.svelte"
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
*/
export let reviews: FeatureReviews
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 allReviews: Store<
(Review & {
signature: string,
madeByLoggedInUser: Store<boolean>
})[]
> = reviews.reviews.map((r) => Utils.NoNull(r))
@ -26,13 +32,13 @@
</script>
<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}
<StarsBar score={$average} />
{/if}
{#if $allReviews.length > 0}
{#each $allReviews as review}
<SingleReview {review} />
<SingleReview {review} {state} {tags} {feature} {layer} {reviews}/>
{/each}
{:else}
<div class="subtle m-2 italic">

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { Review } from "mangrove-reviews-typescript"
import { ImmutableStore, Store } from "../../Logic/UIEventSource"
import { MangroveReviews, Review } from "mangrove-reviews-typescript"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import StarsBar from "./StarsBar.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
@ -9,8 +9,22 @@
import Markdown from "../Base/Markdown.svelte"
import AccordionSingle from "../Flowbite/AccordionSingle.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 tags: UIEventSource<Record<string, string>> = undefined
export let feature: Feature = undefined
export let layer: LayerConfig = undefined
export let reviews: FeatureReviews
export let review: Review & {
kid: string
signature: string
@ -35,8 +49,61 @@
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>
<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
class={"low-interaction flex flex-col rounded-lg p-1 px-2" +
($byLoggedInUser ? "border-interactive" : "")}
@ -79,10 +146,26 @@
{date}
</a>
</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>
{#if review.opinion}
<div class="disable-links">
<div class="disable-links" class:strikethrough={$deleted}>
<Markdown src={review.opinion} />
</div>
{/if}
@ -99,4 +182,9 @@
{#if review.metadata.is_affiliated}
<Tr t={Translations.t.reviews.affiliated_reviewer_warning} />
{/if}
{#if $isDebugging}
<div class="subtle">
Maresi: {(review.signature)}
</div>
{/if}
</div>