Add timezone handling

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2021-10-10 16:25:50 +02:00
parent eba3c70c9b
commit d58ca5743d
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
49 changed files with 1218 additions and 429 deletions

View File

@ -38,6 +38,7 @@
"bulma-divider": "^0.2.0", "bulma-divider": "^0.2.0",
"core-js": "^3.6.4", "core-js": "^3.6.4",
"date-fns": "^2.16.0", "date-fns": "^2.16.0",
"date-fns-tz": "^1.1.6",
"graphql": "^15.0.0", "graphql": "^15.0.0",
"graphql-tag": "^2.10.3", "graphql-tag": "^2.10.3",
"intersection-observer": "^0.12.0", "intersection-observer": "^0.12.0",

View File

@ -0,0 +1,124 @@
<template>
<address>
<b-icon
v-if="showIcon"
:icon="address.poiInfos.poiIcon.icon"
size="is-medium"
class="icon"
/>
<p>
<span
class="addressDescription"
:title="address.poiInfos.name"
v-if="address.poiInfos.name"
>
{{ address.poiInfos.name }}
</span>
<br v-if="address.poiInfos.name" />
<span class="has-text-grey-dark">
{{ address.poiInfos.alternativeName }}
</span>
<br />
<small
v-if="
userTimezoneDifferent &&
longShortTimezoneNamesDifferent &&
timezoneLongNameValid
"
class="has-text-grey-dark"
>
🌐
{{
$t("{timezoneLongName} ({timezoneShortName})", {
timezoneLongName,
timezoneShortName,
})
}}
</small>
<small v-else-if="userTimezoneDifferent" class="has-text-grey-dark">
🌐 {{ timezoneShortName }}
</small>
</p>
</address>
</template>
<script lang="ts">
import { IAddress } from "@/types/address.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class AddressInfo extends Vue {
@Prop({ required: true, type: Object as PropType<IAddress> })
address!: IAddress;
@Prop({ required: false, default: false, type: Boolean }) showIcon!: boolean;
@Prop({ required: false, default: false, type: Boolean })
showTimezone!: boolean;
@Prop({ required: false, type: String }) userTimezone!: string;
get userTimezoneDifferent(): boolean {
return (
this.userTimezone != undefined &&
this.address.timezone != undefined &&
this.userTimezone !== this.address.timezone
);
}
get longShortTimezoneNamesDifferent(): boolean {
return (
this.timezoneLongName != undefined &&
this.timezoneShortName != undefined &&
this.timezoneLongName !== this.timezoneShortName
);
}
get timezoneLongName(): string | undefined {
return this.timezoneName("long");
}
get timezoneShortName(): string | undefined {
return this.timezoneName("short");
}
get timezoneLongNameValid(): boolean {
return (
this.timezoneLongName != undefined && !this.timezoneLongName.match(/UTC/)
);
}
private timezoneName(format: "long" | "short"): string | undefined {
return this.extractTimezone(
new Intl.DateTimeFormat(undefined, {
timeZoneName: format,
timeZone: this.address.timezone,
}).formatToParts()
);
}
private extractTimezone(
parts: Intl.DateTimeFormatPart[]
): string | undefined {
return parts.find((part) => part.type === "timeZoneName")?.value;
}
}
</script>
<style lang="scss" scoped>
address {
font-style: normal;
display: flex;
justify-content: flex-start;
span.addressDescription {
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 0 auto;
min-width: 100%;
max-width: 4rem;
overflow: hidden;
}
span.icon {
padding-right: 1rem;
}
}
</style>

View File

@ -18,64 +18,97 @@
</docs> </docs>
<template> <template>
<span v-if="!endsOn">{{ <p v-if="!endsOn">
beginsOn | formatDateTimeString(showStartTime) <span>{{
}}</span> formatDateTimeString(beginsOn, timezoneToShow, showStartTime)
<span v-else-if="isSameDay() && showStartTime && showEndTime"> }}</span>
{{ <br />
<b-switch
size="is-small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</b-switch>
</p>
<p v-else-if="isSameDay() && showStartTime && showEndTime">
<span>{{
$t("On {date} from {startTime} to {endTime}", { $t("On {date} from {startTime} to {endTime}", {
date: formatDate(beginsOn), date: formatDate(beginsOn),
startTime: formatTime(beginsOn), startTime: formatTime(beginsOn, timezoneToShow),
endTime: formatTime(endsOn), endTime: formatTime(endsOn, timezoneToShow),
}) })
}} }}</span>
</span> <br />
<span v-else-if="isSameDay() && !showStartTime && showEndTime"> <b-switch
{{ size="is-small"
$t("On {date} ending at {endTime}", { v-model="showLocalTimezone"
date: formatDate(beginsOn), v-if="differentFromUserTimezone"
endTime: formatTime(endsOn), >
}) {{ singleTimeZone }}
}} </b-switch>
</span> </p>
<span v-else-if="isSameDay() && showStartTime && !showEndTime"> <p v-else-if="isSameDay() && showStartTime && !showEndTime">
{{ {{
$t("On {date} starting at {startTime}", { $t("On {date} starting at {startTime}", {
date: formatDate(beginsOn), date: formatDate(beginsOn),
startTime: formatTime(beginsOn), startTime: formatTime(beginsOn),
}) })
}} }}
</span> </p>
<span v-else-if="isSameDay()">{{ <p v-else-if="isSameDay()">
$t("On {date}", { date: formatDate(beginsOn) }) {{ $t("On {date}", { date: formatDate(beginsOn) }) }}
}}</span> </p>
<span v-else-if="endsOn && showStartTime && showEndTime"> <p v-else-if="endsOn && showStartTime && showEndTime">
{{ <span>
$t("From the {startDate} at {startTime} to the {endDate} at {endTime}", { {{
startDate: formatDate(beginsOn), $t(
startTime: formatTime(beginsOn), "From the {startDate} at {startTime} to the {endDate} at {endTime}",
endDate: formatDate(endsOn), {
endTime: formatTime(endsOn), startDate: formatDate(beginsOn),
}) startTime: formatTime(beginsOn, timezoneToShow),
}} endDate: formatDate(endsOn),
</span> endTime: formatTime(endsOn, timezoneToShow),
<span v-else-if="endsOn && showStartTime"> }
{{ )
$t("From the {startDate} at {startTime} to the {endDate}", { }}
startDate: formatDate(beginsOn), </span>
startTime: formatTime(beginsOn), <br />
endDate: formatDate(endsOn), <b-switch
}) size="is-small"
}} v-model="showLocalTimezone"
</span> v-if="differentFromUserTimezone"
<span v-else-if="endsOn"> >
{{ multipleTimeZones }}
</b-switch>
</p>
<p v-else-if="endsOn && showStartTime">
<span>
{{
$t("From the {startDate} at {startTime} to the {endDate}", {
startDate: formatDate(beginsOn),
startTime: formatTime(beginsOn, timezoneToShow),
endDate: formatDate(endsOn),
})
}}
</span>
<br />
<b-switch
size="is-small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</b-switch>
</p>
<p v-else-if="endsOn">
{{ {{
$t("From the {startDate} to the {endDate}", { $t("From the {startDate} to the {endDate}", {
startDate: formatDate(beginsOn), startDate: formatDate(beginsOn),
endDate: formatDate(endsOn), endDate: formatDate(endsOn),
}) })
}} }}
</span> </p>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
@ -90,14 +123,47 @@ export default class EventFullDate extends Vue {
@Prop({ required: false, default: true }) showEndTime!: boolean; @Prop({ required: false, default: true }) showEndTime!: boolean;
@Prop({ required: false }) timezone!: string;
@Prop({ required: false }) userTimezone!: string;
showLocalTimezone = true;
get timezoneToShow(): string {
if (this.showLocalTimezone) {
return this.timezone;
}
return this.userActualTimezone;
}
get userActualTimezone(): string {
if (this.userTimezone) {
return this.userTimezone;
}
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
formatDate(value: Date): string | undefined { formatDate(value: Date): string | undefined {
if (!this.$options.filters) return undefined; if (!this.$options.filters) return undefined;
return this.$options.filters.formatDateString(value); return this.$options.filters.formatDateString(value);
} }
formatTime(value: Date): string | undefined { formatTime(value: Date, timezone: string): string | undefined {
if (!this.$options.filters) return undefined; if (!this.$options.filters) return undefined;
return this.$options.filters.formatTimeString(value); return this.$options.filters.formatTimeString(value, timezone || undefined);
}
formatDateTimeString(
value: Date,
timezone: string,
showTime: boolean
): string | undefined {
if (!this.$options.filters) return undefined;
return this.$options.filters.formatDateTimeString(
value,
timezone,
showTime
);
} }
isSameDay(): boolean { isSameDay(): boolean {
@ -106,5 +172,35 @@ export default class EventFullDate extends Vue {
new Date(this.endsOn).toDateString(); new Date(this.endsOn).toDateString();
return this.endsOn !== undefined && sameDay; return this.endsOn !== undefined && sameDay;
} }
get differentFromUserTimezone(): boolean {
return (
!!this.timezone &&
!!this.userActualTimezone &&
this.timezone !== this.userActualTimezone
);
}
get singleTimeZone(): string {
if (this.showLocalTimezone) {
return this.$t("Local time ({timezone})", {
timezone: this.timezoneToShow,
}) as string;
}
return this.$t("Time in your timezone ({timezone})", {
timezone: this.timezoneToShow,
}) as string;
}
get multipleTimeZones(): string {
if (this.showLocalTimezone) {
return this.$t("Local time ({timezone})", {
timezone: this.timezoneToShow,
}) as string;
}
return this.$t("Times in your timezone ({timezone})", {
timezone: this.timezoneToShow,
}) as string;
}
} }
</script> </script>

View File

@ -0,0 +1,175 @@
<template>
<div class="modal-card">
<header class="modal-card-head">
<button type="button" class="delete" @click="$emit('close')" />
</header>
<div class="modal-card-body">
<section class="map">
<map-leaflet
:coords="physicalAddress.geom"
:marker="{
text: physicalAddress.fullName,
icon: physicalAddress.poiInfos.poiIcon.icon,
}"
/>
</section>
<section class="columns is-centered map-footer">
<div class="column is-half has-text-centered">
<p class="address">
<i class="mdi mdi-map-marker"></i>
{{ physicalAddress.fullName }}
</p>
<p class="getting-there">{{ $t("Getting there") }}</p>
<div
class="buttons"
v-if="
addressLinkToRouteByCar ||
addressLinkToRouteByBike ||
addressLinkToRouteByFeet
"
>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByFeet"
:href="addressLinkToRouteByFeet"
>
<i class="mdi mdi-walk"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByBike"
:href="addressLinkToRouteByBike"
>
<i class="mdi mdi-bike"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByTransit"
:href="addressLinkToRouteByTransit"
>
<i class="mdi mdi-bus"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByCar"
:href="addressLinkToRouteByCar"
>
<i class="mdi mdi-car"></i>
</a>
</div>
</div>
</section>
</div>
</div>
</template>
<script lang="ts">
import { Address, IAddress } from "@/types/address.model";
import { RoutingTransportationType, RoutingType } from "@/types/enums";
import { PropType } from "vue";
import { Component, Vue, Prop } from "vue-property-decorator";
const RoutingParamType = {
[RoutingType.OPENSTREETMAP]: {
[RoutingTransportationType.FOOT]: "engine=fossgis_osrm_foot",
[RoutingTransportationType.BIKE]: "engine=fossgis_osrm_bike",
[RoutingTransportationType.TRANSIT]: null,
[RoutingTransportationType.CAR]: "engine=fossgis_osrm_car",
},
[RoutingType.GOOGLE_MAPS]: {
[RoutingTransportationType.FOOT]: "dirflg=w",
[RoutingTransportationType.BIKE]: "dirflg=b",
[RoutingTransportationType.TRANSIT]: "dirflg=r",
[RoutingTransportationType.CAR]: "driving",
},
};
@Component({
components: {
"map-leaflet": () =>
import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
},
})
export default class EventMap extends Vue {
@Prop({ type: Object as PropType<IAddress> }) address!: IAddress;
@Prop({ type: String }) routingType!: RoutingType;
get physicalAddress(): Address | null {
if (!this.address) return null;
return new Address(this.address);
}
makeNavigationPath(
transportationType: RoutingTransportationType
): string | undefined {
const geometry = this.physicalAddress?.geom;
if (geometry) {
/**
* build urls to routing map
*/
if (!RoutingParamType[this.routingType][transportationType]) {
return;
}
const urlGeometry = geometry.split(";").reverse().join(",");
switch (this.routingType) {
case RoutingType.GOOGLE_MAPS:
return `https://maps.google.com/?saddr=Current+Location&daddr=${urlGeometry}&${
RoutingParamType[this.routingType][transportationType]
}`;
case RoutingType.OPENSTREETMAP:
default: {
const bboxX = geometry.split(";").reverse()[0];
const bboxY = geometry.split(";").reverse()[1];
return `https://www.openstreetmap.org/directions?from=&to=${urlGeometry}&${
RoutingParamType[this.routingType][transportationType]
}#map=14/${bboxX}/${bboxY}`;
}
}
}
}
get addressLinkToRouteByCar(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.CAR);
}
get addressLinkToRouteByBike(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.BIKE);
}
get addressLinkToRouteByFeet(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.FOOT);
}
get addressLinkToRouteByTransit(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.TRANSIT);
}
}
</script>
<style lang="scss" scoped>
.modal-card-head {
justify-content: flex-end;
button.delete {
margin-right: 1rem;
}
}
section.map {
height: calc(100% - 8rem);
width: calc(100% - 20px);
}
section.map-footer {
p.address {
margin: 1rem auto;
}
div.buttons {
justify-content: center;
}
}
</style>

