Reviews: hopefully fix #1782, add review overview
This commit is contained in:
parent
8be41571fa
commit
592adfdf2a
11 changed files with 172 additions and 32 deletions
|
@ -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",
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
40
src/UI/Reviews/ReviewsOverview.svelte
Normal file
40
src/UI/Reviews/ReviewsOverview.svelte
Normal 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>
|
|
@ -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({
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue