Feature(privacy): allow to disable reviews, disable reviews in sensitive themes, fix https://github.com/pietervdvn/MapComplete/issues/2282

This commit is contained in:
Pieter Vander Vennet 2025-02-28 19:36:13 +01:00
parent 1facbaa7d1
commit b4a7391725
10 changed files with 1137 additions and 291 deletions

File diff suppressed because it is too large Load diff

View file

@ -1150,6 +1150,39 @@
], ],
"metacondition": "_uid~*" "metacondition": "_uid~*"
}, },
{
"id": "mangrove-reviews-allowed",
"question": {
"en": "Should reviews be loaded when browsing an item?"
},
"mappings": [
{
"if": "mapcomplete-reviews-allowed=always",
"then": {
"en": "Show reviews by default, also in sensitive themes"
}
},
{
"if": "mapcomplete-reviews-allowed=yes",
"alsoShowIf": "mapcomplete-reviews-allowed=",
"then": {
"en": "Show reviews by default, except in sensitive themes (where we'll ask per feature)"
}
},
{
"if": "mapcomplete-reviews-allowed=ask",
"then": {
"en": "Always ask before loading reviews"
}
},
{
"if": "mapcomplete-reviews-allowed=hidden",
"then": {
"en": "Never show reviews at all"
}
}
]
},
{ {
"id": "title-id", "id": "title-id",
"render": { "render": {

View file

@ -10023,6 +10023,33 @@
} }
}, },
"question": "What type of special needs are given here?" "question": "What type of special needs are given here?"
},
"uniform": {
"mappings": {
"0": {
"then": "Students must wear a uniform, which is extensively described"
},
"1": {
"then": "Students must wear clothes in a specific colour scheme"
},
"2": {
"then": "There is no formal dress code, students are allowed to come in casual wear such as t-shirt, jeans, ..."
},
"3": {
"then": "Arms must be covered by the clothes"
},
"4": {
"then": "Knees must be covered by the clothes"
},
"5": {
"then": "Legs must be covered by the clothes"
},
"6": {
"then": "The belly must be covered by the clothes"
}
},
"question": "Do pupils have to wear a uniform or obey a dresscode?",
"render": "{dress_code}"
} }
}, },
"title": { "title": {
@ -13110,6 +13137,23 @@
} }
} }
}, },
"mangrove-reviews-allowed": {
"mappings": {
"0": {
"then": "Show reviews by default, also in sensitive themes"
},
"1": {
"then": "Show reviews by default, except in sensitive themes (where we'll ask per feature)"
},
"2": {
"then": "Always ask before loading"
},
"3": {
"then": "Never show reviews at all"
}
},
"question": "Should reviews be loaded when browsing an item?"
},
"more_privacy": { "more_privacy": {
"mappings": { "mappings": {
"0": { "0": {

View file

@ -8492,6 +8492,32 @@
} }
}, },
"question": "Welke soorten zorg voor buitengewone leerlingen is hier beschikbaar?" "question": "Welke soorten zorg voor buitengewone leerlingen is hier beschikbaar?"
},
"uniform": {
"mappings": {
"0": {
"then": "Studenten moeten een specifiek uniform dragen"
},
"1": {
"then": "Leerlingen moeten kleren van een bepaalde kleur dragen"
},
"2": {
"then": "Er is geen uniformverplichting, leerlingen mogen kledij zoals t-shirts, jeans, ... dragen"
},
"3": {
"then": "De armen moeten volledig bedekt zijn"
},
"4": {
"then": "De knieën moeten volledig bedekt zijn"
},
"5": {
"then": "De benen moeten volledig bedekt zijn"
},
"6": {
"then": "De buik mag niet zichtbaar zijn"
}
},
"question": "Moeten leerlingen een uniform dragen of specifieke kledingsvoorschriften volgen?"
} }
}, },
"title": { "title": {

View file

@ -3,6 +3,7 @@ import { MangroveReviews, Review } from "mangrove-reviews-typescript"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { Feature, Position } from "geojson" import { Feature, Position } from "geojson"
import { GeoOperations } from "../GeoOperations" import { GeoOperations } from "../GeoOperations"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
export class MangroveIdentity { export class MangroveIdentity {
private readonly keypair: UIEventSource<CryptoKeyPair> = new UIEventSource<CryptoKeyPair>( private readonly keypair: UIEventSource<CryptoKeyPair> = new UIEventSource<CryptoKeyPair>(
@ -116,12 +117,12 @@ export class MangroveIdentity {
return [] return []
} }
const allReviews = await MangroveReviews.getReviews({ const allReviews = await MangroveReviews.getReviews({
kid: pem, kid: pem
}) })
this.allReviewsById.setData( this.allReviewsById.setData(
allReviews.reviews.map((r) => ({ allReviews.reviews.map((r) => ({
...r, ...r,
...r.payload, ...r.payload
})) }))
) )
}) })
@ -157,6 +158,7 @@ export default class FeatureReviews {
private readonly _name: Store<string> private readonly _name: Store<string>
private readonly _identity: MangroveIdentity private readonly _identity: MangroveIdentity
private readonly _testmode: Store<boolean> private readonly _testmode: Store<boolean>
public loadingAllowed: UIEventSource<boolean | null>
private constructor( private constructor(
feature: Feature, feature: Feature,
@ -167,8 +169,10 @@ export default class FeatureReviews {
fallbackName?: string fallbackName?: string
uncertaintyRadius?: number uncertaintyRadius?: number
}, },
testmode?: Store<boolean> testmode?: Store<boolean>,
loadingAllowed?: UIEventSource<boolean | null>
) { ) {
this.loadingAllowed = loadingAllowed
const centerLonLat = GeoOperations.centerpointCoordinates(feature) const centerLonLat = GeoOperations.centerpointCoordinates(feature)
;[this._lon, this._lat] = centerLonLat ;[this._lon, this._lat] = centerLonLat
this._identity = mangroveIdentity this._identity = mangroveIdentity
@ -222,6 +226,9 @@ export default class FeatureReviews {
*/ */
this.ConstructSubjectUri(true).mapD( this.ConstructSubjectUri(true).mapD(
async (sub) => { async (sub) => {
if (!loadingAllowed.data) {
return
}
try { try {
const reviews = await MangroveReviews.getReviews({ sub }) const reviews = await MangroveReviews.getReviews({ sub })
console.debug("Got reviews (no-encode) for", feature, reviews, sub) console.debug("Got reviews (no-encode) for", feature, reviews, sub)
@ -230,7 +237,7 @@ export default class FeatureReviews {
console.log("Could not fetch reviews for partially incorrect query ", sub) console.log("Could not fetch reviews for partially incorrect query ", sub)
} }
}, },
[this._name] [this._name, loadingAllowed]
) )
this.average = this._reviews.map((reviews) => { this.average = this._reviews.map((reviews) => {
if (!reviews) { if (!reviews) {
@ -268,19 +275,39 @@ export default class FeatureReviews {
fallbackName?: string fallbackName?: string
uncertaintyRadius?: number uncertaintyRadius?: number
}, },
testmode: Store<boolean> state: SpecialVisualizationState
): FeatureReviews { ): FeatureReviews {
const key = feature.properties.id const key = feature.properties.id
const cached = FeatureReviews._featureReviewsCache[key] const cached = FeatureReviews._featureReviewsCache[key]
if (cached !== undefined) { if (cached !== undefined) {
return cached return cached
} }
const themeIsSensitive = state.theme?.enableMorePrivacy
const settings = state.osmConnection.getPreference<"always" | "yes" | "ask" | "hidden">("reviews-allowed")
const loadingAllowed = new UIEventSource(false)
settings.addCallbackAndRun((s) => {
console.log("Reviews allowed is", s)
if (s === "hidden") {
loadingAllowed.set(null)
return
}
if (s === "always") {
loadingAllowed.set(true)
return
}
if (themeIsSensitive || s === "ask") {
loadingAllowed.set(false)
return
}
loadingAllowed.set(true)
})
const featureReviews = new FeatureReviews( const featureReviews = new FeatureReviews(
feature, feature,
tagsSource, tagsSource,
mangroveIdentity, mangroveIdentity,
options, options,
testmode state.featureSwitchIsTesting,
loadingAllowed
) )
FeatureReviews._featureReviewsCache[key] = featureReviews FeatureReviews._featureReviewsCache[key] = featureReviews
return featureReviews return featureReviews
@ -302,7 +329,7 @@ export default class FeatureReviews {
} }
const r: Review = { const r: Review = {
sub: this.subjectUri.data, sub: this.subjectUri.data,
...review, ...review
} }
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)
@ -317,7 +344,7 @@ export default class FeatureReviews {
...r, ...r,
kid, kid,
signature: jwt, signature: jwt,
madeByLoggedInUser: new ImmutableStore(true), madeByLoggedInUser: new ImmutableStore(true)
} }
this._reviews.data.push(reviewWithKid) this._reviews.data.push(reviewWithKid)
this._reviews.ping() this._reviews.ping()
@ -375,7 +402,7 @@ export default class FeatureReviews {
signature: reviewData.signature, signature: reviewData.signature,
madeByLoggedInUser: this._identity.getKeyId().map((user_key_id) => { madeByLoggedInUser: this._identity.getKeyId().map((user_key_id) => {
return reviewData.kid === user_key_id return reviewData.kid === user_key_id
}), })
}) })
hasNew = true hasNew = true
} }

View file

@ -4,15 +4,22 @@
import TagRenderingEditable from "../Popup/TagRendering/TagRenderingEditable.svelte" import TagRenderingEditable from "../Popup/TagRendering/TagRenderingEditable.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization" import type { SpecialVisualizationState } from "../SpecialVisualization"
import UserRelatedState from "../../Logic/State/UserRelatedState" import UserRelatedState from "../../Logic/State/UserRelatedState"
import type { Feature } from "geojson"
const t = Translations.t.privacy const t = Translations.t.privacy
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
const usersettings = UserRelatedState.usersettingsConfig const usersettings = UserRelatedState.usersettingsConfig
const editPrivacy = usersettings.tagRenderings.find((tr) => tr.id === "more_privacy") const editPrivacy = usersettings.tagRenderings.find((tr) => tr.id === "more_privacy")
const editThemeHistory = usersettings.tagRenderings.find((tr) => tr.id === "sync-visited-themes") const editThemeHistory = usersettings.tagRenderings.find((tr) => tr.id === "sync-visited-themes")
const editReviews = usersettings.tagRenderings.find((tr) => tr.id === "mangrove-reviews-allowed")
const editLocationHistory = usersettings.tagRenderings.find((tr) => tr.id === "sync-visited-locations") const editLocationHistory = usersettings.tagRenderings.find((tr) => tr.id === "sync-visited-locations")
const selectedElement: Feature = {
type: "Feature",
properties: { id: "settings" },
geometry: { type: "Point", coordinates: [0, 0] }
}
const isLoggedIn = state.osmConnection.isLoggedIn const isLoggedIn = state.osmConnection.isLoggedIn
</script> </script>
@ -80,11 +87,7 @@
<li> <li>
<TagRenderingEditable <TagRenderingEditable
config={editLocationHistory} config={editLocationHistory}
selectedElement={{ {selectedElement}
type: "Feature",
properties: { id: "settings" },
geometry: { type: "Point", coordinates: [0, 0] },
}}
{state} {state}
tags={state.userRelatedState.preferencesAsTags} tags={state.userRelatedState.preferencesAsTags}
/> />
@ -93,11 +96,7 @@
<TagRenderingEditable <TagRenderingEditable
config={editThemeHistory} config={editThemeHistory}
selectedElement={{ {selectedElement}
type: "Feature",
properties: { id: "settings" },
geometry: { type: "Point", coordinates: [0, 0] },
}}
{state} {state}
tags={state.userRelatedState.preferencesAsTags} tags={state.userRelatedState.preferencesAsTags}
/> />
@ -114,6 +113,14 @@
</h3> </h3>
<Tr t={t.miscCookies} /> <Tr t={t.miscCookies} />
<TagRenderingEditable
config={editReviews}
{selectedElement}
{state}
tags={state.userRelatedState.preferencesAsTags}
/>
<h3> <h3>
<Tr t={t.whileYoureHere} /> <Tr t={t.whileYoureHere} />
</h3> </h3>

View file

@ -5,20 +5,15 @@
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"
import type { SpecialVisualizationState } from "../SpecialVisualization" import ReviewPrivacyShield from "./ReviewPrivacyShield.svelte"
import { UIEventSource } from "../../Logic/UIEventSource" import ThemeViewState from "../../Models/ThemeViewState"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Mangrove_logo from "../../assets/svg/Mangrove_logo.svelte"
/** /**
* An element showing all reviews * An element showing all reviews
*/ */
export let reviews: FeatureReviews export let reviews: FeatureReviews
export let state: SpecialVisualizationState export let state: ThemeViewState
export let tags: UIEventSource<Record<string, string>>
export let feature: Feature
export let layer: LayerConfig
let average = reviews.average let average = reviews.average
let _reviews = [] let _reviews = []
reviews.reviews.addCallbackAndRunD((r) => { reviews.reviews.addCallbackAndRunD((r) => {
@ -26,6 +21,7 @@
}) })
</script> </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">
{#if _reviews.length > 1} {#if _reviews.length > 1}
<StarsBar score={$average} /> <StarsBar score={$average} />
@ -43,3 +39,4 @@
<Tr cls="text-sm subtle" t={Translations.t.reviews.attribution} /> <Tr cls="text-sm subtle" t={Translations.t.reviews.attribution} />
</div> </div>
</div> </div>
</ReviewPrivacyShield>

View file

@ -2,7 +2,6 @@
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 type { SpecialVisualizationState } from "../SpecialVisualization"
import { Store, UIEventSource } from "../../Logic/UIEventSource" import { 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"
@ -12,11 +11,12 @@
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 { Review } from "mangrove-reviews-typescript"
import { Utils } from "../../Utils"
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 ThemeViewState from "../../Models/ThemeViewState"
export let state: SpecialVisualizationState export let state: ThemeViewState
export let tags: UIEventSource<Record<string, string>> export let tags: UIEventSource<Record<string, string>>
export let feature: Feature export let feature: Feature
export let layer: LayerConfig export let layer: LayerConfig
@ -60,7 +60,7 @@
const review: Omit<Review, "sub"> = { const review: Omit<Review, "sub"> = {
rating: confirmedScore, rating: confirmedScore,
opinion: opinion.data, opinion: opinion.data,
metadata: { nickname, is_affiliated: isAffiliated.data }, metadata: { nickname, is_affiliated: isAffiliated.data }
} }
try { try {
await reviews.createReview(review) await reviews.createReview(review)
@ -72,6 +72,8 @@
} }
</script> </script>
<ReviewPrivacyShield hiddenIfNotAllowed {reviews} guistate={state.guistate}>
{#if uploadFailed} {#if uploadFailed}
<div class="alert flex"> <div class="alert flex">
<ExclamationTriangle class="h-6 w-6" /> <ExclamationTriangle class="h-6 w-6" />
@ -153,3 +155,4 @@
{/if} {/if}
</div> </div>
{/if} {/if}
</ReviewPrivacyShield>

View file

@ -0,0 +1,27 @@
<script lang="ts">/**
* Due to privacy, we cannot load reviews unless allowed in the privacy policy
*/
import FeatureReviews from "../../Logic/Web/MangroveReviews"
import { MenuState } from "../../Models/MenuState"
export let guistate: MenuState
export let reviews: FeatureReviews
export let hiddenIfNotAllowed: boolean = false
let allowed = reviews.loadingAllowed
</script>
{#if $allowed}
<slot />
{:else if !hiddenIfNotAllowed && $allowed !== null }
<div class="low-interaction flex flex-col rounded mx-1">
Reviews are disabled due to your privacy settings.
<button on:click={() => reviews.loadingAllowed.set(true)} class="primary">
Load reviews once
</button>
<button class="as-link self-end" on:click={() => guistate.openUsersettings("mangrove-reviews-allowed")}>
Edit your privacy settings
</button>
</div>
{/if}

View file

@ -47,7 +47,7 @@ export class ReviewSpecialVisualisations {
nameKey: nameKey, nameKey: nameKey,
fallbackName, fallbackName,
}, },
state.featureSwitchIsTesting state
) )
return new SvelteUIElement(ReviewForm, { return new SvelteUIElement(ReviewForm, {
reviews, reviews,
@ -76,7 +76,7 @@ export class ReviewSpecialVisualisations {
doc: "The identifier to use, if <i>tags[subjectKey]</i> as specified above is not available. This is effectively a fallback value", doc: "The identifier to use, if <i>tags[subjectKey]</i> as specified above is not available. This is effectively a fallback value",
}, },
], ],
constr: (state, tags, args, feature, layer) => { constr: (state, tags, args, feature) => {
const nameKey = args[0] ?? "name" const nameKey = args[0] ?? "name"
const fallbackName = args[1] const fallbackName = args[1]
const reviews = FeatureReviews.construct( const reviews = FeatureReviews.construct(
@ -87,9 +87,9 @@ export class ReviewSpecialVisualisations {
nameKey: nameKey, nameKey: nameKey,
fallbackName, fallbackName,
}, },
state.featureSwitchIsTesting state
) )
return new SvelteUIElement(AllReviews, { reviews, state, tags, feature, layer }) return new SvelteUIElement(AllReviews, { reviews, state })
}, },
} }
return [ return [
@ -120,7 +120,7 @@ export class ReviewSpecialVisualisations {
nameKey: nameKey, nameKey: nameKey,
fallbackName, fallbackName,
}, },
state.featureSwitchIsTesting state
) )
return new SvelteUIElement(StarsBarIcon, { return new SvelteUIElement(StarsBarIcon, {
score: reviews.average, score: reviews.average,
@ -181,7 +181,6 @@ export class ReviewSpecialVisualisations {
): BaseUIElement { ): BaseUIElement {
return new Combine([ return new Combine([
createReview.constr(state, tagSource, args, feature, layer), createReview.constr(state, tagSource, args, feature, layer),
listReviews.constr(state, tagSource, args, feature, layer), listReviews.constr(state, tagSource, args, feature, layer),
]) ])
}, },