View File

@ -11,7 +11,7 @@
<b-button <b-button
type="is-text" type="is-text"
class="map-show-button" class="map-show-button"
@click="showMap = !showMap" @click="$emit('showMapModal', true)"
v-if="physicalAddress.geom" v-if="physicalAddress.geom"
> >
{{ $t("Show map") }} {{ $t("Show map") }}
@ -24,6 +24,8 @@
:beginsOn="event.beginsOn" :beginsOn="event.beginsOn"
:show-start-time="event.options.showStartTime" :show-start-time="event.options.showStartTime"
:show-end-time="event.options.showEndTime" :show-end-time="event.options.showEndTime"
:timezone="event.options.timezone"
:userTimezone="userTimezone"
:endsOn="event.endsOn" :endsOn="event.endsOn"
/> />
</event-metadata-block> </event-metadata-block>
@ -130,91 +132,12 @@
> >
<span v-else>{{ extra.value }}</span> <span v-else>{{ extra.value }}</span>
</event-metadata-block> </event-metadata-block>
<b-modal
class="map-modal"
v-if="physicalAddress && physicalAddress.geom"
:active.sync="showMap"
has-modal-card
full-screen
>
<div class="modal-card">
<header class="modal-card-head">
<button type="button" class="delete" @click="showMap = false" />
</header>
<div class="modal-card-body">
<section class="map">
<map-leaflet
:coords="physicalAddress.geom"
:marker="{
text: physicalAddress.fullName,
icon: physicalAddress.poiInfos.poiIcon.icon,
}"
/>
</section>
<section class="columns is-centered map-footer">
<div class="column is-half has-text-centered">
<p class="address">
<i class="mdi mdi-map-marker"></i>
{{ physicalAddress.fullName }}
</p>
<p class="getting-there">{{ $t("Getting there") }}</p>
<div
class="buttons"
v-if="
addressLinkToRouteByCar ||
addressLinkToRouteByBike ||
addressLinkToRouteByFeet
"
>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByFeet"
:href="addressLinkToRouteByFeet"
>
<i class="mdi mdi-walk"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByBike"
:href="addressLinkToRouteByBike"
>
<i class="mdi mdi-bike"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByTransit"
:href="addressLinkToRouteByTransit"
>
<i class="mdi mdi-bus"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByCar"
:href="addressLinkToRouteByCar"
>
<i class="mdi mdi-car"></i>
</a>
</div>
</div>
</section>
</div>
</div>
</b-modal>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Address } from "@/types/address.model"; import { Address } from "@/types/address.model";
import { IConfig } from "@/types/config.model"; import { IConfig } from "@/types/config.model";
import { import { EventMetadataKeyType, EventMetadataType } from "@/types/enums";
EventMetadataKeyType,
EventMetadataType,
RoutingTransportationType,
RoutingType,
} from "@/types/enums";
import { IEvent } from "@/types/event.model"; import { IEvent } from "@/types/event.model";
import { PropType } from "vue"; import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
@ -224,11 +147,13 @@ import EventMetadataBlock from "./EventMetadataBlock.vue";
import EventFullDate from "./EventFullDate.vue"; import EventFullDate from "./EventFullDate.vue";
import PopoverActorCard from "../Account/PopoverActorCard.vue"; import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActorCard from "../../components/Account/ActorCard.vue"; import ActorCard from "../../components/Account/ActorCard.vue";
import AddressInfo from "../../components/Address/AddressInfo.vue";
import { import {
IEventMetadata, IEventMetadata,
IEventMetadataDescription, IEventMetadataDescription,
} from "@/types/event-metadata"; } from "@/types/event-metadata";
import { eventMetaDataList } from "../../services/EventMetadata"; import { eventMetaDataList } from "../../services/EventMetadata";
import { IUser } from "@/types/current-user.model";
@Component({ @Component({
components: { components: {
@ -236,15 +161,14 @@ import { eventMetaDataList } from "../../services/EventMetadata";
EventFullDate, EventFullDate,
PopoverActorCard, PopoverActorCard,
ActorCard, ActorCard,
"map-leaflet": () => AddressInfo,
import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
}, },
}) })
export default class EventMetadataSidebar extends Vue { export default class EventMetadataSidebar extends Vue {
@Prop({ type: Object as PropType<IEvent>, required: true }) event!: IEvent; @Prop({ type: Object as PropType<IEvent>, required: true }) event!: IEvent;
@Prop({ type: Object as PropType<IConfig>, required: true }) config!: IConfig; @Prop({ type: Object as PropType<IConfig>, required: true }) config!: IConfig;
@Prop({ required: true }) user!: IUser | undefined;
showMap = false; @Prop({ required: false, default: false }) showMap!: boolean;
RouteName = RouteName; RouteName = RouteName;
@ -255,21 +179,6 @@ export default class EventMetadataSidebar extends Vue {
EventMetadataType = EventMetadataType; EventMetadataType = EventMetadataType;
EventMetadataKeyType = EventMetadataKeyType; EventMetadataKeyType = EventMetadataKeyType;
RoutingParamType = {
[RoutingType.OPENSTREETMAP]: {
[RoutingTransportationType.FOOT]: "engine=fossgis_osrm_foot",
[RoutingTransportationType.BIKE]: "engine=fossgis_osrm_bike",
[RoutingTransportationType.TRANSIT]: null,
[RoutingTransportationType.CAR]: "engine=fossgis_osrm_car",
},
[RoutingType.GOOGLE_MAPS]: {
[RoutingTransportationType.FOOT]: "dirflg=w",
[RoutingTransportationType.BIKE]: "dirflg=b",
[RoutingTransportationType.TRANSIT]: "dirflg=r",
[RoutingTransportationType.CAR]: "driving",
},
};
get physicalAddress(): Address | null { get physicalAddress(): Address | null {
if (!this.event.physicalAddress) return null; if (!this.event.physicalAddress) return null;
@ -286,50 +195,6 @@ export default class EventMetadataSidebar extends Vue {
}); });
} }
makeNavigationPath(
transportationType: RoutingTransportationType
): string | undefined {
const geometry = this.physicalAddress?.geom;
if (geometry) {
const routingType = this.config.maps.routing.type;
/**
* build urls to routing map
*/
if (!this.RoutingParamType[routingType][transportationType]) {
return;
}
const urlGeometry = geometry.split(";").reverse().join(",");
switch (routingType) {
case RoutingType.GOOGLE_MAPS:
return `https://maps.google.com/?saddr=Current+Location&daddr=${urlGeometry}&${this.RoutingParamType[routingType][transportationType]}`;
case RoutingType.OPENSTREETMAP:
default: {
const bboxX = geometry.split(";").reverse()[0];
const bboxY = geometry.split(";").reverse()[1];
return `https://www.openstreetmap.org/directions?from=&to=${urlGeometry}&${this.RoutingParamType[routingType][transportationType]}#map=14/${bboxX}/${bboxY}`;
}
}
}
}
get addressLinkToRouteByCar(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.CAR);
}
get addressLinkToRouteByBike(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.BIKE);
}
get addressLinkToRouteByFeet(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.FOOT);
}
get addressLinkToRouteByTransit(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.TRANSIT);
}
urlToHostname(url: string): string | null { urlToHostname(url: string): string | null {
try { try {
return new URL(url).hostname; return new URL(url).hostname;
@ -362,6 +227,10 @@ export default class EventMetadataSidebar extends Vue {
} }
} }
} }
get userTimezone(): string | undefined {
return this.user?.settings?.timezone;
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -391,50 +260,6 @@ div.address-wrapper {
.map-show-button { .map-show-button {
cursor: pointer; cursor: pointer;
} }
address {
font-style: normal;
flex-wrap: wrap;
display: flex;
justify-content: flex-start;
span.addressDescription {
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 0 auto;
min-width: 100%;
max-width: 4rem;
overflow: hidden;
}
:not(.addressDescription) {
flex: 1;
min-width: 100%;
}
}
}
}
.map-modal {
.modal-card-head {
justify-content: flex-end;
button.delete {
margin-right: 1rem;
}
}
section.map {
height: calc(100% - 8rem);
width: calc(100% - 20px);
}
section.map-footer {
p.address {
margin: 1rem auto;
}
div.buttons {
justify-content: center;
}
} }
} }
</style> </style>

View File

