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~*"
},
{
"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",
"render": {

View file

@ -10023,6 +10023,33 @@
}
},
"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": {
@ -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": {
"mappings": {
"0": {

View file

@ -8492,6 +8492,32 @@
}
},
"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": {

View file

@ -3,6 +3,7 @@ import { MangroveReviews, Review } from "mangrove-reviews-typescript"
import { Utils } from "../../Utils"
import { Feature, Position } from "geojson"
import { GeoOperations } from "../GeoOperations"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
export class MangroveIdentity {
private readonly keypair: UIEventSource<CryptoKeyPair> = new UIEventSource<CryptoKeyPair>(
@ -116,12 +117,12 @@ export class MangroveIdentity {
return []
}
const allReviews = await MangroveReviews.getReviews({
kid: pem,
kid: pem
})
this.allReviewsById.setData(
allReviews.reviews.map((r) => ({
...r,
...r.payload,
...r.payload
}))
)
})
@ -157,6 +158,7 @@ export default class FeatureReviews {
private readonly _name: Store<string>
private readonly _identity: MangroveIdentity
private readonly _testmode: Store<boolean>
public loadingAllowed: UIEventSource<boolean | null>
private constructor(
feature: Feature,
@ -167,8 +169,10 @@ export default class FeatureReviews {
fallbackName?: string
uncertaintyRadius?: number
},
testmode?: Store<boolean>
testmode?: Store<boolean>,
loadingAllowed?: UIEventSource<boolean | null>
) {
this.loadingAllowed = loadingAllowed
const centerLonLat = GeoOperations.centerpointCoordinates(feature)
;[this._lon, this._lat] = centerLonLat
this._identity = mangroveIdentity
@ -222,6 +226,9 @@ export default class FeatureReviews {
*/
this.ConstructSubjectUri(true).mapD(
async (sub) => {
if (!loadingAllowed.data) {
return
}
try {
const reviews = await MangroveReviews.getReviews({ 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)
}
},
[this._name]
[this._name, loadingAllowed]
)
this.average = this._reviews.map((reviews) => {
if (!reviews) {
@ -268,19 +275,39 @@ export default class FeatureReviews {
fallbackName?: string
uncertaintyRadius?: number
},
testmode: Store<boolean>
state: SpecialVisualizationState
): FeatureReviews {
const key = feature.properties.id
const cached = FeatureReviews._featureReviewsCache[key]
if (cached !== undefined) {
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(
feature,
tagsSource,
mangroveIdentity,
options,
testmode
state.featureSwitchIsTesting,
loadingAllowed
)
FeatureReviews._featureReviewsCache[key] = featureReviews
return featureReviews
@ -302,7 +329,7 @@ export default class FeatureReviews {
}
const r: Review = {
sub: this.subjectUri.data,
...review,
...review
}
const keypair: CryptoKeyPair = await this._identity.getKeypair()
const jwt = await MangroveReviews.signReview(keypair, r)
@ -317,7 +344,7 @@ export default class FeatureReviews {
...r,
kid,
signature: jwt,
madeByLoggedInUser: new ImmutableStore(true),
madeByLoggedInUser: new ImmutableStore(true)
}
this._reviews.data.push(reviewWithKid)
this._reviews.ping()
@ -375,7 +402,7 @@ export default class FeatureReviews {
signature: reviewData.signature,
madeByLoggedInUser: this._identity.getKeyId().map((user_key_id) => {
return reviewData.kid === user_key_id
}),
})
})
hasNew = true
}
@ -401,8 +428,8 @@ export default class FeatureReviews {
} else if (this._uncertainty > 1000) {
console.error(
"Not fetching reviews. Only got a point and a very big uncertainty range (" +
this._uncertainty +
"), so you'd probably only get garbage. Specify a name"
this._uncertainty +
"), so you'd probably only get garbage. Specify a name"
)
return undefined
}

View file

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

View file

@ -5,20 +5,15 @@
import StarsBar from "./StarsBar.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Mangrove_logo from "../../assets/svg/Mangrove_logo.svelte"
import ReviewPrivacyShield from "./ReviewPrivacyShield.svelte"
import ThemeViewState from "../../Models/ThemeViewState"
/**
* An element showing all reviews
*/
export let reviews: FeatureReviews
export let state: SpecialVisualizationState
export let tags: UIEventSource<Record<string, string>>
export let feature: Feature
export let layer: LayerConfig
export let state: ThemeViewState
let average = reviews.average
let _reviews = []
reviews.reviews.addCallbackAndRunD((r) => {
@ -26,20 +21,22 @@
})
</script>
<div class="border-2 border-dashed border-gray-300 p-2">
{#if _reviews.length > 1}
<StarsBar score={$average} />
{/if}
{#if _reviews.length > 0}
{#each _reviews as review}
<SingleReview {review} />
{/each}
{:else}
<div class="subtle m-2 italic">
<Tr t={Translations.t.reviews.no_reviews_yet} />
<ReviewPrivacyShield {reviews} guistate={state.guistate}>
<div class="border-2 border-dashed border-gray-300 p-2">
{#if _reviews.length > 1}
<StarsBar score={$average} />
{/if}
{#if _reviews.length > 0}
{#each _reviews as review}
<SingleReview {review} />
{/each}
{:else}
<div class="subtle m-2 italic">
<Tr t={Translations.t.reviews.no_reviews_yet} />
</div>
{/if}
<div class="flex justify-end">
<Tr cls="text-sm subtle" t={Translations.t.reviews.attribution} />
</div>
{/if}
<div class="flex justify-end">
<Tr cls="text-sm subtle" t={Translations.t.reviews.attribution} />
</div>
</div>
</ReviewPrivacyShield>

View file

@ -2,7 +2,6 @@
import FeatureReviews from "../../Logic/Web/MangroveReviews"
import StarsBar from "./StarsBar.svelte"
import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
@ -12,11 +11,12 @@
import If from "../Base/If.svelte"
import Loading from "../Base/Loading.svelte"
import { Review } from "mangrove-reviews-typescript"
import { Utils } from "../../Utils"
import { placeholder } from "../../Utils/placeholder"
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 feature: Feature
export let layer: LayerConfig
@ -60,7 +60,7 @@
const review: Omit<Review, "sub"> = {
rating: confirmedScore,
opinion: opinion.data,
metadata: { nickname, is_affiliated: isAffiliated.data },
metadata: { nickname, is_affiliated: isAffiliated.data }
}
try {
await reviews.createReview(review)
@ -72,84 +72,87 @@
}
</script>
{#if uploadFailed}
<div class="alert flex">
<ExclamationTriangle class="h-6 w-6" />
<Tr t={Translations.t.general.error} />
{uploadFailed}
</div>
{:else if _state === "done"}
<Tr cls="thanks w-full" t={t.saved} />
{:else if _state === "saving"}
<Loading>
<Tr t={t.saving_review} />
</Loading>
{:else}
<div class="interactive border-interactive p-1">
<div class="font-bold">
{#if question}
{question}
{:else}
<SpecialTranslation {feature} {layer} {state} t={Translations.t.reviews.question} {tags} />
{/if}
<ReviewPrivacyShield hiddenIfNotAllowed {reviews} guistate={state.guistate}>
{#if uploadFailed}
<div class="alert flex">
<ExclamationTriangle class="h-6 w-6" />
<Tr t={Translations.t.general.error} />
{uploadFailed}
</div>
<StarsBar
on:click={(e) => {
{:else if _state === "done"}
<Tr cls="thanks w-full" t={t.saved} />
{:else if _state === "saving"}
<Loading>
<Tr t={t.saving_review} />
</Loading>
{:else}
<div class="interactive border-interactive p-1">
<div class="font-bold">
{#if question}
{question}
{:else}
<SpecialTranslation {feature} {layer} {state} t={Translations.t.reviews.question} {tags} />
{/if}
</div>
<StarsBar
on:click={(e) => {
confirmedScore = e.detail.score
score = confirmedScore
console.log("Confirmed score is:", confirmedScore)
}}
on:hover={(e) => {
on:hover={(e) => {
score = e.detail.score
}}
on:mouseout={() => {
on:mouseout={() => {
score = null
}}
score={score ?? confirmedScore ?? 0}
starSize="w-8 h-8"
/>
score={score ?? confirmedScore ?? 0}
starSize="w-8 h-8"
/>
{#if confirmedScore !== undefined}
<label class="neutral-label">
<Tr cls="font-bold mt-2" t={t.question_opinion} />
<textarea
autofocus
bind:value={$opinion}
inputmode="text"
rows="3"
class="mb-1 w-full"
use:placeholder={t.reviewPlaceholder}
/>
{#if $hasError === "too_long"}
<div class="alert flex items-center px-2">
<ExclamationTriangle class="h-12 w-12" />
<Tr
t={t.too_long.Subs({
{#if confirmedScore !== undefined}
<label class="neutral-label">
<Tr cls="font-bold mt-2" t={t.question_opinion} />
<textarea
autofocus
bind:value={$opinion}
inputmode="text"
rows="3"
class="mb-1 w-full"
use:placeholder={t.reviewPlaceholder}
/>
{#if $hasError === "too_long"}
<div class="alert flex items-center px-2">
<ExclamationTriangle class="h-12 w-12" />
<Tr
t={t.too_long.Subs({
max: FeatureReviews.REVIEW_OPINION_MAX_LENGTH,
amount: $opinion?.length ?? 0,
})}
/>
/>
</div>
{/if}
</label>
<Checkbox selected={isAffiliated}>
<div class="flex flex-col">
<Tr t={t.i_am_affiliated} />
<Tr cls="subtle" t={t.i_am_affiliated_explanation} />
</div>
{/if}
</label>
<Checkbox selected={isAffiliated}>
<div class="flex flex-col">
<Tr t={t.i_am_affiliated} />
<Tr cls="subtle" t={t.i_am_affiliated_explanation} />
</Checkbox>
<div class="flex w-full flex-wrap items-center justify-between">
<If condition={state.osmConnection.isLoggedIn}>
<Tr t={t.reviewing_as.Subs({ nickname: state.osmConnection.userDetails.data.name })} />
<Tr slot="else" t={t.reviewing_as_anonymous} />
</If>
<button class="primary" class:disabled={$hasError !== undefined} on:click={save}>
<Tr t={t.save} />
</button>
</div>
</Checkbox>
<div class="flex w-full flex-wrap items-center justify-between">
<If condition={state.osmConnection.isLoggedIn}>
<Tr t={t.reviewing_as.Subs({ nickname: state.osmConnection.userDetails.data.name })} />
<Tr slot="else" t={t.reviewing_as_anonymous} />
</If>
<button class="primary" class:disabled={$hasError !== undefined} on:click={save}>
<Tr t={t.save} />
</button>
</div>
<Tr cls="subtle mt-4" t={t.tos} />
{/if}
</div>
{/if}
<Tr cls="subtle mt-4" t={t.tos} />
{/if}
</div>
{/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,
fallbackName,
},
state.featureSwitchIsTesting
state
)
return new SvelteUIElement(ReviewForm, {
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",
},
],
constr: (state, tags, args, feature, layer) => {
constr: (state, tags, args, feature) => {
const nameKey = args[0] ?? "name"
const fallbackName = args[1]
const reviews = FeatureReviews.construct(
@ -87,9 +87,9 @@ export class ReviewSpecialVisualisations {
nameKey: nameKey,
fallbackName,
},
state.featureSwitchIsTesting
state
)
return new SvelteUIElement(AllReviews, { reviews, state, tags, feature, layer })
return new SvelteUIElement(AllReviews, { reviews, state })
},
}
return [
@ -120,7 +120,7 @@ export class ReviewSpecialVisualisations {
nameKey: nameKey,
fallbackName,
},
state.featureSwitchIsTesting
state
)
return new SvelteUIElement(StarsBarIcon, {
score: reviews.average,
@ -181,7 +181,6 @@ export class ReviewSpecialVisualisations {
): BaseUIElement {
return new Combine([
createReview.constr(state, tagSource, args, feature, layer),
listReviews.constr(state, tagSource, args, feature, layer),
])
},