Reviews: hopefully fix #1782, add review overview

This commit is contained in:
Pieter Vander Vennet 2024-02-15 03:11:10 +01:00
parent 8be41571fa
commit 592adfdf2a
11 changed files with 172 additions and 32 deletions

View file

@ -671,6 +671,7 @@
"reviewPlaceholder": "Describe your experience…", "reviewPlaceholder": "Describe your experience…",
"reviewing_as": "Reviewing as {nickname}", "reviewing_as": "Reviewing as {nickname}",
"reviewing_as_anonymous": "Reviewing as anonymous", "reviewing_as_anonymous": "Reviewing as anonymous",
"reviews_bug": "Expected more reviews? Some reviews are not displayed due to a bug.",
"save": "Save review", "save": "Save review",
"saved": "Review saved. Thanks for sharing!", "saved": "Review saved. Thanks for sharing!",
"saving_review": "Saving…", "saving_review": "Saving…",
@ -678,7 +679,9 @@
"title_singular": "One review", "title_singular": "One review",
"too_long": "At most {max} characters are allowed. Your review has {amount} characters.", "too_long": "At most {max} characters are allowed. Your review has {amount} characters.",
"tos": "If you create a review, you agree to <a href='https://mangrove.reviews/terms' target='_blank'>the TOS and privacy policy of Mangrove.reviews</a>", "tos": "If you create a review, you agree to <a href='https://mangrove.reviews/terms' target='_blank'>the TOS and privacy policy of Mangrove.reviews</a>",
"write_a_comment": "Leave a review…" "write_a_comment": "Leave a review…",
"your_reviews": "Your previous reviews",
"your_reviews_empty": "We couldn't find any of your previous reviews"
}, },
"split": { "split": {
"cancel": "Cancel", "cancel": "Cancel",

View file

@ -73,7 +73,6 @@ export default class UserRelatedState {
constructor( constructor(
osmConnection: OsmConnection, osmConnection: OsmConnection,
availableLanguages?: string[],
layout?: LayoutConfig, layout?: LayoutConfig,
featureSwitches?: FeatureSwitchState, featureSwitches?: FeatureSwitchState,
mapProperties?: MapProperties mapProperties?: MapProperties
@ -365,6 +364,11 @@ export default class UserRelatedState {
[translationMode] [translationMode]
) )
this.mangroveIdentity.getKeyId().addCallbackAndRun(kid => {
amendedPrefs.data["mangrove_kid"] = kid
amendedPrefs.ping()
})
const usersettingMetaTagging = new ThemeMetaTagging() const usersettingMetaTagging = new ThemeMetaTagging()
osmConnection.userDetails.addCallback((userDetails) => { osmConnection.userDetails.addCallback((userDetails) => {
for (const k in userDetails) { for (const k in userDetails) {

View file

@ -306,7 +306,8 @@ export abstract class Store<T> implements Readable<T> {
export class ImmutableStore<T> extends Store<T> { export class ImmutableStore<T> extends Store<T> {
public readonly data: T public readonly data: T
static FALSE = new ImmutableStore<boolean>(false)
static TRUE = new ImmutableStore<boolean>(true)
constructor(data: T) { constructor(data: T) {
super() super()
this.data = data this.data = data

View file

@ -5,10 +5,12 @@ import { Feature, Position } from "geojson"
import { GeoOperations } from "../GeoOperations" import { GeoOperations } from "../GeoOperations"
export class MangroveIdentity { export class MangroveIdentity {
public readonly keypair: Store<CryptoKeyPair> private readonly keypair: Store<CryptoKeyPair>
public readonly key_id: Store<string> private readonly mangroveIdentity: UIEventSource<string>
private readonly key_id: Store<string>
constructor(mangroveIdentity: UIEventSource<string>) { constructor(mangroveIdentity: UIEventSource<string>) {
this.mangroveIdentity = mangroveIdentity
const key_id = new UIEventSource<string>(undefined) const key_id = new UIEventSource<string>(undefined)
this.key_id = key_id this.key_id = key_id
const keypairEventSource = new UIEventSource<CryptoKeyPair>(undefined) const keypairEventSource = new UIEventSource<CryptoKeyPair>(undefined)
@ -23,13 +25,7 @@ export class MangroveIdentity {
key_id.setData(pem) key_id.setData(pem)
}) })
try {
if (!Utils.runningFromConsole && (mangroveIdentity.data ?? "") === "") {
MangroveIdentity.CreateIdentity(mangroveIdentity).then((_) => {})
}
} catch (e) {
console.error("Could not create identity: ", e)
}
} }
/** /**
@ -44,8 +40,61 @@ export class MangroveIdentity {
// Identity has been loaded via osmPreferences by now - we don't overwrite // Identity has been loaded via osmPreferences by now - we don't overwrite
return return
} }
console.log("Creating a new Mangrove identity!")
identity.setData(JSON.stringify(jwk)) identity.setData(JSON.stringify(jwk))
} }
/**
* Only called to create a review.
*/
async getKeypair(): Promise<CryptoKeyPair> {
if(this.keypair.data ?? "" === ""){
// We want to create a review, but it seems like no key has been setup at this moment
// We create the key
try {
if (!Utils.runningFromConsole && (this.mangroveIdentity.data ?? "") === "") {
await MangroveIdentity.CreateIdentity(this.mangroveIdentity)
}
} catch (e) {
console.error("Could not create identity: ", e)
}
}
return this.keypair.data
}
getKeyId(): Store<string> {
return this.key_id
}
private allReviewsById : UIEventSource<(Review & {kid: string, signature: string})[]>= undefined
/**
* Gets all reviews that are made for the current identity.
*/
public getAllReviews(): Store<(Review & {kid: string, signature: string})[]>{
if(this.allReviewsById !== undefined){
return this.allReviewsById
}
this.allReviewsById = new UIEventSource( [])
this.key_id.map(pem => {
if(pem === undefined){
return []
}
MangroveReviews.getReviews({
kid: pem
}).then(allReviews => {
this.allReviewsById.setData(allReviews.reviews.map(r => ({
...r, ...r.payload
})))
})
})
return this.allReviewsById
}
addReview(review: Review & {kid, signature}) {
this.allReviewsById?.setData(this.allReviewsById?.data?.concat([review]))
}
} }
/** /**
@ -176,26 +225,30 @@ export default class FeatureReviews {
* The given review is uploaded to mangrove.reviews and added to the list of known reviews * The given review is uploaded to mangrove.reviews and added to the list of known reviews
*/ */
public async createReview(review: Omit<Review, "sub">): Promise<void> { public async createReview(review: Omit<Review, "sub">): Promise<void> {
if(review.opinion.length > FeatureReviews .REVIEW_OPINION_MAX_LENGTH){ if(review.opinion !== undefined && review.opinion.length > FeatureReviews .REVIEW_OPINION_MAX_LENGTH){
throw "Opinion too long, should be at most "+FeatureReviews.REVIEW_OPINION_MAX_LENGTH+" characters long" throw "Opinion too long, should be at most "+FeatureReviews.REVIEW_OPINION_MAX_LENGTH+" characters long"
} }
const r: Review = { const r: Review = {
sub: this.subjectUri.data, sub: this.subjectUri.data,
...review, ...review,
} }
const keypair: CryptoKeyPair = this._identity.keypair.data 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)
await MangroveReviews.submitReview(jwt) await MangroveReviews.submitReview(jwt)
this._reviews.data.push({ const reviewWithKid = {
...r, ...r,
kid, kid,
signature: jwt, signature: jwt,
madeByLoggedInUser: new ImmutableStore(true), madeByLoggedInUser: new ImmutableStore(true),
}) }
this._reviews.data.push( reviewWithKid)
this._reviews.ping() this._reviews.ping()
this._identity.addReview(reviewWithKid)
} }
/** /**
* Adds given reviews to the 'reviews'-UI-eventsource * Adds given reviews to the 'reviews'-UI-eventsource
* @param reviews * @param reviews
@ -235,7 +288,7 @@ export default class FeatureReviews {
...review, ...review,
kid: reviewData.kid, kid: reviewData.kid,
signature: reviewData.signature, signature: reviewData.signature,
madeByLoggedInUser: this._identity.key_id.map((user_key_id) => { madeByLoggedInUser: this._identity.getKeyId().map((user_key_id) => {
return reviewData.kid === user_key_id return reviewData.kid === user_key_id
}), }),
}) })

View file

@ -171,7 +171,6 @@ export default class ThemeViewState implements SpecialVisualizationState {
}) })
this.userRelatedState = new UserRelatedState( this.userRelatedState = new UserRelatedState(
this.osmConnection, this.osmConnection,
layout?.language,
layout, layout,
this.featureSwitches, this.featureSwitches,
this.mapProperties this.mapProperties

View file

@ -29,7 +29,7 @@
center() center()
} }
const titleIconBlacklist = ["osmlink", "sharelink", "favourite_title_icon"] let titleIconBlacklist = ["osmlink", "sharelink", "favourite_title_icon"]
</script> </script>
{#if favLayer !== undefined} {#if favLayer !== undefined}

View file

@ -48,7 +48,7 @@
<div class="flex flex-col" on:keypress={(e) => console.log("Got keypress", e)}> <div class="flex flex-col" on:keypress={(e) => console.log("Got keypress", e)}>
<Tr t={Translations.t.favouritePoi.intro.Subs({ length: $favourites?.length ?? 0 })} /> <Tr t={Translations.t.favouritePoi.intro.Subs({ length: $favourites?.length ?? 0 })} />
<Tr t={Translations.t.favouritePoi.priintroPrivacyvacy} /> <Tr t={Translations.t.favouritePoi.introPrivacy} />
{#each $favourites as feature (feature.properties.id)} {#each $favourites as feature (feature.properties.id)}
<FavouriteSummary {feature} {state} /> <FavouriteSummary {feature} {state} />

View file

@ -35,9 +35,9 @@
let _state: "ask" | "saving" | "done" = "ask" let _state: "ask" | "saving" | "done" = "ask"
const connection = state.osmConnection let connection = state.osmConnection
const hasError: Store<undefined | "too_long"> = opinion.mapD(op => { let hasError: Store<undefined | "too_long"> = opinion.mapD(op => {
const tooLong = op.length > FeatureReviews.REVIEW_OPINION_MAX_LENGTH const tooLong = op.length > FeatureReviews.REVIEW_OPINION_MAX_LENGTH
if (tooLong) { if (tooLong) {
return "too_long" return "too_long"
@ -45,6 +45,8 @@
return undefined return undefined
}) })
let uploadFailed: string = undefined
async function save() { async function save() {
if (hasError.data) { if (hasError.data) {
return return
@ -63,13 +65,24 @@
console.log("Testing - not actually saving review", review) console.log("Testing - not actually saving review", review)
await Utils.waitFor(1000) await Utils.waitFor(1000)
} else { } else {
await reviews.createReview(review) try {
await reviews.createReview(review)
} catch (e) {
console.error("Could not create review due to", e)
uploadFailed = "" + e
}
} }
_state = "done" _state = "done"
} }
</script> </script>
{#if uploadFailed}
{#if _state === "done"} <div class="alert flex">
<ExclamationTriangle class="w-6 h-6" />
<Tr t={Translations.t.general.error}/>
{uploadFailed}
</div>
{:else if _state === "done"}
<Tr cls="thanks w-full" t={t.saved} /> <Tr cls="thanks w-full" t={t.saved} />
{:else if _state === "saving"} {:else if _state === "saving"}
<Loading> <Loading>
@ -109,8 +122,9 @@
/> />
{#if $hasError === "too_long"} {#if $hasError === "too_long"}
<div class="alert flex items-center px-2"> <div class="alert flex items-center px-2">
<ExclamationTriangle class="w-12 h-12"/> <ExclamationTriangle class="w-12 h-12" />
<Tr t={t.too_long.Subs({max: FeatureReviews.REVIEW_OPINION_MAX_LENGTH, amount: $opinion?.length ?? 0})}> </Tr> <Tr
t={t.too_long.Subs({max: FeatureReviews.REVIEW_OPINION_MAX_LENGTH, amount: $opinion?.length ?? 0})}></Tr>
</div> </div>
{/if} {/if}
</label> </label>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import LoginToggle from "../Base/LoginToggle.svelte"
import LoginButton from "../Base/LoginButton.svelte"
import SingleReview from "./SingleReview.svelte"
import Mangrove_logo from "../../assets/svg/Mangrove_logo.svelte"
/**
* A panel showing all the reviews by the logged-in user
*/
export let state: SpecialVisualizationState
let reviews = state.userRelatedState.mangroveIdentity.getAllReviews()
const t = Translations.t.reviews
</script>
<LoginToggle {state}>
<div slot="not-logged-in">
<LoginButton osmConnection={state.osmConnection}>
<Tr t={Translations.t.favouritePoi.loginToSeeList} />
</LoginButton>
</div>
{#if $reviews?.length > 0}
<div class="flex flex-col" on:keypress={(e) => console.log("Got keypress", e)}>
{#each $reviews as review (review.sub)}
<SingleReview {review} showSub={true} {state} />
{/each}
</div>
{:else}
<Tr t={t.your_reviews_empty} />
{/if}
<a class="link-underline" href="https://github.com/pietervdvn/MapComplete/issues/1782" target="_blank" rel="noopener noreferrer"><Tr t={t.reviews_bug}/></a>
<div class="flex justify-end">
<Mangrove_logo class="h-12 w-12 shrink-0 p-1" />
<Tr cls="text-sm subtle" t={t.attribution} />
</div>
</LoginToggle>

View file

@ -1,22 +1,43 @@
<script lang="ts"> <script lang="ts">
import { Review } from "mangrove-reviews-typescript" import { Review } from "mangrove-reviews-typescript"
import { Store } from "../../Logic/UIEventSource" import { ImmutableStore, Store } 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"
import { ariaLabel } from "../../Utils/ariaLabel" import { ariaLabel } from "../../Utils/ariaLabel"
import type { SpecialVisualizationState } from "../SpecialVisualization"
export let review: Review & { kid: string; signature: string; madeByLoggedInUser: Store<boolean> } export let state: SpecialVisualizationState = undefined
export let review: Review & { kid: string; signature: string; madeByLoggedInUser?: Store<boolean> }
let name = review.metadata.nickname let name = review.metadata.nickname
name ??= ((review.metadata.given_name ?? "") + " " + (review.metadata.family_name ?? "")).trim() name ??= ((review.metadata.given_name ?? "") + " " + (review.metadata.family_name ?? "")).trim()
let d = new Date() let d = new Date()
d.setTime(review.iat * 1000) d.setTime(review.iat * 1000)
let date = d.toDateString() let date = d.toDateString()
let byLoggedInUser = review.madeByLoggedInUser let byLoggedInUser = review.madeByLoggedInUser ?? ImmutableStore.FALSE
export let showSub = false
let subUrl = new URL(review.sub)
let [lat, lon] = subUrl.pathname.split(",").map(l => Number(l))
let sub = subUrl.searchParams.get("q")
function selectFeature(){
console.log("Selecting and zooming to", {lon, lat})
state?.mapProperties?.location?.setData({lon, lat})
state?.mapProperties?.zoom?.setData(Math.max(16, state?.mapProperties?.zoom?.data))
state?.guistate?.closeAll()
}
</script> </script>
<div class={"low-interaction rounded-lg p-1 px-2 " + ($byLoggedInUser ? "border-interactive" : "")}> <div class={"low-interaction rounded-lg p-1 px-2 flex flex-col" + ($byLoggedInUser ? "border-interactive" : "")}>
<div class="flex items-center justify-between"> {#if showSub}
<button class="link" on:click={() => selectFeature()}>
<h3>{sub}</h3>
</button>
{/if}
<div class="flex items-center justify-between w-full">
<div <div
tabindex="0" tabindex="0"
use:ariaLabel={Translations.t.reviews.rated.Subs({ use:ariaLabel={Translations.t.reviews.rated.Subs({

View file

@ -66,6 +66,7 @@
import FilterPanel from "./BigComponents/FilterPanel.svelte" import FilterPanel from "./BigComponents/FilterPanel.svelte"
import PrivacyPolicy from "./BigComponents/PrivacyPolicy.svelte" import PrivacyPolicy from "./BigComponents/PrivacyPolicy.svelte"
import { BBox } from "../Logic/BBox" import { BBox } from "../Logic/BBox"
import ReviewsOverview from "./Reviews/ReviewsOverview.svelte"
export let state: ThemeViewState export let state: ThemeViewState
let layout = state.layout let layout = state.layout
@ -588,6 +589,10 @@
<Tr t={Translations.t.favouritePoi.title} /> <Tr t={Translations.t.favouritePoi.title} />
</h3> </h3>
<Favourites {state} /> <Favourites {state} />
<h3>
<Tr t={Translations.t.reviews.your_reviews} />
</h3>
<ReviewsOverview {state}/>
</div> </div>
</TabbedGroup> </TabbedGroup>
</FloatOver> </FloatOver>