@ -1,72 +1,89 @@
<template> <template>
<div class="address-autocomplete"> <div class="address-autocomplete columns is-desktop">
<b-field <div class="column">
:label-for="id" <b-field
expanded :label-for="id"
:message="fieldErrors"
:type="{ 'is-danger': fieldErrors.length }"
>
<template slot="label">
{{ actualLabel }}
<b-button
v-if="canShowLocateMeButton && !gettingLocation"
size="is-small"
icon-right="map-marker"
@click="locateMe"
:title="$t('Use my location')"
/>
<span
class="is-size-6 has-text-weight-normal"
v-else-if="gettingLocation"
>{{ $t("Getting location") }}</span
>
</template>
<b-autocomplete
:data="addressData"
v-model="queryText"
:placeholder="$t('e.g. 10 Rue Jangot')"
field="fullName"
:loading="isFetching"
@typing="fetchAsyncData"
icon="map-marker"
expanded expanded
@select="updateSelected" :message="fieldErrors"
v-bind="$attrs" :type="{ 'is-danger': fieldErrors.length }"
:id="id"
> >
<template #default="{ option }"> <template slot="label">
<b-icon :icon="option.poiInfos.poiIcon.icon" /> {{ actualLabel }}
<b>{{ option.poiInfos.name }}</b <b-button
><br /> v-if="canShowLocateMeButton && !gettingLocation"
<small>{{ option.poiInfos.alternativeName }}</small> size="is-small"
icon-right="map-marker"
@click="locateMe"
:title="$t('Use my location')"
/>
<span
class="is-size-6 has-text-weight-normal"
v-else-if="gettingLocation"
>{{ $t("Getting location") }}</span
>
</template> </template>
<template slot="empty"> <b-autocomplete
<span v-if="isFetching">{{ $t("Searching") }}</span> :data="addressData"
<div v-else-if="queryText.length >= 3" class="is-enabled"> v-model="queryText"
<span>{{ $t('No results for "{queryText}"', { queryText }) }}</span> :placeholder="$t('e.g. 10 Rue Jangot')"
<span>{{ field="fullName"
$t( :loading="isFetching"
"You can try another search term or drag and drop the marker on the map", @typing="fetchAsyncData"
{ icon="map-marker"
queryText, expanded
} @select="updateSelected"
) v-bind="$attrs"
}}</span> :id="id"
<!-- <p class="control" @click="openNewAddressModal">--> >
<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>--> <template #default="{ option }">
<!-- </p>--> <b-icon :icon="option.poiInfos.poiIcon.icon" />
</div> <b>{{ option.poiInfos.name }}</b
</template> ><br />
</b-autocomplete> <small>{{ option.poiInfos.alternativeName }}</small>
<b-button </template>
:disabled="!queryText" <template slot="empty">
@click="resetAddress" <span v-if="isFetching">{{ $t("Searching") }}</span>
class="reset-area" <div v-else-if="queryText.length >= 3" class="is-enabled">
icon-left="close" <span>{{
:title="$t('Clear address field')" $t('No results for "{queryText}"', { queryText })
/> }}</span>
</b-field> <span>{{
<div class="map" v-if="selected && selected.geom && selected.poiInfos"> $t(
"You can try another search term or drag and drop the marker on the map",
{
queryText,
}
)
}}</span>
<!-- <p class="control" @click="openNewAddressModal">-->
<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>-->
<!-- </p>-->
</div>
</template>
</b-autocomplete>
<b-button
:disabled="!queryText"
@click="resetAddress"
class="reset-area"
icon-left="close"
:title="$t('Clear address field')"
/>
</b-field>
<div class="card" v-if="selected.originId || selected.url">
<div class="card-content">
<address-info
:address="selected"
:show-icon="true"
:show-timezone="true"
:user-timezone="userTimezone"
/>
</div>
</div>
</div>
<div
class="map column"
v-if="selected && selected.geom && selected.poiInfos"
>
<map-leaflet <map-leaflet
:coords="selected.geom" :coords="selected.geom"
:marker="{ :marker="{
@ -126,14 +143,19 @@ import { Component, Prop, Watch, Mixins } from "vue-property-decorator";
import { LatLng } from "leaflet"; import { LatLng } from "leaflet";
import { Address, IAddress } from "../../types/address.model"; import { Address, IAddress } from "../../types/address.model";
import AddressAutoCompleteMixin from "../../mixins/AddressAutoCompleteMixin"; import AddressAutoCompleteMixin from "../../mixins/AddressAutoCompleteMixin";
import AddressInfo from "../../components/Address/AddressInfo.vue";
@Component({ @Component({
inheritAttrs: false, inheritAttrs: false,
components: {
AddressInfo,
},
}) })
export default class FullAddressAutoComplete extends Mixins( export default class FullAddressAutoComplete extends Mixins(
AddressAutoCompleteMixin AddressAutoCompleteMixin
) { ) {
@Prop({ required: false, default: "" }) label!: string; @Prop({ required: false, default: "" }) label!: string;
@Prop({ required: false }) userTimezone!: string;
addressModalActive = false; addressModalActive = false;

View File

@ -187,7 +187,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator"; import { Component, Ref, Vue, Watch } from "vue-property-decorator";
import Logo from "@/components/Logo.vue"; import Logo from "@/components/Logo.vue";
import { GraphQLError } from "graphql"; import { GraphQLError } from "graphql";
import { loadLanguageAsync } from "@/utils/i18n"; import { loadLanguageAsync } from "@/utils/i18n";
@ -259,6 +259,13 @@ export default class NavBar extends Vue {
displayName = displayName; displayName = displayName;
@Ref("user-dropdown") userDropDown!: any;
toggleMenu(): void {
console.debug("called toggleMenu");
this.userDropDown.showMenu();
}
@Watch("currentActor") @Watch("currentActor")
async initializeListOfIdentities(): Promise<void> { async initializeListOfIdentities(): Promise<void> {
if (!this.currentUser.isLoggedIn) return; if (!this.currentUser.isLoggedIn) return;

View File

@ -14,10 +14,11 @@ function formatDateString(value: string): string {
}); });
} }
function formatTimeString(value: string): string { function formatTimeString(value: string, timeZone: string): string {
return parseDateTime(value).toLocaleTimeString(locale(), { return parseDateTime(value).toLocaleTimeString(locale(), {
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
timeZone,
}); });
} }
@ -55,6 +56,7 @@ const SHORT_TIME_FORMAT_OPTIONS: DateTimeFormatOptions = {
function formatDateTimeString( function formatDateTimeString(
value: string, value: string,
timeZone: string | undefined = undefined,
showTime = true, showTime = true,
dateFormat = "long" dateFormat = "long"
): string { ): string {
@ -66,6 +68,7 @@ function formatDateTimeString(
options = { options = {
...options, ...options,
...(isLongFormat ? LONG_TIME_FORMAT_OPTIONS : SHORT_TIME_FORMAT_OPTIONS), ...(isLongFormat ? LONG_TIME_FORMAT_OPTIONS : SHORT_TIME_FORMAT_OPTIONS),
timeZone,
}; };
} }
const format = new Intl.DateTimeFormat(locale(), options); const format = new Intl.DateTimeFormat(locale(), options);

View File

@ -13,6 +13,7 @@ export const ADDRESS_FRAGMENT = gql`
type type
url url
originId originId
timezone
} }
`; `;

View File

@ -96,6 +96,31 @@ export const CONFIG = gql`
} }
`; `;
export const CONFIG_EDIT_EVENT = gql`
query EditEventConfig {
config {
timezones
features {
groups
}
anonymous {
participation {
allowed
validation {
email {
enabled
confirmationRequired
}
captcha {
enabled
}
}
}
}
}
}
`;
export const TERMS = gql` export const TERMS = gql`
query Terms($locale: String) { query Terms($locale: String) {
config { config {

View File

@ -46,6 +46,7 @@ const EVENT_OPTIONS_FRAGMENT = gql`
anonymousParticipation anonymousParticipation
showStartTime showStartTime
showEndTime showEndTime
timezone
offers { offers {
price price
priceCurrency priceCurrency

View File

@ -147,6 +147,17 @@ export const USER_SETTINGS = gql`
${USER_SETTINGS_FRAGMENT} ${USER_SETTINGS_FRAGMENT}
`; `;
export const LOGGED_USER_TIMEZONE = gql`
query LoggedUserTimezone {
loggedUser {
id
settings {
timezone
}
}
}
`;
export const SET_USER_SETTINGS = gql` export const SET_USER_SETTINGS = gql`
mutation SetUserSettings( mutation SetUserSettings(
$timezone: String $timezone: String

View File

@ -1158,5 +1158,47 @@
"Who can post a comment?": "Who can post a comment?", "Who can post a comment?": "Who can post a comment?",
"Does the event needs to be confirmed later or is it cancelled?": "Does the event needs to be confirmed later or is it cancelled?", "Does the event needs to be confirmed later or is it cancelled?": "Does the event needs to be confirmed later or is it cancelled?",
"When the post is private, you'll need to share the link around.": "When the post is private, you'll need to share the link around.", "When the post is private, you'll need to share the link around.": "When the post is private, you'll need to share the link around.",
"Reset": "Reset" "Reset": "Reset",
"Local time ({timezone})": "Local time ({timezone})",
"Time in your timezone ({timezone})": "Time in your timezone ({timezone})",
"Export": "Export",
"Times in your timezone ({timezone})": "Times in your timezone ({timezone})",
"Skip to main": "Skip to main",
"Comment body": "Comment body",
"has loaded": "has loaded",
"Follows": "Follows",
"Event description body": "Event description body",
"Event timezone will default to the timezone of the event's address if there is one, or to your own timezone setting.": "Event timezone will default to the timezone of the event's address if there is one, or to your own timezone setting.",
"Clear timezone field": "Clear timezone field",
"Group description body": "Group description body",
"Moderation logs": "Moderation logs",
"Post body": "Post body",
"{group} posts": "{group} posts",
"{group}'s todolists": "{group}'s todolists",
"Validating email": "Validating email",
"Redirecting to Mobilizon": "Redirecting to Mobilizon",
"Reset password": "Reset password",
"First steps": "First steps",
"Validating account": "Validating account",
"Navigated to {pageTitle}": "Navigated to {pageTitle}",
"Confirm participation": "Confirm participation",
"Participation with account": "Participation with account",
"Participation without account": "Participation without account",
"Unlogged participation": "Unlogged participation",
"Discussions list": "Discussions list",
"Create discussion": "Create discussion",
"Tag search": "Tag search",
"Homepage": "Homepage",
"About instance": "About instance",
"Privacy": "Privacy",
"Interact": "Interact",
"Account settings": "Account settings",
"Admin dashboard": "Admin dashboard",
"Admin settings": "Admin settings",
"Group profiles": "Group profiles",
"Reports list": "Reports list",
"Create identity": "Create identity",
"Resent confirmation email": "Resent confirmation email",
"Send password reset": "Send password reset",
"Email validate": "Email validate"
} }

View File

@ -1262,5 +1262,47 @@
"{profile} updated the member {member}.": "{profile} a mis à jour le ou la membre {member}.", "{profile} updated the member {member}.": "{profile} a mis à jour le ou la membre {member}.",
"{title} ({count} todos)": "{title} ({count} todos)", "{title} ({count} todos)": "{title} ({count} todos)",
"{username} was invited to {group}": "{username} a été invité à {group}", "{username} was invited to {group}": "{username} a été invité à {group}",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap" "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"Local time ({timezone})": "Heure locale ({timezone})",
"Time in your timezone ({timezone})": "Heure dans votre fuseau horaire ({timezone})",
"Export": "Export",
"Times in your timezone ({timezone})": "Heures dans votre fuseau horaire ({timezone})",
"has loaded": "a chargé",
"Skip to main": "",
"Navigated to {pageTitle}": "Navigué vers {pageTitle}",
"Comment body": "Corps du commentaire",
"Confirm participation": "Confirmer la participation",
"Participation with account": "Participation avec compte",
"Participation without account": "Participation sans compte",
"Unlogged participation": "Participation non connecté⋅e",
"Discussions list": "Liste des discussions",
"Create discussion": "Créer une discussion",
"Tag search": "Recherche par tag",
"Homepage": "Page d'accueil",
"About instance": "À propos de l'instance",
"Privacy": "Vie privée",
"Interact": "Interagir",
"Redirecting to Mobilizon": "Redirection vers Mobilizon",
"First steps": "",
"Account settings": "",
"Admin dashboard": "",
"Admin settings": "",
"Group profiles": "",
"Reports list": "",
"Moderation logs": "",
"Create identity": "",
"Resent confirmation email": "",
"Send password reset": "",
"Email validate": "",
"Validating account": "",
"Follows": "",
"Event description body": "",
"Event timezone will default to the timezone of the event's address if there is one, or to your own timezone setting.": "Le fuseau horaire de l'événement sera mis par défaut au fuseau horaire de l'addresse de l'événement s'il y en a une, ou bien à votre propre paramètre de fuseau horaire.",
"Clear timezone field": "",
"Group description body": "",
"Post body": "Corps du billet",
"{group} posts": "Billets de {group}",
"{group}'s todolists": "Liste de tâches de {group}",
"Validating email": "",
"Reset password": ""
} }

View File

@ -13,6 +13,7 @@ export interface IAddress {
geom?: string; geom?: string;
url?: string; url?: string;
originId?: string; originId?: string;
timezone?: string;
} }
export interface IPoiInfo { export interface IPoiInfo {
@ -44,20 +45,23 @@ export class Address implements IAddress {
geom?: string = ""; geom?: string = "";
timezone?: string = "";
constructor(hash?: IAddress) { constructor(hash?: IAddress) {
if (!hash) return; if (!hash) return;
this.id = hash.id; this.id = hash.id;
this.description = hash.description; this.description = hash.description?.trim();
this.street = hash.street; this.street = hash.street?.trim();
this.locality = hash.locality; this.locality = hash.locality?.trim();
this.postalCode = hash.postalCode; this.postalCode = hash.postalCode?.trim();
this.region = hash.region; this.region = hash.region?.trim();
this.country = hash.country; this.country = hash.country?.trim();
this.type = hash.type; this.type = hash.type;
this.geom = hash.geom; this.geom = hash.geom;
this.url = hash.url; this.url = hash.url;
this.originId = hash.originId; this.originId = hash.originId;
this.timezone = hash.timezone;
} }
get poiInfos(): IPoiInfo { get poiInfos(): IPoiInfo {

View File

@ -26,6 +26,7 @@ export interface IEventOptions {
showParticipationPrice: boolean; showParticipationPrice: boolean;
showStartTime: boolean; showStartTime: boolean;
showEndTime: boolean; showEndTime: boolean;
timezone: string | null;
} }
export class EventOptions implements IEventOptions { export class EventOptions implements IEventOptions {
@ -54,4 +55,6 @@ export class EventOptions implements IEventOptions {
showStartTime = true; showStartTime = true;
showEndTime = true; showEndTime = true;
timezone = null;
} }

View File

@ -35,7 +35,7 @@ interface IEventEditJSON {
id?: string; id?: string;
title: string; title: string;
description: string; description: string;
beginsOn: string; beginsOn: string | null;
endsOn: string | null; endsOn: string | null;
status: EventStatus; status: EventStatus;
visibility: EventVisibility; visibility: EventVisibility;
@ -92,6 +92,9 @@ export interface IEvent {
toEditJSON(): IEventEditJSON; toEditJSON(): IEventEditJSON;
} }
export interface IEditableEvent extends Omit<IEvent, "beginsOn"> {
beginsOn: Date | null;
}
export class EventModel implements IEvent { export class EventModel implements IEvent {
id?: string; id?: string;
@ -158,7 +161,7 @@ export class EventModel implements IEvent {
metadata: IEventMetadata[] = []; metadata: IEventMetadata[] = [];
constructor(hash?: IEvent) { constructor(hash?: IEvent | IEditableEvent) {
if (!hash) return; if (!hash) return;
this.id = hash.id; this.id = hash.id;
@ -170,8 +173,14 @@ export class EventModel implements IEvent {
this.slug = hash.slug; this.slug = hash.slug;
this.description = hash.description || ""; this.description = hash.description || "";
this.beginsOn = new Date(hash.beginsOn); if (hash.beginsOn) {
if (hash.endsOn) this.endsOn = new Date(hash.endsOn); this.beginsOn = new Date(hash.beginsOn);
}
if (hash.endsOn) {
this.endsOn = new Date(hash.endsOn);
} else {
this.endsOn = null;
}
this.publishAt = new Date(hash.publishAt); this.publishAt = new Date(hash.publishAt);
@ -217,12 +226,12 @@ export function removeTypeName(entity: any): any {
return entity; return entity;
} }
export function toEditJSON(event: IEvent): IEventEditJSON { export function toEditJSON(event: IEditableEvent): IEventEditJSON {
return { return {
id: event.id, id: event.id,
title: event.title, title: event.title,
description: event.description, description: event.description,
beginsOn: event.beginsOn.toISOString(), beginsOn: event.beginsOn ? event.beginsOn.toISOString() : null,
endsOn: event.endsOn ? event.endsOn.toISOString() : null, endsOn: event.endsOn ? event.endsOn.toISOString() : null,
status: event.status, status: event.status,
visibility: event.visibility, visibility: event.visibility,

View File

@ -44,9 +44,10 @@
:placeholder="$t('Type or select a date…')" :placeholder="$t('Type or select a date…')"
icon="calendar-today" icon="calendar-today"
:locale="$i18n.locale" :locale="$i18n.locale"
v-model="event.beginsOn" v-model="beginsOn"
horizontal-time-picker horizontal-time-picker
editable editable
:tz-offset="tzOffset(beginsOn)"
:datepicker="{ :datepicker="{
id: 'begins-on-field', id: 'begins-on-field',
'aria-next-label': $t('Next month'), 'aria-next-label': $t('Next month'),
@ -62,9 +63,10 @@
:placeholder="$t('Type or select a date…')" :placeholder="$t('Type or select a date…')"
icon="calendar-today" icon="calendar-today"
:locale="$i18n.locale" :locale="$i18n.locale"
v-model="event.endsOn" v-model="endsOn"
horizontal-time-picker horizontal-time-picker
:min-datetime="event.beginsOn" :min-datetime="beginsOn"
:tz-offset="tzOffset(endsOn)"
editable editable
:datepicker="{ :datepicker="{
id: 'ends-on-field', id: 'ends-on-field',
@ -75,12 +77,14 @@
</b-datetimepicker> </b-datetimepicker>
</b-field> </b-field>
<!-- <b-switch v-model="endsOnNull">{{ $t('No end date') }}</b-switch>-->
<b-button type="is-text" @click="dateSettingsIsOpen = true"> <b-button type="is-text" @click="dateSettingsIsOpen = true">
{{ $t("Date parameters") }} {{ $t("Date parameters") }}
</b-button> </b-button>
<full-address-auto-complete v-model="event.physicalAddress" /> <full-address-auto-complete
v-model="eventPhysicalAddress"
:user-timezone="userActualTimezone"
/>
<div class="field"> <div class="field">
<label class="label">{{ $t("Description") }}</label> <label class="label">{{ $t("Description") }}</label>
@ -332,9 +336,45 @@
<form action> <form action>
<div class="modal-card" style="width: auto"> <div class="modal-card" style="width: auto">
<header class="modal-card-head"> <header class="modal-card-head">
<p class="modal-card-title">{{ $t("Date and time settings") }}</p> <h3 class="modal-card-title">{{ $t("Date and time settings") }}</h3>
</header> </header>
<section class="modal-card-body"> <section class="modal-card-body">
<p>
{{
$t(
"Event timezone will default to the timezone of the event's address if there is one, or to your own timezone setting."
)
}}
</p>
<b-field :label="$t('Timezone')" label-for="timezone" expanded>
<b-select
:placeholder="$t('Select a timezone')"
:loading="!config"
v-model="timezone"
id="timezone"
>
<optgroup
:label="group"
v-for="(groupTimezones, group) in timezones"
:key="group"
>
<option
v-for="timezone in groupTimezones"
:value="`${group}/${timezone}`"
:key="timezone"
>
{{ sanitizeTimezone(timezone) }}
</option>
</optgroup>
</b-select>
<b-button
:disabled="!timezone"
@click="timezone = null"
class="reset-area"
icon-left="close"
:title="$t('Clear timezone field')"
/>
</b-field>
<b-field :label="$t('Event page settings')"> <b-field :label="$t('Event page settings')">
<b-switch v-model="eventOptions.showStartTime">{{ <b-switch v-model="eventOptions.showStartTime">{{
$t("Show the time when the event begins") $t("Show the time when the event begins")
@ -514,6 +554,7 @@ section {
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { getTimezoneOffset } from "date-fns-tz";
import PictureUpload from "@/components/PictureUpload.vue"; import PictureUpload from "@/components/PictureUpload.vue";
import EditorComponent from "@/components/Editor.vue"; import EditorComponent from "@/components/Editor.vue";
import TagInput from "@/components/Event/TagInput.vue"; import TagInput from "@/components/Event/TagInput.vue";
@ -541,6 +582,7 @@ import {
} from "../../graphql/event"; } from "../../graphql/event";
import { import {
EventModel, EventModel,
IEditableEvent,
IEvent, IEvent,
removeTypeName, removeTypeName,
toEditJSON, toEditJSON,
@ -566,7 +608,7 @@ import {
} from "../../utils/image"; } from "../../utils/image";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import "intersection-observer"; import "intersection-observer";
import { CONFIG } from "../../graphql/config"; import { CONFIG_EDIT_EVENT } from "../../graphql/config";
import { IConfig } from "../../types/config.model"; import { IConfig } from "../../types/config.model";
import { import {
ApolloCache, ApolloCache,
@ -575,6 +617,9 @@ import {
} from "@apollo/client/core"; } from "@apollo/client/core";
import cloneDeep from "lodash/cloneDeep"; import cloneDeep from "lodash/cloneDeep";
import { IEventOptions } from "@/types/event-options.model"; import { IEventOptions } from "@/types/event-options.model";
import { USER_SETTINGS } from "@/graphql/user";
import { IUser } from "@/types/current-user.model";
import { IAddress } from "@/types/address.model";
const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10; const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
@ -591,7 +636,8 @@ const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
}, },
apollo: { apollo: {
currentActor: CURRENT_ACTOR_CLIENT, currentActor: CURRENT_ACTOR_CLIENT,
config: CONFIG, loggedUser: USER_SETTINGS,
config: CONFIG_EDIT_EVENT,
identities: IDENTITIES, identities: IDENTITIES,
event: { event: {
query: FETCH_EVENT, query: FETCH_EVENT,
@ -643,9 +689,11 @@ export default class EditEvent extends Vue {
currentActor!: IActor; currentActor!: IActor;
event: IEvent = new EventModel(); loggedUser!: IUser;
unmodifiedEvent: IEvent = new EventModel(); event: IEditableEvent = new EventModel();
unmodifiedEvent: IEditableEvent = new EventModel();
identities: IActor[] = []; identities: IActor[] = [];
@ -671,8 +719,6 @@ export default class EditEvent extends Vue {
dateSettingsIsOpen = false; dateSettingsIsOpen = false;
endsOnNull = false;
saving = false; saving = false;
displayNameAndUsername = displayNameAndUsername; displayNameAndUsername = displayNameAndUsername;
@ -908,7 +954,7 @@ export default class EditEvent extends Vue {
*/ */
private postCreateOrUpdate(store: any, updateEvent: IEvent) { private postCreateOrUpdate(store: any, updateEvent: IEvent) {
const resultEvent: IEvent = { ...updateEvent }; const resultEvent: IEvent = { ...updateEvent };
console.log(resultEvent); console.debug("resultEvent", resultEvent);
if (!updateEvent.draft) { if (!updateEvent.draft) {
store.writeQuery({ store.writeQuery({
query: EVENT_PERSON_PARTICIPATION, query: EVENT_PERSON_PARTICIPATION,
@ -984,6 +1030,23 @@ export default class EditEvent extends Vue {
...toEditJSON(new EventModel(this.event)), ...toEditJSON(new EventModel(this.event)),
options: this.eventOptions, options: this.eventOptions,
}; };
console.debug(this.event.beginsOn?.toISOString());
// if (this.event.beginsOn && this.timezone) {
// console.debug(
// "begins on should be",
// zonedTimeToUtc(this.event.beginsOn, this.timezone).toISOString()
// );
// }
// if (this.event.beginsOn && this.timezone) {
// res.beginsOn = zonedTimeToUtc(
// this.event.beginsOn,
// this.timezone
// ).toISOString();
// }
const organizerActor = this.event.organizerActor?.id const organizerActor = this.event.organizerActor?.id
? this.event.organizerActor ? this.event.organizerActor
: this.organizerActor; : this.organizerActor;
@ -995,10 +1058,6 @@ export default class EditEvent extends Vue {
: null; : null;
res = { ...res, attributedToId }; res = { ...res, attributedToId };
if (this.endsOnNull) {
res.endsOn = null;
}
if (this.pictureFile) { if (this.pictureFile) {
const pictureObj = buildFileVariable(this.pictureFile, "picture"); const pictureObj = buildFileVariable(this.pictureFile, "picture");
res = { ...res, ...pictureObj }; res = { ...res, ...pictureObj };
@ -1119,13 +1178,16 @@ export default class EditEvent extends Vue {
); );
} }
get beginsOn(): Date { get beginsOn(): Date | null {
// if (this.timezone && this.event.beginsOn) {
// return utcToZonedTime(this.event.beginsOn, this.timezone);
// }
return this.event.beginsOn; return this.event.beginsOn;
} }
@Watch("beginsOn", { deep: true }) set beginsOn(beginsOn: Date | null) {
onBeginsOnChanged(beginsOn: string): void { this.event.beginsOn = beginsOn;
if (!this.event.endsOn) return; if (!this.event.endsOn || !beginsOn) return;
const dateBeginsOn = new Date(beginsOn); const dateBeginsOn = new Date(beginsOn);
const dateEndsOn = new Date(this.event.endsOn); const dateEndsOn = new Date(this.event.endsOn);
if (dateEndsOn < dateBeginsOn) { if (dateEndsOn < dateBeginsOn) {
@ -1137,13 +1199,94 @@ export default class EditEvent extends Vue {
} }
} }
/** get endsOn(): Date | null {
* In event endsOn datepicker, we lock starting with the day before the beginsOn date // if (this.event.endsOn && this.timezone) {
*/ // return utcToZonedTime(this.event.endsOn, this.timezone);
get minDateForEndsOn(): Date { // }
const minDate = new Date(this.event.beginsOn); return this.event.endsOn;
minDate.setDate(minDate.getDate() - 1); }
return minDate;
set endsOn(endsOn: Date | null) {
this.event.endsOn = endsOn;
}
get timezones(): Record<string, string[]> {
if (!this.config || !this.config.timezones) return {};
return this.config.timezones.reduce(
(acc: { [key: string]: Array<string> }, val: string) => {
const components = val.split("/");
const [prefix, suffix] = [
components.shift() as string,
components.join("/"),
];
const pushOrCreate = (
acc2: { [key: string]: Array<string> },
prefix2: string,
suffix2: string
) => {
// eslint-disable-next-line no-param-reassign
(acc2[prefix2] = acc2[prefix2] || []).push(suffix2);
return acc2;
};
if (suffix) {
return pushOrCreate(acc, prefix, suffix);
}
return pushOrCreate(acc, this.$t("Other") as string, prefix);
},
{}
);
}
// eslint-disable-next-line class-methods-use-this
sanitizeTimezone(timezone: string): string {
return timezone
.split("_")
.join(" ")
.replace("St ", "St. ")
.split("/")
.join(" - ");
}
get timezone(): string | null {
return this.event.options.timezone;
}
set timezone(timezone: string | null) {
this.event.options = {
...this.event.options,
timezone,
};
}
get userTimezone(): string | undefined {
return this.loggedUser?.settings?.timezone;
}
get userActualTimezone(): string {
if (this.userTimezone) {
return this.userTimezone;
}
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
tzOffset(date: Date): number {
if (this.timezone && date) {
const eventUTCOffset = getTimezoneOffset(this.timezone, date);
const localUTCOffset = getTimezoneOffset(this.userActualTimezone);
return (eventUTCOffset - localUTCOffset) / (60 * 1000);
}
return 0;
}
get eventPhysicalAddress(): IAddress | null {
return this.event.physicalAddress;
}
set eventPhysicalAddress(address: IAddress | null) {
if (address && address.timezone) {
this.timezone = address.timezone;
}
this.event.physicalAddress = address;
} }
} }
</script> </script>

View File

@ -303,6 +303,8 @@
v-if="event && config" v-if="event && config"
:event="event" :event="event"
:config="config" :config="config"
:user="loggedUser"
@showMapModal="showMap = true"
/> />
</div> </div>
</aside> </aside>
@ -458,6 +460,22 @@
</section> </section>
</div> </div>
</b-modal> </b-modal>
<b-modal
class="map-modal"
v-if="event.physicalAddress && event.physicalAddress.geom"
:active.sync="showMap"
has-modal-card
full-screen
:can-cancel="['escape', 'outside']"
>
<template #default="props">
<event-map
:routingType="routingType"
:address="event.physicalAddress"
@close="props.close"
/>
</template>
</b-modal>
</div> </div>
</div> </div>
</template> </template>
@ -508,11 +526,14 @@ import Subtitle from "../../components/Utils/Subtitle.vue";
import Tag from "../../components/Tag.vue"; import Tag from "../../components/Tag.vue";
import EventMetadataSidebar from "../../components/Event/EventMetadataSidebar.vue"; import EventMetadataSidebar from "../../components/Event/EventMetadataSidebar.vue";
import EventBanner from "../../components/Event/EventBanner.vue"; import EventBanner from "../../components/Event/EventBanner.vue";
import EventMap from "../../components/Event/EventMap.vue";
import PopoverActorCard from "../../components/Account/PopoverActorCard.vue"; import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
import { IParticipant } from "../../types/participant.model"; import { IParticipant } from "../../types/participant.model";
import { ApolloCache, FetchResult } from "@apollo/client/core"; import { ApolloCache, FetchResult } from "@apollo/client/core";
import { IEventMetadataDescription } from "@/types/event-metadata"; import { IEventMetadataDescription } from "@/types/event-metadata";
import { eventMetaDataList } from "../../services/EventMetadata"; import { eventMetaDataList } from "../../services/EventMetadata";
import { USER_SETTINGS } from "@/graphql/user";
import { IUser } from "@/types/current-user.model";
// noinspection TypeScriptValidateTypes // noinspection TypeScriptValidateTypes
@Component({ @Component({
@ -529,6 +550,7 @@ import { eventMetaDataList } from "../../services/EventMetadata";
PopoverActorCard, PopoverActorCard,
EventBanner, EventBanner,
EventMetadataSidebar, EventMetadataSidebar,
EventMap,
ShareEventModal: () => ShareEventModal: () =>
import( import(
/* webpackChunkName: "shareEventModal" */ "../../components/Event/ShareEventModal.vue" /* webpackChunkName: "shareEventModal" */ "../../components/Event/ShareEventModal.vue"
@ -567,9 +589,8 @@ import { eventMetaDataList } from "../../services/EventMetadata";
this.handleErrors(graphQLErrors); this.handleErrors(graphQLErrors);
}, },
}, },
currentActor: { currentActor: CURRENT_ACTOR_CLIENT,
query: CURRENT_ACTOR_CLIENT, loggedUser: USER_SETTINGS,
},
participations: { participations: {
query: EVENT_PERSON_PARTICIPATION, query: EVENT_PERSON_PARTICIPATION,
fetchPolicy: "cache-and-network", fetchPolicy: "cache-and-network",
@ -646,6 +667,8 @@ export default class Event extends EventMixin {
person!: IPerson; person!: IPerson;
loggedUser!: IUser;
participations: IParticipant[] = []; participations: IParticipant[] = [];
oldParticipationRole!: string; oldParticipationRole!: string;
@ -1130,6 +1153,12 @@ export default class Event extends EventMixin {
return acc; return acc;
}, {}); }, {});
} }
showMap = false;
get routingType(): string | undefined {
return this.config?.maps?.routing?.type;
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -59,7 +59,7 @@ describe("PostElementItem", () => {
postData.title postData.title
); );
expect(wrapper.find(".metadata").text()).toContain( expect(wrapper.find(".metadata").text()).toContain(
formatDateTimeString(postData.insertedAt, false) formatDateTimeString(postData.insertedAt, undefined, false)
); );
expect(wrapper.find(".metadata small").text()).not.toContain("Public"); expect(wrapper.find(".metadata small").text()).not.toContain("Public");

View File

@ -18,7 +18,7 @@
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]
}, },
"lib": ["esnext", "dom", "dom.iterable", "scripthost", "webworker"] "lib": ["esnext", "dom", "es2017.intl", "dom.iterable", "scripthost", "webworker"]
}, },
"include": [ "include": [
"src/**/*.ts", "src/**/*.ts",

View File

@ -4221,6 +4221,11 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0" whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0" whatwg-url "^8.0.0"
date-fns-tz@^1.1.6:
version "1.1.6"
resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-1.1.6.tgz#93cbf354e2aeb2cd312ffa32e462c1943cf20a8e"
integrity sha512-nyy+URfFI3KUY7udEJozcoftju+KduaqkVfwyTIE0traBiVye09QnyWKLZK7drRr5h9B7sPJITmQnS3U6YOdQg==
date-fns@^2.16.0: date-fns@^2.16.0:
version "2.25.0" version "2.25.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.25.0.tgz#8c5c8f1d958be3809a9a03f4b742eba894fc5680" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.25.0.tgz#8c5c8f1d958be3809a9a03f4b742eba894fc5680"

View File

@ -49,7 +49,7 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do
Create an actor locally by its URL (AP ID) Create an actor locally by its URL (AP ID)
""" """
@spec make_actor_from_url(url :: String.t(), preload :: boolean()) :: @spec make_actor_from_url(url :: String.t(), preload :: boolean()) ::
{:ok, Actor.t()} | {:error, make_actor_errors} {:ok, Actor.t()} | {:error, make_actor_errors | Ecto.Changeset.t()}
def make_actor_from_url(url, preload \\ false) do def make_actor_from_url(url, preload \\ false) do
if are_same_origin?(url, Endpoint.url()) do if are_same_origin?(url, Endpoint.url()) do
{:error, :actor_is_local} {:error, :actor_is_local}
@ -63,7 +63,7 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do
Logger.info("Actor #{url} was deleted") Logger.info("Actor #{url} was deleted")
{:error, :actor_deleted} {:error, :actor_deleted}
{:error, err} when err in [:http_error, :json_decode_error] -> {:error, err} ->
{:error, err} {:error, err}
end end
end end

View File

@ -35,7 +35,7 @@ defmodule Mobilizon.Federation.ActivityPub.Permission do
@doc """ @doc """
Check that actor can create such an object Check that actor can create such an object
""" """
@spec can_create_group_object?(String.t() | integer(), String.t() | integer(), Entity.t()) :: @spec can_create_group_object?(String.t() | integer(), String.t() | integer(), struct()) ::
boolean() boolean()
def can_create_group_object?( def can_create_group_object?(
actor_id, actor_id,

View File

@ -156,7 +156,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Events do
role role
) )
{:error, %Ecto.Changeset{} = err} -> {:error, _, %Ecto.Changeset{} = err, _} ->
{:error, err} {:error, err}
end end
else else

View File

@ -12,7 +12,8 @@ defmodule Mobilizon.GraphQL.API.Events do
@doc """ @doc """
Create an event Create an event
""" """
@spec create_event(map) :: {:ok, Activity.t(), Event.t()} | any @spec create_event(map) ::
{:ok, Activity.t(), Event.t()} | {:error, atom() | Ecto.Changeset.t()}
def create_event(args) do def create_event(args) do
# For now we don't federate drafts but it will be needed if we want to edit them as groups # For now we don't federate drafts but it will be needed if we want to edit them as groups
Actions.Create.create(:event, prepare_args(args), should_federate(args)) Actions.Create.create(:event, prepare_args(args), should_federate(args))
@ -21,7 +22,8 @@ defmodule Mobilizon.GraphQL.API.Events do
@doc """ @doc """
Update an event Update an event
""" """
@spec update_event(map, Event.t()) :: {:ok, Activity.t(), Event.t()} | any @spec update_event(map, Event.t()) ::
{:ok, Activity.t(), Event.t()} | {:error, atom | Ecto.Changeset.t()}
def update_event(args, %Event{} = event) do def update_event(args, %Event{} = event) do
Actions.Update.update(event, prepare_args(args), should_federate(args)) Actions.Update.update(event, prepare_args(args), should_federate(args))
end end

View File

@ -16,7 +16,9 @@ defmodule Mobilizon.GraphQL.Middleware.CurrentActorProvider do
_config _config
) do ) do
case Cachex.fetch(:default_actors, to_string(user_id), fn -> default(user) end) do case Cachex.fetch(:default_actors, to_string(user_id), fn -> default(user) end) do
{status, %Actor{} = current_actor} when status in [:ok, :commit] -> {status, %Actor{preferred_username: preferred_username} = current_actor}
when status in [:ok, :commit] ->
Sentry.Context.set_user_context(%{name: preferred_username})
context = Map.put(context, :current_actor, current_actor) context = Map.put(context, :current_actor, current_actor)
%Absinthe.Resolution{resolution | context: context} %Absinthe.Resolution{resolution | context: context}

View File

@ -11,7 +11,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Address do
@doc """ @doc """
Search an address Search an address
""" """
@spec search(map, map, map) :: {:ok, [Address.t()]} @spec search(map, map, map) :: {:ok, [map()]}
def search( def search(
_parent, _parent,
%{query: query, locale: locale, page: _page, limit: _limit} = args, %{query: query, locale: locale, page: _page, limit: _limit} = args,

View File

@ -13,9 +13,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
alias Mobilizon.Federation.ActivityPub.Activity alias Mobilizon.Federation.ActivityPub.Activity
alias Mobilizon.Federation.ActivityPub.Permission alias Mobilizon.Federation.ActivityPub.Permission
alias Mobilizon.Service.TimezoneDetector
import Mobilizon.Users.Guards, only: [is_moderator: 1] import Mobilizon.Users.Guards, only: [is_moderator: 1]
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
import Mobilizon.GraphQL.Resolvers.Event.Utils import Mobilizon.GraphQL.Resolvers.Event.Utils
require Logger
# We limit the max number of events that can be retrieved # We limit the max number of events that can be retrieved
@event_max_limit 100 @event_max_limit 100
@ -262,35 +264,47 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
def create_event( def create_event(
_parent, _parent,
%{organizer_actor_id: organizer_actor_id} = args, %{organizer_actor_id: organizer_actor_id} = args,
%{context: %{current_user: user}} = _resolution %{context: %{current_user: %User{} = user}} = _resolution
) do ) do
# See https://github.com/absinthe-graphql/absinthe/issues/490
if Config.only_groups_can_create_events?() and Map.get(args, :attributed_to_id) == nil do if Config.only_groups_can_create_events?() and Map.get(args, :attributed_to_id) == nil do
{:error, "only groups can create events"} {:error,
dgettext(
"errors",
"Only groups can create events"
)}
else else
with {:is_owned, %Actor{} = organizer_actor} <- User.owns_actor(user, organizer_actor_id), case User.owns_actor(user, organizer_actor_id) do
args <- Map.put(args, :options, args[:options] || %{}), {:is_owned, %Actor{} = organizer_actor} ->
{:group_check, true} <- {:group_check, is_organizer_group_member?(args)}, if is_organizer_group_member?(args) do
args_with_organizer <- Map.put(args, :organizer_actor, organizer_actor), args_with_organizer =
{:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} <- args |> Map.put(:organizer_actor, organizer_actor) |> extract_timezone(user.id)
API.Events.create_event(args_with_organizer) do
{:ok, event} case API.Events.create_event(args_with_organizer) do
else {:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} ->
{:group_check, false} -> {:ok, event}
{:error,
dgettext( {:error, %Ecto.Changeset{} = error} ->
"errors", {:error, error}
"Organizer profile doesn't have permission to create an event on behalf of this group"
)} {:error, err} ->
Logger.warning("Unknown error while creating event: #{inspect(err)}")
{:error,
dgettext(
"errors",
"Unknown error while creating event"
)}
end
else
{:error,
dgettext(
"errors",
"Organizer profile doesn't have permission to create an event on behalf of this group"
)}
end
{:is_owned, nil} -> {:is_owned, nil} ->
{:error, dgettext("errors", "Organizer profile is not owned by the user")} {:error, dgettext("errors", "Organizer profile is not owned by the user")}
{:error, _, %Ecto.Changeset{} = error, _} ->
{:error, error}
{:error, %Ecto.Changeset{} = error} ->
{:error, error}
end end
end end
end end
@ -314,6 +328,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
with {:ok, %Event{} = event} <- Events.get_event_with_preload(event_id), with {:ok, %Event{} = event} <- Events.get_event_with_preload(event_id),
{:ok, args} <- verify_profile_change(args, event, user, actor), {:ok, args} <- verify_profile_change(args, event, user, actor),
args <- extract_timezone(args, user.id),
{:event_can_be_managed, true} <- {:event_can_be_managed, true} <-
{:event_can_be_managed, can_event_be_updated_by?(event, actor)}, {:event_can_be_managed, can_event_be_updated_by?(event, actor)},
{:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} <- {:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} <-
@ -442,4 +457,42 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
{:ok, args} {:ok, args}
end end
end end
@spec extract_timezone(map(), String.t() | integer()) :: map()
defp extract_timezone(args, user_id) do
event_options = Map.get(args, :options, %{})
timezone = Map.get(event_options, :timezone)
physical_address = Map.get(args, :physical_address)
fallback_tz =
case Mobilizon.Users.get_setting(user_id) do
nil -> nil
setting -> setting |> Map.from_struct() |> get_in([:timezone])
end
timezone = determine_timezone(timezone, physical_address, fallback_tz)
event_options = Map.put(event_options, :timezone, timezone)
Map.put(args, :options, event_options)
end
@spec determine_timezone(
String.t() | nil,
any(),
String.t() | nil
) :: String.t() | nil
defp determine_timezone(timezone, physical_address, fallback_tz) do
case physical_address do
physical_address when is_map(physical_address) ->
TimezoneDetector.detect(
timezone,
physical_address.geom,
fallback_tz
)
_ ->
timezone || fallback_tz
end
end
end end

View File

@ -21,6 +21,7 @@ defmodule Mobilizon.GraphQL.Schema.AddressType do
field(:url, :string, description: "The address's URL") field(:url, :string, description: "The address's URL")
field(:id, :id, description: "The address's ID") field(:id, :id, description: "The address's ID")
field(:origin_id, :string, description: "The address's original ID from the provider") field(:origin_id, :string, description: "The address's original ID from the provider")
field(:timezone, :string, description: "The (estimated) timezone of the location")
end end
@desc """ @desc """
@ -54,6 +55,7 @@ defmodule Mobilizon.GraphQL.Schema.AddressType do
field(:url, :string, description: "The address's URL") field(:url, :string, description: "The address's URL")
field(:id, :id, description: "The address's ID") field(:id, :id, description: "The address's ID")
field(:origin_id, :string, description: "The address's original ID from the provider") field(:origin_id, :string, description: "The address's original ID from the provider")
field(:timezone, :string, description: "The (estimated) timezone of the location")
end end
@desc """ @desc """

View File

@ -237,6 +237,8 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:show_start_time, :boolean, description: "Show event start time") field(:show_start_time, :boolean, description: "Show event start time")
field(:show_end_time, :boolean, description: "Show event end time") field(:show_end_time, :boolean, description: "Show event end time")
field(:timezone, :string, description: "The event's timezone")
field(:hide_organizer_when_group_event, :boolean, field(:hide_organizer_when_group_event, :boolean,
description: description:
"Whether to show or hide the person organizer when event is organized by a group" "Whether to show or hide the person organizer when event is organized by a group"
@ -286,6 +288,8 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:show_start_time, :boolean, description: "Show event start time") field(:show_start_time, :boolean, description: "Show event start time")
field(:show_end_time, :boolean, description: "Show event end time") field(:show_end_time, :boolean, description: "Show event end time")
field(:timezone, :string, description: "The event's timezone")
field(:hide_organizer_when_group_event, :boolean, field(:hide_organizer_when_group_event, :boolean,
description: description:
"Whether to show or hide the person organizer when event is organized by a group" "Whether to show or hide the person organizer when event is organized by a group"
@ -393,7 +397,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
arg(:category, :string, default_value: "meeting", description: "The event's category") arg(:category, :string, default_value: "meeting", description: "The event's category")
arg(:physical_address, :address_input, description: "The event's physical address") arg(:physical_address, :address_input, description: "The event's physical address")
arg(:options, :event_options_input, description: "The event options") arg(:options, :event_options_input, default_value: %{}, description: "The event options")
arg(:metadata, list_of(:event_metadata_input), description: "The event metadata") arg(:metadata, list_of(:event_metadata_input), description: "The event metadata")
arg(:draft, :boolean, arg(:draft, :boolean,

View File

@ -48,6 +48,7 @@ defmodule Mobilizon do
Guardian.DB.Token.SweeperServer, Guardian.DB.Token.SweeperServer,
ActivityPub.Federator, ActivityPub.Federator,
Mobilizon.PythonWorker, Mobilizon.PythonWorker,
TzWorld.Backend.DetsWithIndexCache,
cachex_spec(:feed, 2500, 60, 60, &Feed.create_cache/1), cachex_spec(:feed, 2500, 60, 60, &Feed.create_cache/1),
cachex_spec(:ics, 2500, 60, 60, &ICalendar.create_cache/1), cachex_spec(:ics, 2500, 60, 60, &ICalendar.create_cache/1),
cachex_spec( cachex_spec(

View File

@ -12,17 +12,18 @@ defmodule Mobilizon.Addresses.Address do
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
@type t :: %__MODULE__{ @type t :: %__MODULE__{
country: String.t(), country: String.t() | nil,
locality: String.t(), locality: String.t() | nil,
region: String.t(), region: String.t() | nil,
description: String.t(), description: String.t() | nil,
geom: Geo.PostGIS.Geometry.t(), geom: Geo.PostGIS.Geometry.t() | nil,
postal_code: String.t(), postal_code: String.t() | nil,
street: String.t(), street: String.t() | nil,
type: String.t(), type: String.t() | nil,
url: String.t(), url: String.t(),
origin_id: String.t(), origin_id: String.t() | nil,
events: [Event.t()] events: [Event.t()],
timezone: String.t() | nil
} }
@required_attrs [:url] @required_attrs [:url]
@ -35,7 +36,8 @@ defmodule Mobilizon.Addresses.Address do
:postal_code, :postal_code,
:street, :street,
:origin_id, :origin_id,
:type :type,
:timezone
] ]
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@ -50,6 +52,7 @@ defmodule Mobilizon.Addresses.Address do
field(:type, :string) field(:type, :string)
field(:url, :string) field(:url, :string)
field(:origin_id, :string) field(:origin_id, :string)
field(:timezone, :string)
has_many(:events, Event, foreign_key: :physical_address_id) has_many(:events, Event, foreign_key: :physical_address_id)
@ -61,6 +64,7 @@ defmodule Mobilizon.Addresses.Address do
def changeset(%__MODULE__{} = address, attrs) do def changeset(%__MODULE__{} = address, attrs) do
address address
|> cast(attrs, @attrs) |> cast(attrs, @attrs)
|> maybe_set_timezone()
|> set_url() |> set_url()
|> validate_required(@required_attrs) |> validate_required(@required_attrs)
|> unique_constraint(:url, name: :addresses_url_index) |> unique_constraint(:url, name: :addresses_url_index)
@ -90,4 +94,29 @@ defmodule Mobilizon.Addresses.Address do
"#{address.street} #{address.postal_code} #{address.locality} #{address.region} #{address.country}" "#{address.street} #{address.postal_code} #{address.locality} #{address.region} #{address.country}"
) )
end end
@spec maybe_set_timezone(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp maybe_set_timezone(%Ecto.Changeset{} = changeset) do
case get_change(changeset, :geom) do
nil ->
changeset
geom ->
case get_field(changeset, :timezone) do
# Only update the timezone if the geom has change and we don't already have a set timezone
nil -> put_change(changeset, :timezone, timezone(geom))
_ -> changeset
end
end
end
@spec timezone(Geo.PostGIS.Geometry.t() | nil) :: String.t() | nil
defp timezone(nil), do: nil
defp timezone(geom) do
case TzWorld.timezone_at(geom) do
{:ok, tz} -> tz
{:error, _err} -> nil
end
end
end end

View File

@ -27,6 +27,7 @@ defmodule Mobilizon.Events.EventOptions do
participation_condition: [EventParticipationCondition.t()], participation_condition: [EventParticipationCondition.t()],
show_start_time: boolean, show_start_time: boolean,
show_end_time: boolean, show_end_time: boolean,
timezone: String.t() | nil,
hide_organizer_when_group_event: boolean hide_organizer_when_group_event: boolean
} }
@ -41,6 +42,7 @@ defmodule Mobilizon.Events.EventOptions do
:show_participation_price, :show_participation_price,
:show_start_time, :show_start_time,
:show_end_time, :show_end_time,
:timezone,
:hide_organizer_when_group_event :hide_organizer_when_group_event
] ]
@ -57,6 +59,7 @@ defmodule Mobilizon.Events.EventOptions do
field(:show_participation_price, :boolean) field(:show_participation_price, :boolean)
field(:show_start_time, :boolean, default: true) field(:show_start_time, :boolean, default: true)
field(:show_end_time, :boolean, default: true) field(:show_end_time, :boolean, default: true)
field(:timezone, :string)
field(:hide_organizer_when_group_event, :boolean, default: false) field(:hide_organizer_when_group_event, :boolean, default: false)
embeds_many(:offers, EventOffer) embeds_many(:offers, EventOffer)

View File

@ -401,7 +401,8 @@ defmodule Mobilizon.Events do
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@spec list_organized_events_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t() @spec list_organized_events_for_actor(Actor.t(), integer | nil, integer | nil) ::
Page.t(Event.t())
def list_organized_events_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do def list_organized_events_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id actor_id
|> event_for_actor_query(desc: :begins_on) |> event_for_actor_query(desc: :begins_on)
@ -409,13 +410,15 @@ defmodule Mobilizon.Events do
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@spec list_simple_organized_events_for_group(Actor.t(), integer | nil, integer | nil) ::
Page.t(Event.t())
def list_simple_organized_events_for_group(%Actor{} = actor, page \\ nil, limit \\ nil) do def list_simple_organized_events_for_group(%Actor{} = actor, page \\ nil, limit \\ nil) do
list_organized_events_for_group(actor, :all, nil, nil, page, limit) list_organized_events_for_group(actor, :all, nil, nil, page, limit)
end end
@spec list_organized_events_for_group( @spec list_organized_events_for_group(
Actor.t(), Actor.t(),
EventVisibility.t(), EventVisibility.t() | :all,
DateTime.t() | nil, DateTime.t() | nil,
DateTime.t() | nil, DateTime.t() | nil,
integer | nil, integer | nil,
@ -885,7 +888,9 @@ defmodule Mobilizon.Events do
@doc """ @doc """
Creates a participant. Creates a participant.
""" """
@spec create_participant(map) :: {:ok, Participant.t()} | {:error, Changeset.t()} @spec create_participant(map) ::
{:ok, Participant.t()}
| {:error, :participant | :update_event_participation_stats, Changeset.t(), map()}
def create_participant(attrs \\ %{}, update_event_participation_stats \\ true) do def create_participant(attrs \\ %{}, update_event_participation_stats \\ true) do
with {:ok, %{participant: %Participant{} = participant}} <- with {:ok, %{participant: %Participant{} = participant}} <-
Multi.new() Multi.new()
@ -912,7 +917,8 @@ defmodule Mobilizon.Events do
Updates a participant. Updates a participant.
""" """
@spec update_participant(Participant.t(), map) :: @spec update_participant(Participant.t(), map) ::
{:ok, Participant.t()} | {:error, Changeset.t()} {:ok, Participant.t()}
| {:error, :participant | :update_event_participation_stats, Changeset.t(), map()}
def update_participant(%Participant{role: old_role} = participant, attrs) do def update_participant(%Participant{role: old_role} = participant, attrs) do
with {:ok, %{participant: %Participant{} = participant}} <- with {:ok, %{participant: %Participant{} = participant}} <-
Multi.new() Multi.new()
@ -1625,11 +1631,12 @@ defmodule Mobilizon.Events do
from(p in query, where: p.role == ^role) from(p in query, where: p.role == ^role)
end end
@spec event_filter_visibility(Ecto.Queryable.t(), :public | :all) ::
Ecto.Queryable.t() | Ecto.Query.t()
defp event_filter_visibility(query, :all), do: query defp event_filter_visibility(query, :all), do: query
defp event_filter_visibility(query, :public) do defp event_filter_visibility(query, :public) do
query where(query, visibility: ^:public, draft: false)
|> where(visibility: ^:public, draft: false)
end end
defp event_filter_begins_on(query, nil, nil), defp event_filter_begins_on(query, nil, nil),

View File

@ -66,12 +66,15 @@ defmodule Mobilizon.Service.Geospatial.Addok do
defp process_data(features) do defp process_data(features) do
features features
|> Enum.map(fn %{"geometry" => geometry, "properties" => properties} -> |> Enum.map(fn %{"geometry" => geometry, "properties" => properties} ->
coordinates = geometry |> Map.get("coordinates") |> Provider.coordinates()
%Address{ %Address{
country: Map.get(properties, "country", default_country()), country: Map.get(properties, "country", default_country()),
locality: Map.get(properties, "city"), locality: Map.get(properties, "city"),
region: Map.get(properties, "context"), region: Map.get(properties, "context"),
description: Map.get(properties, "name") || street_address(properties), description: Map.get(properties, "name") || street_address(properties),
geom: geometry |> Map.get("coordinates") |> Provider.coordinates(), geom: coordinates,
timezone: Provider.timezone(coordinates),
postal_code: Map.get(properties, "postcode"), postal_code: Map.get(properties, "postcode"),
street: properties |> street_address() street: properties |> street_address()
} }

View File

@ -124,12 +124,15 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do
description description
end end
coordinates = Provider.coordinates([lon, lat])
%Address{ %Address{
country: Map.get(components, "country"), country: Map.get(components, "country"),
locality: Map.get(components, "locality"), locality: Map.get(components, "locality"),
region: Map.get(components, "administrative_area_level_1"), region: Map.get(components, "administrative_area_level_1"),
description: description, description: description,
geom: [lon, lat] |> Provider.coordinates(), geom: coordinates,
timezone: Provider.timezone(coordinates),
postal_code: Map.get(components, "postal_code"), postal_code: Map.get(components, "postal_code"),
street: street_address(components), street: street_address(components),
origin_id: "gm:" <> to_string(place_id) origin_id: "gm:" <> to_string(place_id)

View File

@ -98,12 +98,15 @@ defmodule Mobilizon.Service.Geospatial.MapQuest do
end end
defp produce_address(address, lat, lng) do defp produce_address(address, lat, lng) do
coordinates = Provider.coordinates([lng, lat])
%Address{ %Address{
country: Map.get(address, "adminArea1"), country: Map.get(address, "adminArea1"),
locality: Map.get(address, "adminArea5"), locality: Map.get(address, "adminArea5"),
region: Map.get(address, "adminArea3"), region: Map.get(address, "adminArea3"),
description: Map.get(address, "street"), description: Map.get(address, "street"),
geom: [lng, lat] |> Provider.coordinates(), geom: coordinates,
timezone: Provider.timezone(coordinates),
postal_code: Map.get(address, "postalCode"), postal_code: Map.get(address, "postalCode"),
street: Map.get(address, "street") street: Map.get(address, "street")
} }

View File

@ -75,7 +75,8 @@ defmodule Mobilizon.Service.Geospatial.Mimirsbrunn do
"properties" => %{"geocoding" => geocoding} "properties" => %{"geocoding" => geocoding}
} -> } ->
address = process_address(geocoding) address = process_address(geocoding)
%Address{address | geom: Provider.coordinates(coordinates)} coordinates = Provider.coordinates(coordinates)
%Address{address | geom: coordinates, timezone: Provider.timezone(coordinates)}
end) end)
end end

View File

@ -75,7 +75,8 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
"properties" => %{"geocoding" => geocoding} "properties" => %{"geocoding" => geocoding}
} -> } ->
address = process_address(geocoding) address = process_address(geocoding)
%Address{address | geom: Provider.coordinates(coordinates)} coordinates = Provider.coordinates(coordinates)
%Address{address | geom: coordinates, timezone: Provider.timezone(coordinates)}
end) end)
end end

View File

@ -76,7 +76,8 @@ defmodule Mobilizon.Service.Geospatial.Pelias do
"properties" => properties "properties" => properties
} -> } ->
address = process_address(properties) address = process_address(properties)
%Address{address | geom: Provider.coordinates(coordinates)} coordinates = Provider.coordinates(coordinates)
%Address{address | geom: coordinates, timezone: Provider.timezone(coordinates)}
end) end)
end end

View File

@ -69,12 +69,15 @@ defmodule Mobilizon.Service.Geospatial.Photon do
defp process_data(features) do defp process_data(features) do
features features
|> Enum.map(fn %{"geometry" => geometry, "properties" => properties} -> |> Enum.map(fn %{"geometry" => geometry, "properties" => properties} ->
coordinates = geometry |> Map.get("coordinates") |> Provider.coordinates()
%Address{ %Address{
country: Map.get(properties, "country"), country: Map.get(properties, "country"),
locality: Map.get(properties, "city"), locality: Map.get(properties, "city"),
region: Map.get(properties, "state"), region: Map.get(properties, "state"),
description: Map.get(properties, "name") || street_address(properties), description: Map.get(properties, "name") || street_address(properties),
geom: geometry |> Map.get("coordinates") |> Provider.coordinates(), geom: coordinates,
timezone: Provider.timezone(coordinates),
postal_code: Map.get(properties, "postcode"), postal_code: Map.get(properties, "postcode"),
street: properties |> street_address() street: properties |> street_address()
} }

View File

@ -79,6 +79,19 @@ defmodule Mobilizon.Service.Geospatial.Provider do
def coordinates(_), do: nil def coordinates(_), do: nil
@doc """
Returns the timezone for a Geo.Point
"""
@spec timezone(nil | Geo.Point.t()) :: nil | String.t()
def timezone(nil), do: nil
def timezone(%Geo.Point{} = point) do
case TzWorld.timezone_at(point) do
{:ok, tz} -> tz
{:error, _err} -> nil
end
end
@spec endpoint(atom()) :: String.t() @spec endpoint(atom()) :: String.t()
def endpoint(provider) do def endpoint(provider) do
Application.get_env(:mobilizon, provider) |> get_in([:endpoint]) Application.get_env(:mobilizon, provider) |> get_in([:endpoint])

View File

@ -2,7 +2,7 @@ defimpl Mobilizon.Service.Metadata, for: Mobilizon.Events.Event do
alias Phoenix.HTML alias Phoenix.HTML
alias Phoenix.HTML.Tag alias Phoenix.HTML.Tag
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
alias Mobilizon.Events.Event alias Mobilizon.Events.{Event, EventOptions}
alias Mobilizon.Web.JsonLD.ObjectView alias Mobilizon.Web.JsonLD.ObjectView
import Mobilizon.Service.Metadata.Utils, import Mobilizon.Service.Metadata.Utils,
@ -53,20 +53,52 @@ defimpl Mobilizon.Service.Metadata, for: Mobilizon.Events.Event do
%Event{ %Event{
description: description, description: description,
begins_on: begins_on, begins_on: begins_on,
physical_address: %Address{} = address physical_address: address,
options: %EventOptions{timezone: timezone},
language: language
}, },
locale locale
) do ) do
"#{datetime_to_string(begins_on, locale)} - #{render_address(address)} - #{process_description(description, locale)}" language = build_language(language, locale)
begins_on = build_begins_on(begins_on, timezone, language)
begins_on
|> datetime_to_string(language)
|> (&[&1]).()
|> add_timezone(begins_on)
|> maybe_build_address(address)
|> build_description(description, language)
|> Enum.join(" - ")
end end
defp description( @spec build_language(String.t() | nil, String.t()) :: String.t()
%Event{ defp build_language(language, locale), do: language || locale
description: description,
begins_on: begins_on @spec build_begins_on(DateTime.t(), String.t() | nil, String.t()) :: DateTime.t()
}, defp build_begins_on(begins_on, timezone, language) do
locale if timezone do
) do case DateTime.shift_zone(begins_on, timezone) do
"#{datetime_to_string(begins_on, locale)} - #{process_description(description, locale)}" {:ok, begins_on} -> begins_on
{:error, _err} -> begins_on
end
else
begins_on
end
end
defp add_timezone(elements, %DateTime{} = begins_on) do
elements ++ [Cldr.DateTime.Formatter.zone_gmt(begins_on)]
end
@spec maybe_build_address(list(String.t()), Address.t() | nil) :: list(String.t())
defp maybe_build_address(elements, %Address{} = address) do
elements ++ [render_address(address)]
end
defp maybe_build_address(elements, _address), do: elements
@spec build_description(list(String.t()), String.t(), String.t()) :: list(String.t())
defp build_description(elements, description, language) do
elements ++ [process_description(description, language)]
end end
end end

View File

@ -0,0 +1,40 @@
defmodule Mobilizon.Service.TimezoneDetector do
@moduledoc """
Detect the timezone from a point
"""
@type detectable :: Geo.Point.t() | Geo.PointZ.t() | {float() | float()}
@doc """
Detect the most appropriate timezone from a value, a geographic set of coordinates and a fallback
"""
@spec detect(String.t() | nil, detectable(), String.t()) :: String.t()
def detect(nil, geo, fallback) do
case TzWorld.timezone_at(geo) do
{:ok, timezone} ->
timezone
{:error, :time_zone_not_found} ->
fallback
end
end
def detect(timezone, geo, fallback) do
if Tzdata.zone_exists?(timezone) do
timezone
else
detect(nil, geo, fallback)
end
end
@spec detect(String.t() | nil, String.t()) :: String.t()
def detect(nil, fallback), do: fallback
def detect(timezone, fallback) do
if Tzdata.zone_exists?(timezone) do
timezone
else
fallback
end
end
end

View File

@ -27,23 +27,30 @@ defmodule Mobilizon.Web.Auth.Context do
user_agent = conn |> Plug.Conn.get_req_header("user-agent") |> List.first() user_agent = conn |> Plug.Conn.get_req_header("user-agent") |> List.first()
if SentryAdapter.enabled?() do
Sentry.Context.set_request_context(%{
url: Plug.Conn.request_url(conn),
method: conn.method,
headers: %{
"User-Agent": user_agent,
Referer: conn |> Plug.Conn.get_req_header("referer") |> List.first()
},
query_string: conn.query_string,
env: %{
REQUEST_ID: conn |> Plug.Conn.get_resp_header("x-request-id") |> List.first(),
SERVER_NAME: conn.host
}
})
end
{conn, context} = {conn, context} =
case Guardian.Plug.current_resource(conn) do case Guardian.Plug.current_resource(conn) do
%User{id: user_id, email: user_email} = user -> %User{id: user_id, email: user_email} = user ->
if SentryAdapter.enabled?() do if SentryAdapter.enabled?() do
Sentry.Context.set_user_context(%{id: user_id, name: user_email}) Sentry.Context.set_user_context(%{
id: user_id,
Sentry.Context.set_request_context(%{ email: user_email,
url: Plug.Conn.request_url(conn), ip_address: context.ip
method: conn.method,
headers: %{
"User-Agent": user_agent
},
query_string: conn.query_string,
env: %{
REQUEST_ID: conn |> Plug.Conn.get_resp_header("x-request-id") |> List.first(),
SERVER_NAME: conn.host
}
}) })
end end

View File

@ -206,6 +206,7 @@ defmodule Mobilizon.Mixfile do
{:paasaa, "~> 0.5.0"}, {:paasaa, "~> 0.5.0"},
{:nimble_csv, "~> 1.1"}, {:nimble_csv, "~> 1.1"},
{:export, "~> 0.1.0"}, {:export, "~> 0.1.0"},
{:tz_world, "~> 0.5.0"},
# Dev and test dependencies # Dev and test dependencies
{:phoenix_live_reload, "~> 1.2", only: [:dev, :e2e]}, {:phoenix_live_reload, "~> 1.2", only: [:dev, :e2e]},
{:ex_machina, "~> 2.3", only: [:dev, :test]}, {:ex_machina, "~> 2.3", only: [:dev, :test]},

View File

@ -131,6 +131,7 @@
"telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"},
"tesla": {:hex, :tesla, "1.4.3", "f5a494e08fb1abe4fd9c28abb17f3d9b62b8f6fc492860baa91efb1aab61c8a0", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "e0755bb664bf4d664af72931f320c97adbf89da4586670f4864bf259b5750386"}, "tesla": {:hex, :tesla, "1.4.3", "f5a494e08fb1abe4fd9c28abb17f3d9b62b8f6fc492860baa91efb1aab61c8a0", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "e0755bb664bf4d664af72931f320c97adbf89da4586670f4864bf259b5750386"},
"timex": {:hex, :timex, "3.7.6", "502d2347ec550e77fdf419bc12d15bdccd31266bb7d925b30bf478268098282f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "a296327f79cb1ec795b896698c56e662ed7210cc9eb31f0ab365eb3a62e2c589"}, "timex": {:hex, :timex, "3.7.6", "502d2347ec550e77fdf419bc12d15bdccd31266bb7d925b30bf478268098282f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "a296327f79cb1ec795b896698c56e662ed7210cc9eb31f0ab365eb3a62e2c589"},
"tz_world": {:hex, :tz_world, "0.5.0", "fb93adb6ec9a32bbf1664d84083bb426b5273a31d4051a93654feaf9feb96b33", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:geo, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "df236849eff87a36c436b557803fb72b57f10dbc3f5d44cd1c06324e0c8447bb"},
"tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"}, "tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"},
"ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"}, "ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"},
"ueberauth_discord": {:hex, :ueberauth_discord, "0.6.0", "d6ec040e4195c4138b9a959c79024ab4c213ba1aed9fc08099ecff141a6486da", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6.3", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "c5ea960191c1d6c3a974947cae4d57efa565a9a0796b8e82bee45fac7ae2fabc"}, "ueberauth_discord": {:hex, :ueberauth_discord, "0.6.0", "d6ec040e4195c4138b9a959c79024ab4c213ba1aed9fc08099ecff141a6486da", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6.3", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "c5ea960191c1d6c3a974947cae4d57efa565a9a0796b8e82bee45fac7ae2fabc"},

View File

@ -0,0 +1,9 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddTimezoneToAddresses do
use Ecto.Migration
def change do
alter table(:addresses) do
add(:timezone, :string)
end
end
end