diff --git a/js/src/components/Event/AddressAutoComplete.vue b/js/src/components/Event/AddressAutoComplete.vue index e98c2fbb3..f156469b5 100644 --- a/js/src/components/Event/AddressAutoComplete.vue +++ b/js/src/components/Event/AddressAutoComplete.vue @@ -71,6 +71,7 @@ import { IConfig } from "../../types/config.model"; }) export default class AddressAutoComplete extends Vue { @Prop({ required: true }) value!: IAddress; + @Prop({ required: false, default: false }) type!: string | false; addressData: IAddress[] = []; @@ -118,13 +119,17 @@ export default class AddressAutoComplete extends Vue { } this.isFetching = true; + const variables: { query: string; locale: string; type?: string } = { + query, + locale: this.$i18n.locale, + }; + if (this.type) { + variables.type = this.type; + } const result = await this.$apollo.query({ query: ADDRESS, fetchPolicy: "network-only", - variables: { - query, - locale: this.$i18n.locale, - }, + variables, }); this.addressData = result.data.searchAddress.map( @@ -144,7 +149,7 @@ export default class AddressAutoComplete extends Vue { @Watch("value") updateEditing(): void { - if (!(this.value && this.value.id)) return; + if (!this.value?.id) return; this.selected = this.value; const address = new Address(this.selected); this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`; diff --git a/js/src/components/Event/EventCard.vue b/js/src/components/Event/EventCard.vue index f41a537bb..f6f8a7ee0 100644 --- a/js/src/components/Event/EventCard.vue +++ b/js/src/components/Event/EventCard.vue @@ -39,13 +39,24 @@ /> </div> <div class="media-content"> - <p class="event-title">{{ event.title }}</p> - <div class="event-subtitle" v-if="event.physicalAddress"> + <p class="event-title" :title="event.title">{{ event.title }}</p> + <div + class="event-subtitle" + v-if="event.physicalAddress" + :title=" + isDescriptionDifferentFromLocality + ? `${event.physicalAddress.description}, ${event.physicalAddress.locality}` + : event.physicalAddress.description + " + > <!-- <p>{{ $t('By @{username}', { username: actor.preferredUsername }) }}</p>--> - <span> + <span v-if="isDescriptionDifferentFromLocality"> {{ event.physicalAddress.description }}, {{ event.physicalAddress.locality }} </span> + <span v-else> + {{ event.physicalAddress.description }} + </span> </div> </div> </div> @@ -130,6 +141,14 @@ export default class EventCard extends Vue { this.event.organizerActor || this.mergedOptions.organizerActor ); } + + get isDescriptionDifferentFromLocality(): boolean { + return ( + this.event?.physicalAddress?.description !== + this.event?.physicalAddress?.locality && + this.event?.physicalAddress?.description !== undefined + ); + } } </script> diff --git a/js/src/components/Event/RecentEventCardWrapper.vue b/js/src/components/Event/RecentEventCardWrapper.vue new file mode 100644 index 000000000..bee20a990 --- /dev/null +++ b/js/src/components/Event/RecentEventCardWrapper.vue @@ -0,0 +1,35 @@ +<template> + <div> + <p class="time"> + {{ + formatDistanceToNow(new Date(event.publishAt || event.insertedAt), { + locale: $dateFnsLocale, + addSuffix: true, + }) || $t("Right now") + }} + </p> + <EventCard :event="event" /> + </div> +</template> +<script lang="ts"> +import { IEvent } from "@/types/event.model"; +import { formatDistanceToNow } from "date-fns"; +import { Component, Prop, Vue } from "vue-property-decorator"; +import EventCard from "./EventCard.vue"; + +@Component({ + components: { + EventCard, + }, +}) +export default class RecentEventCardWrapper extends Vue { + @Prop({ required: true, type: Object }) event!: IEvent; + + formatDistanceToNow = formatDistanceToNow; +} +</script> +<style lang="scss" scoped> +p.time { + color: $orange-2; +} +</style> diff --git a/js/src/graphql/address.ts b/js/src/graphql/address.ts index 9e3fdbc0e..527b86258 100644 --- a/js/src/graphql/address.ts +++ b/js/src/graphql/address.ts @@ -15,10 +15,11 @@ originId `; export const ADDRESS = gql` - query($query:String!, $locale: String) { + query($query:String!, $locale: String, $type: AddressSearchType) { searchAddress( query: $query, - locale: $locale + locale: $locale, + type: $type ) { ${$addressFragment} } diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts index 834a25f80..1012798c2 100644 --- a/js/src/graphql/event.ts +++ b/js/src/graphql/event.ts @@ -207,6 +207,7 @@ export const FETCH_EVENTS = gql` endsOn status visibility + insertedAt picture { id url @@ -673,3 +674,26 @@ export const FETCH_GROUP_EVENTS = gql` } } `; + +export const CLOSE_EVENTS = gql` + query CloseEvents($location: String, $radius: Float) { + searchEvents(location: $location, radius: $radius, page: 1, limit: 10) { + total + elements { + id + title + uuid + beginsOn + picture { + id + url + } + tags { + slug + title + } + __typename + } + } + } +`; diff --git a/js/src/graphql/user.ts b/js/src/graphql/user.ts index 158be5e71..b794ef1a8 100644 --- a/js/src/graphql/user.ts +++ b/js/src/graphql/user.ts @@ -125,6 +125,11 @@ export const USER_SETTINGS_FRAGMENT = gql` notificationBeforeEvent notificationPendingParticipation notificationPendingMembership + location { + range + geohash + name + } } `; @@ -149,6 +154,7 @@ export const SET_USER_SETTINGS = gql` $notificationBeforeEvent: Boolean $notificationPendingParticipation: NotificationPendingEnum $notificationPendingMembership: NotificationPendingEnum + $location: LocationInput ) { setUserSettings( timezone: $timezone @@ -157,6 +163,7 @@ export const SET_USER_SETTINGS = gql` notificationBeforeEvent: $notificationBeforeEvent notificationPendingParticipation: $notificationPendingParticipation notificationPendingMembership: $notificationPendingMembership + location: $location ) { ...UserSettingFragment } diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json index 669100da6..c03e07c49 100644 --- a/js/src/i18n/en_US.json +++ b/js/src/i18n/en_US.json @@ -850,5 +850,14 @@ "{instanceName} is an instance of {mobilizon_link}, a free software built with the community.": "{instanceName} is an instance of {mobilizon_link}, a free software built with the community.", "Open a topic on our forum": "Open a topic on our forum", "Open an issue on our bug tracker (advanced users)": "Open an issue on our bug tracker (advanced users)", - "Unable to copy to clipboard": "Unable to copy to clipboard" + "Unable to copy to clipboard": "Unable to copy to clipboard", + "{count} km": "{count} km", + "City or region": "City or region", + "Select a radius": "Select a radius", + "Your city or region and the radius will only be used to suggest you events nearby.": "Your city or region and the radius will only be used to suggest you events nearby.", + "Your upcoming events": "Your upcoming events", + "Last published events": "Last published events", + "On {instance}": "On {instance}", + "Close events": "Close events", + "Within {number} kilometers of {place}": "|Within one kilometer of {place}|Within {number} kilometers of {place}" } diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json index 30038166f..d0c26e188 100644 --- a/js/src/i18n/fr_FR.json +++ b/js/src/i18n/fr_FR.json @@ -945,5 +945,14 @@ "{instanceName} is an instance of {mobilizon_link}, a free software built with the community.": "{instanceName} est une instance de {mobilizon_link}, un logiciel libre construit de manière communautaire.", "Open a topic on our forum": "Ouvrir un sujet sur notre forum", "Open an issue on our bug tracker (advanced users)": "Ouvrir un ticket sur notre système de suivi des bugs (utilisateur⋅ices avancé⋅es)", - "Unable to copy to clipboard": "Impossible de copier dans le presse-papiers" + "Unable to copy to clipboard": "Impossible de copier dans le presse-papiers", + "Select a radius": "Select a radius", + "{count} km": "{count} km", + "City or region": "Ville ou région", + "Your city or region and the radius will only be used to suggest you events nearby.": "Votre ville ou région et le rayon seront uniquement utilisé pour vous suggérer des événements proches.", + "Your upcoming events": "Vos événements à venir", + "Last published events": "Derniers événements publiés", + "On {instance}": "Sur {instance}", + "Close events": "Événements proches", + "Within {number} kilometers of {place}": "|Dans un rayon d'un kilomètre de {place}|Dans un rayon de {number} kilomètres de {place}" } diff --git a/js/src/types/address.model.ts b/js/src/types/address.model.ts index 7ba83b53d..c1cee4738 100644 --- a/js/src/types/address.model.ts +++ b/js/src/types/address.model.ts @@ -66,9 +66,9 @@ export class Address implements IAddress { let alternativeName = ""; let poiIcon: IPOIIcon = poiIcons.default; // Google Maps doesn't have a type - if (this.type == null && this.description === this.street) + if (this.type == null && this.description === this.street) { this.type = "house"; - + } switch (this.type) { case "house": name = this.description; @@ -123,6 +123,9 @@ export class Address implements IAddress { if (name && alternativeName) { return `${name}, ${alternativeName}`; } + if (name) { + return name; + } return ""; } diff --git a/js/src/types/current-user.model.ts b/js/src/types/current-user.model.ts index 2c834c552..051b31f7f 100644 --- a/js/src/types/current-user.model.ts +++ b/js/src/types/current-user.model.ts @@ -12,13 +12,20 @@ export interface ICurrentUser { defaultActor?: IPerson; } +export interface IUserPreferredLocation { + range?: number; + name?: string; + geohash?: string; +} + export interface IUserSettings { - timezone: string; - notificationOnDay: boolean; - notificationEachWeek: boolean; - notificationBeforeEvent: boolean; - notificationPendingParticipation: INotificationPendingEnum; - notificationPendingMembership: INotificationPendingEnum; + timezone?: string; + notificationOnDay?: boolean; + notificationEachWeek?: boolean; + notificationBeforeEvent?: boolean; + notificationPendingParticipation?: INotificationPendingEnum; + notificationPendingMembership?: INotificationPendingEnum; + location?: IUserPreferredLocation; } export interface IUser extends ICurrentUser { diff --git a/js/src/types/enums.ts b/js/src/types/enums.ts index 18c5d7da6..80f36a31e 100644 --- a/js/src/types/enums.ts +++ b/js/src/types/enums.ts @@ -178,3 +178,7 @@ export enum GroupVisibility { UNLISTED = "UNLISTED", PRIVATE = "PRIVATE", } + +export enum AddressSearchType { + ADMINISTRATIVE = "ADMINISTRATIVE", +} diff --git a/js/src/views/Home.vue b/js/src/views/Home.vue index 1a8a27520..d5ba7ae83 100644 --- a/js/src/views/Home.vue +++ b/js/src/views/Home.vue @@ -47,20 +47,23 @@ </div> </section> <div - id="featured_events" + id="recent_events" class="container section" v-if="config && (!currentUser.id || !currentActor.id)" > - <section class="events-featured"> - <h2 class="title">{{ $t("Featured events") }}</h2> + <section class="events-recent"> + <h2 class="is-size-2 has-text-weight-bold"> + {{ $t("Last published events") }} + </h2> + <p> + {{ $t("On {instance}", { instance: config.name }) }} + <b-loading :active.sync="$apollo.loading" /> + </p> <b-loading :active.sync="$apollo.loading" /> - <div - v-if="filteredFeaturedEvents.length > 0" - class="columns is-multiline" - > + <div v-if="this.events.total > 0" class="columns is-multiline"> <div class="column is-one-third-desktop" - v-for="event in filteredFeaturedEvents.slice(0, 6)" + v-for="event in this.events.elements.slice(0, 6)" :key="event.uuid" > <EventCard :event="event" /> @@ -185,11 +188,12 @@ }) }}</b-message> </section> + <!-- Your upcoming events --> <section v-if="currentActor.id && goingToEvents.size > 0" class="container" > - <h3 class="title">{{ $t("Upcoming") }}</h3> + <h3 class="title">{{ $t("Your upcoming events") }}</h3> <b-loading :active.sync="$apollo.loading" /> <div v-for="row of goingToEvents" class="upcoming-events" :key="row[0]"> <span @@ -231,6 +235,7 @@ > </span> </section> + <!-- Last week events --> <section v-if="currentActor && lastWeekEvents.length > 0"> <h3 class="title">{{ $t("Last week") }}</h3> <b-loading :active.sync="$apollo.loading" /> @@ -244,19 +249,59 @@ /> </div> </section> - <section class="events-featured"> - <h2 class="title">{{ $t("Featured events") }}</h2> - <b-loading :active.sync="$apollo.loading" /> - <div - v-if="filteredFeaturedEvents.length > 0" - class="columns is-multiline" - > + <!-- Events close to you --> + <section class="events-close" v-if="closeEvents.total > 0"> + <h2 class="is-size-2 has-text-weight-bold"> + {{ $t("Close events") }} + </h2> + <p> + {{ + $tc( + "Within {number} kilometers of {place}", + loggedUser.settings.location.radius, + { + number: loggedUser.settings.location.radius, + place: loggedUser.settings.location.name, + } + ) + }} + <router-link :to="{ name: RouteName.PREFERENCES }"> + <b-icon + class="clickable" + icon="pencil" + :title="$t('Change')" + size="is-small" + /> + </router-link> + <b-loading :active.sync="$apollo.loading" /> + </p> + <div class="columns is-multiline"> <div class="column is-one-third-desktop" - v-for="event in filteredFeaturedEvents.slice(0, 6)" + v-for="event in closeEvents.elements.slice(0, 3)" :key="event.uuid" > - <EventCard :event="event" /> + <event-card :event="event" /> + </div> + </div> + </section> + <hr class="home-separator" /> + <section class="events-recent"> + <h2 class="is-size-2 has-text-weight-bold"> + {{ $t("Last published events") }} + </h2> + <p> + {{ $t("On {instance}", { instance: config.name }) }} + <b-loading :active.sync="$apollo.loading" /> + </p> + + <div v-if="this.events.total > 0" class="columns is-multiline"> + <div + class="column is-one-third-desktop" + v-for="event in this.events.elements.slice(0, 6)" + :key="event.uuid" + > + <recent-event-card-wrapper :event="event" /> </div> </div> <b-message v-else type="is-danger" @@ -279,9 +324,10 @@ import { ParticipantRole } from "@/types/enums"; import { Paginate } from "@/types/paginate"; import { supportsWebPFormat } from "@/utils/support"; import { IParticipant, Participant } from "../types/participant.model"; -import { FETCH_EVENTS } from "../graphql/event"; +import { CLOSE_EVENTS, FETCH_EVENTS } from "../graphql/event"; import EventListCard from "../components/Event/EventListCard.vue"; import EventCard from "../components/Event/EventCard.vue"; +import RecentEventCardWrapper from "../components/Event/RecentEventCardWrapper.vue"; import { CURRENT_ACTOR_CLIENT, LOGGED_USER_PARTICIPATIONS, @@ -330,7 +376,24 @@ import Subtitle from "../components/Utils/Subtitle.vue"; (participation: IParticipant) => new Participant(participation) ), skip() { - return this.currentUser.isLoggedIn === false; + return this.currentUser?.isLoggedIn === false; + }, + }, + closeEvents: { + query: CLOSE_EVENTS, + variables() { + return { + location: this.loggedUser?.settings?.location?.geohash, + radius: this.loggedUser?.settings?.location?.radius, + }; + }, + update: (data) => data.searchEvents, + skip() { + return ( + this.currentUser?.isLoggedIn === false && + this.loggedUser?.settings?.location?.geohash && + this.loggedUser?.settings?.location?.radius + ); }, }, }, @@ -339,6 +402,7 @@ import Subtitle from "../components/Utils/Subtitle.vue"; DateComponent, EventListCard, EventCard, + RecentEventCardWrapper, "settings-onboard": () => import("./User/SettingsOnboard.vue"), }, metaInfo() { @@ -364,7 +428,7 @@ export default class Home extends Vue { country = { name: null }; - currentUser!: ICurrentUser; + currentUser!: IUser; loggedUser!: ICurrentUser; @@ -378,6 +442,8 @@ export default class Home extends Vue { supportsWebPFormat = supportsWebPFormat; + closeEvents: Paginate<IEvent> = { elements: [], total: 0 }; + // get displayed_name() { // return this.loggedPerson && this.loggedPerson.name === null // ? this.loggedPerson.preferredUsername @@ -499,21 +565,6 @@ export default class Home extends Vue { return res; } - /** - * Return all events from server excluding the ones shown as participating - */ - get filteredFeaturedEvents(): IEvent[] { - return this.events.elements.filter( - ({ id }) => - !this.thisWeekGoingToEvents - .filter( - (participation) => participation.role === ParticipantRole.CREATOR - ) - .map(({ event: { id: eventId } }) => eventId) - .includes(id) - ); - } - eventDeleted(eventid: string): void { this.currentUserParticipations = this.currentUserParticipations.filter( (participation) => participation.event.id !== eventid @@ -549,7 +600,7 @@ main > div > .container { color: rgba(0, 0, 0, 0.87); } -.events-featured { +.events-recent { & > h3 { padding-left: 0.75rem; } @@ -620,7 +671,7 @@ section.hero { } } -#featured_events { +#recent_events { padding: 1rem 0; min-height: calc(100vh - 400px); z-index: 10; @@ -686,4 +737,12 @@ section.hero { #homepage { background: $white; } + +.home-separator { + background-color: $orange-2; +} + +.clickable { + cursor: pointer; +} </style> diff --git a/js/src/views/Search.vue b/js/src/views/Search.vue index 765c96899..143fad907 100644 --- a/js/src/views/Search.vue +++ b/js/src/views/Search.vue @@ -409,7 +409,7 @@ export default class Search extends Vue { } get geohash(): string | undefined { - if (this.location && this.location.geom) { + if (this.location?.geom) { const [lon, lat] = this.location.geom.split(";"); return ngeohash.encode(lat, lon, 6); } diff --git a/js/src/views/Settings/Notifications.vue b/js/src/views/Settings/Notifications.vue index aed37cf67..c204471a9 100644 --- a/js/src/views/Settings/Notifications.vue +++ b/js/src/views/Settings/Notifications.vue @@ -135,13 +135,14 @@ import RouteName from "../../router/name"; export default class Notifications extends Vue { loggedUser!: IUser; - notificationOnDay = true; + notificationOnDay: boolean | undefined = true; - notificationEachWeek = false; + notificationEachWeek: boolean | undefined = false; - notificationBeforeEvent = false; + notificationBeforeEvent: boolean | undefined = false; - notificationPendingParticipation = INotificationPendingEnum.NONE; + notificationPendingParticipation: INotificationPendingEnum | undefined = + INotificationPendingEnum.NONE; notificationPendingParticipationValues: Record<string, unknown> = {}; diff --git a/js/src/views/Settings/Preferences.vue b/js/src/views/Settings/Preferences.vue index 925243ea0..4cc9ff690 100644 --- a/js/src/views/Settings/Preferences.vue +++ b/js/src/views/Settings/Preferences.vue @@ -26,7 +26,7 @@ </option> </b-select> </b-field> - <b-field :label="$t('Timezone')"> + <b-field :label="$t('Timezone')" v-if="selectedTimezone"> <b-select :placeholder="$t('Select a timezone')" :loading="!config || !loggedUser" @@ -55,11 +55,46 @@ <b-message v-else type="is-danger">{{ $t("Unable to detect timezone.") }}</b-message> + <hr /> + <b-field grouped> + <b-field :label="$t('City or region')" expanded> + <address-auto-complete + v-if=" + loggedUser && loggedUser.settings && loggedUser.settings.location + " + :type="AddressSearchType.ADMINISTRATIVE" + v-model="address" + > + </address-auto-complete> + </b-field> + <b-field :label="$t('Radius')"> + <b-select + :placeholder="$t('Select a radius')" + v-model="locationRange" + > + <option + v-for="index in [1, 5, 10, 25, 50, 100]" + :key="index" + :value="index" + > + {{ $tc("{count} km", index, { count: index }) }} + </option> + </b-select> + </b-field> + </b-field> + <p> + {{ + $t( + "Your city or region and the radius will only be used to suggest you events nearby." + ) + }} + </p> </div> </div> </template> <script lang="ts"> import { Component, Vue, Watch } from "vue-property-decorator"; +import ngeohash from "ngeohash"; import { saveLocaleData } from "@/utils/auth"; import { TIMEZONES } from "../../graphql/config"; import { @@ -68,22 +103,28 @@ import { UPDATE_USER_LOCALE, } from "../../graphql/user"; import { IConfig } from "../../types/config.model"; -import { IUser } from "../../types/current-user.model"; +import { IUser, IUserSettings } from "../../types/current-user.model"; import langs from "../../i18n/langs.json"; import RouteName from "../../router/name"; +import AddressAutoComplete from "../../components/Event/AddressAutoComplete.vue"; +import { AddressSearchType } from "@/types/enums"; +import { Address, IAddress } from "@/types/address.model"; @Component({ apollo: { config: TIMEZONES, loggedUser: USER_SETTINGS, }, + components: { + AddressAutoComplete, + }, }) export default class Preferences extends Vue { config!: IConfig; loggedUser!: IUser; - selectedTimezone: string | null = null; + selectedTimezone: string | undefined = undefined; locale: string | null = null; @@ -91,14 +132,16 @@ export default class Preferences extends Vue { langs: Record<string, string> = langs; + AddressSearchType = AddressSearchType; + @Watch("loggedUser") setSavedTimezone(loggedUser: IUser): void { - if (loggedUser && loggedUser.settings.timezone) { + if (loggedUser?.settings?.timezone) { this.selectedTimezone = loggedUser.settings.timezone; } else { this.selectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } - if (loggedUser && loggedUser.locale) { + if (loggedUser?.locale) { this.locale = loggedUser.locale; } else { this.locale = this.$i18n.locale; @@ -145,12 +188,7 @@ export default class Preferences extends Vue { @Watch("selectedTimezone") async updateTimezone(): Promise<void> { if (this.selectedTimezone !== this.loggedUser.settings.timezone) { - await this.$apollo.mutate<{ setUserSetting: string }>({ - mutation: SET_USER_SETTINGS, - variables: { - timezone: this.selectedTimezone, - }, - }); + this.updateUserSettings({ timezone: this.selectedTimezone }); } } @@ -166,5 +204,67 @@ export default class Preferences extends Vue { saveLocaleData(this.locale); } } + + get address(): IAddress | null { + if ( + this.loggedUser?.settings?.location?.name && + this.loggedUser?.settings?.location?.geohash + ) { + const { latitude, longitude } = ngeohash.decode( + this.loggedUser?.settings?.location?.geohash + ); + const name = this.loggedUser?.settings?.location?.name; + return { + description: name, + locality: "", + type: "administrative", + geom: `${longitude};${latitude}`, + street: "", + postalCode: "", + region: "", + country: "", + }; + } + return null; + } + + set address(address: IAddress | null) { + if (address && address.geom) { + const { geom } = address; + const addressObject = new Address(address); + const queryText = addressObject.poiInfos.name; + const [lon, lat] = geom.split(";"); + const geohash = ngeohash.encode(lat, lon, 6); + if (queryText && geom) { + this.updateUserSettings({ + location: { + geohash, + name: queryText, + }, + }); + } + } + } + + get locationRange(): number | undefined { + return this.loggedUser?.settings?.location?.range; + } + + set locationRange(locationRange: number | undefined) { + if (locationRange) { + this.updateUserSettings({ + location: { + range: locationRange, + }, + }); + } + } + + private async updateUserSettings(userSettings: IUserSettings) { + await this.$apollo.mutate<{ setUserSetting: string }>({ + mutation: SET_USER_SETTINGS, + variables: userSettings, + }); + } } </script> diff --git a/lib/graphql/resolvers/address.ex b/lib/graphql/resolvers/address.ex index f2d5fc402..a2476a8dd 100644 --- a/lib/graphql/resolvers/address.ex +++ b/lib/graphql/resolvers/address.ex @@ -14,7 +14,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Address do @spec search(map, map, map) :: {:ok, [Address.t()]} def search( _parent, - %{query: query, locale: locale, page: _page, limit: _limit}, + %{query: query, locale: locale, page: _page, limit: _limit} = args, %{context: %{ip: ip}} ) do geolix = Geolix.lookup(ip) @@ -25,7 +25,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Address do _ -> nil end - addresses = Geospatial.service().search(query, lang: locale, country_code: country_code) + addresses = + Geospatial.service().search(query, + lang: locale, + country_code: country_code, + type: Map.get(args, :type) + ) {:ok, addresses} end diff --git a/lib/graphql/schema/address.ex b/lib/graphql/schema/address.ex index aba803ee6..bb1c1f622 100644 --- a/lib/graphql/schema/address.ex +++ b/lib/graphql/schema/address.ex @@ -56,6 +56,15 @@ defmodule Mobilizon.GraphQL.Schema.AddressType do field(:origin_id, :string, description: "The address's original ID from the provider") end + @desc """ + A list of possible values for the type option to search an address. + + Results may vary depending on the geocoding provider. + """ + enum :address_search_type do + value(:administrative, description: "Administrative results (cities, regions,...)") + end + object :address_queries do @desc "Search for an address" field :search_address, type: list_of(:address) do @@ -73,6 +82,8 @@ defmodule Mobilizon.GraphQL.Schema.AddressType do arg(:limit, :integer, default_value: 10, description: "The limit of search results per page") + arg(:type, :address_search_type, description: "Filter by type of results") + resolve(&Address.search/3) end diff --git a/lib/graphql/schema/event.ex b/lib/graphql/schema/event.ex index b10a3b2ca..e74760b8f 100644 --- a/lib/graphql/schema/event.ex +++ b/lib/graphql/schema/event.ex @@ -101,7 +101,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do # field(:sessions, list_of(:session)) field(:updated_at, :datetime, description: "When the event was last updated") - field(:created_at, :datetime, description: "When the event was created") + field(:inserted_at, :datetime, description: "When the event was created") field(:options, :event_options, description: "The event options") end diff --git a/lib/graphql/schema/user.ex b/lib/graphql/schema/user.ex index 10ad416ca..af62f798b 100644 --- a/lib/graphql/schema/user.ex +++ b/lib/graphql/schema/user.ex @@ -177,6 +177,10 @@ defmodule Mobilizon.GraphQL.Schema.UserType do description: "When does the user receives a notification about a new pending membership in one of the group they're admin for" ) + + field(:location, :location, + description: "The user's preferred location, where they want to be suggested events" + ) end @desc "The list of values the for pending notification settings" @@ -199,6 +203,25 @@ defmodule Mobilizon.GraphQL.Schema.UserType do ) end + object :location do + field(:range, :integer, description: "The range in kilometers the user wants to see events") + + field(:geohash, :string, description: "A geohash representing the user's preferred location") + + field(:name, :string, description: "A string describing the user's preferred location") + end + + @desc """ + The set of parameters needed to input a location + """ + input_object :location_input do + field(:range, :integer, description: "The range in kilometers the user wants to see events") + + field(:geohash, :string, description: "A geohash representing the user's preferred location") + + field(:name, :string, description: "A string describing the user's preferred location") + end + object :user_queries do @desc "Get an user" field :user, :user do @@ -343,6 +366,10 @@ defmodule Mobilizon.GraphQL.Schema.UserType do "When does the user receives a notification about a new pending membership in one of the group they're admin for" ) + arg(:location, :location_input, + description: "A geohash of the user's preferred location, where they want to see events" + ) + resolve(&User.set_user_setting/3) end diff --git a/lib/mobilizon/users/setting.ex b/lib/mobilizon/users/setting.ex index 9d20f5d39..38d762818 100644 --- a/lib/mobilizon/users/setting.ex +++ b/lib/mobilizon/users/setting.ex @@ -30,6 +30,8 @@ defmodule Mobilizon.Users.Setting do @attrs @required_attrs ++ @optional_attrs + @location_attrs [:name, :range, :geohash] + @primary_key {:user_id, :id, autogenerate: false} schema "user_settings" do field(:timezone, :string) @@ -45,6 +47,12 @@ defmodule Mobilizon.Users.Setting do default: :one_day ) + embeds_one :location, Location, on_replace: :update, primary_key: false do + field(:name, :string) + field(:range, :integer) + field(:geohash, :string) + end + belongs_to(:user, User, primary_key: true, type: :id, foreign_key: :id, define_field: false) timestamps() @@ -54,6 +62,12 @@ defmodule Mobilizon.Users.Setting do def changeset(setting, attrs) do setting |> cast(attrs, @attrs) + |> cast_embed(:location, with: &location_changeset/2) |> validate_required(@required_attrs) end + + def location_changeset(schema, params) do + schema + |> cast(params, @location_attrs) + end end diff --git a/lib/service/geospatial/addok.ex b/lib/service/geospatial/addok.ex index 95d38e4ae..908125e8f 100644 --- a/lib/service/geospatial/addok.ex +++ b/lib/service/geospatial/addok.ex @@ -40,7 +40,6 @@ defmodule Mobilizon.Service.Geospatial.Addok do @spec build_url(atom(), map(), list()) :: String.t() defp build_url(method, args, options) do limit = Keyword.get(options, :limit, 10) - coords = Keyword.get(options, :coords, nil) endpoint = Keyword.get(options, :endpoint, @endpoint) case method do @@ -48,8 +47,9 @@ defmodule Mobilizon.Service.Geospatial.Addok do "#{endpoint}/reverse/?lon=#{args.lon}&lat=#{args.lat}&limit=#{limit}" :search -> - url = "#{endpoint}/search/?q=#{URI.encode(args.q)}&limit=#{limit}" - if is_nil(coords), do: url, else: url <> "&lat=#{coords.lat}&lon=#{coords.lon}" + "#{endpoint}/search/?q=#{URI.encode(args.q)}&limit=#{limit}" + |> add_parameter(options, :country_code) + |> add_parameter(options, :type) end end @@ -89,4 +89,20 @@ defmodule Mobilizon.Service.Geospatial.Addok do Map.get(properties, "street") end end + + @spec add_parameter(String.t(), Keyword.t(), atom()) :: String.t() + defp add_parameter(url, options, key) do + value = Keyword.get(options, key) + + if is_nil(value), do: url, else: do_add_parameter(url, key, value) + end + + @spec do_add_parameter(String.t(), atom(), any()) :: String.t() + defp do_add_parameter(url, :coords, coords), + do: "#{url}&lat=#{coords.lat}&lon=#{coords.lon}" + + defp do_add_parameter(url, :type, :administrative), + do: "#{url}&type=municipality" + + defp do_add_parameter(url, :type, _type), do: url end diff --git a/lib/service/geospatial/google_maps.ex b/lib/service/geospatial/google_maps.ex index e5c6bc5e4..63f61729f 100644 --- a/lib/service/geospatial/google_maps.ex +++ b/lib/service/geospatial/google_maps.ex @@ -29,6 +29,9 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do @api_key_missing_message "API Key required to use Google Maps" + @geocode_endpoint "https://maps.googleapis.com/maps/api/geocode/json" + @details_endpoint "https://maps.googleapis.com/maps/api/place/details/json" + @impl Provider @doc """ Google Maps implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`. @@ -77,15 +80,13 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do api_key = Keyword.get(options, :api_key, @api_key) if is_nil(api_key), do: raise(ArgumentError, message: @api_key_missing_message) - url = - "https://maps.googleapis.com/maps/api/geocode/json?limit=#{limit}&key=#{api_key}&language=#{ - lang - }" + url = "#{@geocode_endpoint}?limit=#{limit}&key=#{api_key}&language=#{lang}" uri = case method do :search -> - url <> "&address=#{args.q}" + "#{url}&address=#{args.q}" + |> add_parameter(options, :type) :geocode -> zoom = Keyword.get(options, :zoom, 15) @@ -95,9 +96,7 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do url <> "&latlng=#{args.lat},#{args.lon}&result_type=#{result_type}" :place_details -> - "https://maps.googleapis.com/maps/api/place/details/json?key=#{api_key}&placeid=#{ - args.place_id - }" + "#{@details_endpoint}?key=#{api_key}&placeid=#{args.place_id}" end URI.encode(uri) @@ -173,4 +172,17 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do nil end end + + @spec add_parameter(String.t(), Keyword.t(), atom()) :: String.t() + defp add_parameter(url, options, key, default \\ nil) do + value = Keyword.get(options, key, default) + + if is_nil(value), do: url, else: do_add_parameter(url, key, value) + end + + @spec do_add_parameter(String.t(), atom(), any()) :: String.t() + defp do_add_parameter(url, :type, :administrative), + do: "#{url}&components=administrative_area" + + defp do_add_parameter(url, :type, _), do: url end diff --git a/lib/service/geospatial/mimirsbrunn.ex b/lib/service/geospatial/mimirsbrunn.ex index 01aa191b8..c469f16f8 100644 --- a/lib/service/geospatial/mimirsbrunn.ex +++ b/lib/service/geospatial/mimirsbrunn.ex @@ -43,13 +43,13 @@ defmodule Mobilizon.Service.Geospatial.Mimirsbrunn do defp build_url(method, args, options) do limit = Keyword.get(options, :limit, 10) lang = Keyword.get(options, :lang, "en") - coords = Keyword.get(options, :coords, nil) endpoint = Keyword.get(options, :endpoint, @endpoint) case method do :search -> - url = "#{endpoint}/autocomplete?q=#{URI.encode(args.q)}&lang=#{lang}&limit=#{limit}" - if is_nil(coords), do: url, else: url <> "&lat=#{coords.lat}&lon=#{coords.lon}" + "#{endpoint}/autocomplete?q=#{URI.encode(args.q)}&lang=#{lang}&limit=#{limit}" + |> add_parameter(options, :coords) + |> add_parameter(options, :type) :geocode -> "#{endpoint}/reverse?lon=#{args.lon}&lat=#{args.lat}" @@ -143,4 +143,20 @@ defmodule Mobilizon.Service.Geospatial.Mimirsbrunn do defp get_postal_code(%{"postcode" => nil}), do: nil defp get_postal_code(%{"postcode" => postcode}), do: postcode |> String.split(";") |> hd() + + @spec add_parameter(String.t(), Keyword.t(), atom()) :: String.t() + defp add_parameter(url, options, key) do + value = Keyword.get(options, key) + + if is_nil(value), do: url, else: do_add_parameter(url, key, value) + end + + @spec do_add_parameter(String.t(), atom(), any()) :: String.t() + defp do_add_parameter(url, :coords, coords), + do: "#{url}&lat=#{coords.lat}&lon=#{coords.lon}" + + defp do_add_parameter(url, :type, :administrative), + do: "#{url}&type=zone" + + defp do_add_parameter(url, :type, _type), do: url end diff --git a/lib/service/geospatial/nominatim.ex b/lib/service/geospatial/nominatim.ex index 9e94f8367..e42e4eef2 100644 --- a/lib/service/geospatial/nominatim.ex +++ b/lib/service/geospatial/nominatim.ex @@ -41,9 +41,6 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do limit = Keyword.get(options, :limit, 10) lang = Keyword.get(options, :lang, "en") endpoint = Keyword.get(options, :endpoint, @endpoint) - country_code = Keyword.get(options, :country_code) - zoom = Keyword.get(options, :zoom) - api_key = Keyword.get(options, :api_key, @api_key) url = case method do @@ -53,16 +50,15 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do }&addressdetails=1&namedetails=1" :geocode -> - url = - "#{endpoint}/reverse?format=geocodejson&lat=#{args.lat}&lon=#{args.lon}&accept-language=#{ - lang - }&addressdetails=1&namedetails=1" - - if is_nil(zoom), do: url, else: url <> "&zoom=#{zoom}" + "#{endpoint}/reverse?format=geocodejson&lat=#{args.lat}&lon=#{args.lon}&accept-language=#{ + lang + }&addressdetails=1&namedetails=1" + |> add_parameter(options, :zoom) end - url = if is_nil(country_code), do: url, else: "#{url}&countrycodes=#{country_code}" - if is_nil(api_key), do: url, else: url <> "&key=#{api_key}" + url + |> add_parameter(options, :country_code) + |> add_parameter(options, :api_key, @api_key) end @spec fetch_features(String.t()) :: list(Address.t()) @@ -146,4 +142,23 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do description end end + + @spec add_parameter(String.t(), Keyword.t(), atom()) :: String.t() + defp add_parameter(url, options, key, default \\ nil) do + value = Keyword.get(options, key, default) + + if is_nil(value), do: url, else: do_add_parameter(url, key, value) + end + + @spec do_add_parameter(String.t(), atom(), any()) :: String.t() + defp do_add_parameter(url, :zoom, zoom), + do: "#{url}&zoom=#{zoom}" + + @spec do_add_parameter(String.t(), atom(), any()) :: String.t() + defp do_add_parameter(url, :country_code, country_code), + do: "#{url}&countrycodes=#{country_code}" + + @spec do_add_parameter(String.t(), atom(), any()) :: String.t() + defp do_add_parameter(url, :api_key, api_key), + do: "#{url}&key=#{api_key}" end diff --git a/lib/service/geospatial/pelias.ex b/lib/service/geospatial/pelias.ex index f9d82797a..35fd2b5bc 100644 --- a/lib/service/geospatial/pelias.ex +++ b/lib/service/geospatial/pelias.ex @@ -8,7 +8,6 @@ defmodule Mobilizon.Service.Geospatial.Pelias do alias Mobilizon.Addresses.Address alias Mobilizon.Service.Geospatial.Provider alias Mobilizon.Service.HTTP.GeospatialClient - require Logger @behaviour Provider @@ -41,25 +40,20 @@ defmodule Mobilizon.Service.Geospatial.Pelias do defp build_url(method, args, options) do limit = Keyword.get(options, :limit, 10) lang = Keyword.get(options, :lang, "en") - coords = Keyword.get(options, :coords, nil) endpoint = Keyword.get(options, :endpoint, @endpoint) - country_code = Keyword.get(options, :country_code) url = case method do :search -> - url = - "#{endpoint}/v1/autocomplete?text=#{URI.encode(args.q)}&lang=#{lang}&size=#{limit}" - - if is_nil(coords), - do: url, - else: url <> "&focus.point.lat=#{coords.lat}&focus.point.lon=#{coords.lon}" + "#{endpoint}/v1/autocomplete?text=#{URI.encode(args.q)}&lang=#{lang}&size=#{limit}" + |> add_parameter(options, :coords) + |> add_parameter(options, :type) :geocode -> "#{endpoint}/v1/reverse?point.lon=#{args.lon}&point.lat=#{args.lat}" end - if is_nil(country_code), do: url, else: "#{url}&boundary.country=#{country_code}" + add_parameter(url, options, :country_code) end @spec fetch_features(String.t()) :: list(Address.t()) @@ -120,9 +114,31 @@ defmodule Mobilizon.Service.Geospatial.Pelias do "dependency" ] + @spec get_type(map()) :: String.t() | nil defp get_type(%{"layer" => layer}) when layer in @administrative_layers, do: "administrative" defp get_type(%{"layer" => "address"}), do: "house" defp get_type(%{"layer" => "street"}), do: "street" defp get_type(%{"layer" => "venue"}), do: "venue" defp get_type(%{"layer" => _}), do: nil + + @spec add_parameter(String.t(), Keyword.t(), atom()) :: String.t() + def add_parameter(url, options, key) do + value = Keyword.get(options, key) + + if is_nil(value), do: url, else: do_add_parameter(url, key, value) + end + + @spec do_add_parameter(String.t(), atom(), any()) :: String.t() + defp do_add_parameter(url, :coords, value), + do: "#{url}&focus.point.lat=#{value.lat}&focus.point.lon=#{value.lon}" + + defp do_add_parameter(url, :type, :administrative), + do: "#{url}&layers=coarse" + + defp do_add_parameter(url, :type, _type), do: url + + defp do_add_parameter(url, :country_code, nil), do: url + + defp do_add_parameter(url, :country_code, country_code), + do: "#{url}&boundary.country=#{country_code}" end diff --git a/lib/service/geospatial/provider.ex b/lib/service/geospatial/provider.ex index 20afc49ce..be3193e5c 100644 --- a/lib/service/geospatial/provider.ex +++ b/lib/service/geospatial/provider.ex @@ -54,6 +54,8 @@ defmodule Mobilizon.Service.Geospatial.Provider do * `coords` Map of coordinates (ex: `%{lon: 48.11, lat: -1.77}`) allowing to give a geographic priority to the search. Defaults to `nil`. + * `type` Filter by type of results. Allowed values: + * `:administrative` (cities, regions) ## Examples diff --git a/priv/repo/migrations/20210210143432_add_location_settings_to_user.exs b/priv/repo/migrations/20210210143432_add_location_settings_to_user.exs new file mode 100644 index 000000000..8513ce9f4 --- /dev/null +++ b/priv/repo/migrations/20210210143432_add_location_settings_to_user.exs @@ -0,0 +1,9 @@ +defmodule Mobilizon.Storage.Repo.Migrations.AddLocationSettingsToUser do + use Ecto.Migration + + def change do + alter table(:user_settings) do + add(:location, :map) + end + end +end