Migrate to Vue 3 and Vite

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2022-07-12 10:55:28 +02:00
parent 8f4099ee33
commit ee20e03cc2
464 changed files with 31515 additions and 32758 deletions

View File

@@ -4,44 +4,48 @@
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
text: t('Account'),
},
{
name: RouteName.ACCOUNT_SETTINGS_GENERAL,
text: $t('General'),
text: t('General'),
},
]"
/>
<section>
<div class="setting-title">
<h2>{{ $t("Email") }}</h2>
</div>
<i18n
<h2>{{ t("Email") }}</h2>
<i18n-t
tag="p"
class="content"
class="prose dark:prose-invert"
v-if="loggedUser"
path="Your current email is {email}. You use it to log in."
keypath="Your current email is {email}. You use it to log in."
>
<template #email>
<b>{{ loggedUser.email }}</b>
</template>
</i18n-t>
<o-notification
v-if="!canChangeEmail && loggedUser.provider"
variant="warning"
:closable="false"
>
<b slot="email">{{ loggedUser.email }}</b>
</i18n>
<b-message v-if="!canChangeEmail" type="is-warning" :closable="false">
{{
$t(
t(
"Your email address was automatically set based on your {provider} account.",
{
provider: providerName(loggedUser.provider),
}
)
}}
</b-message>
<b-notification
type="is-danger"
</o-notification>
<o-notification
variant="danger"
has-icon
aria-close-label="Close notification"
role="alert"
:key="error"
v-for="error in changeEmailErrors"
>{{ error }}</b-notification
>{{ error }}</o-notification
>
<form
@submit.prevent="resetEmailAction"
@@ -49,18 +53,18 @@
class="form"
v-if="canChangeEmail"
>
<b-field :label="$t('New email')" label-for="account-email">
<b-input
<o-field :label="t('New email')" label-for="account-email">
<o-input
aria-required="true"
required
type="email"
id="account-email"
v-model="newEmail"
/>
</b-field>
<p class="help">{{ $t("You'll receive a confirmation email.") }}</p>
<b-field :label="$t('Password')" label-for="account-password">
<b-input
</o-field>
<p class="help">{{ t("You'll receive a confirmation email.") }}</p>
<o-field :label="t('Password')" label-for="account-password">
<o-input
aria-required="true"
required
type="password"
@@ -69,35 +73,38 @@
minlength="6"
v-model="passwordForEmailChange"
/>
</b-field>
<button
class="button is-primary"
:disabled="!($refs.emailForm && $refs.emailForm.checkValidity())"
</o-field>
<o-button
class="mt-2"
variant="primary"
:disabled="!(emailForm && emailForm.checkValidity())"
>
{{ $t("Change my email") }}
</button>
{{ t("Change my email") }}
</o-button>
</form>
<div class="setting-title">
<h2>{{ $t("Password") }}</h2>
</div>
<b-message v-if="!canChangePassword" type="is-warning" :closable="false">
<h2 class="mt-2">{{ t("Password") }}</h2>
<o-notification
v-if="!canChangePassword"
variant="warning"
:closable="false"
>
{{
$t(
t(
"You can't change your password because you are registered through {provider}.",
{
provider: providerName(loggedUser.provider),
}
)
}}
</b-message>
<b-notification
type="is-danger"
</o-notification>
<o-notification
variant="danger"
has-icon
aria-close-label="Close notification"
role="alert"
:key="error"
v-for="error in changePasswordErrors"
>{{ error }}</b-notification
>{{ error }}</o-notification
>
<form
@submit.prevent="resetPasswordAction"
@@ -105,8 +112,8 @@
class="form"
v-if="canChangePassword"
>
<b-field :label="$t('Old password')" label-for="account-old-password">
<b-input
<o-field :label="t('Old password')" label-for="account-old-password">
<o-input
aria-required="true"
required
type="password"
@@ -115,9 +122,9 @@
id="account-old-password"
v-model="oldPassword"
/>
</b-field>
<b-field :label="$t('New password')" label-for="account-new-password">
<b-input
</o-field>
<o-field :label="t('New password')" label-for="account-new-password">
<o-input
aria-required="true"
required
type="password"
@@ -126,302 +133,287 @@
id="account-new-password"
v-model="newPassword"
/>
</b-field>
<button
class="button is-primary"
:disabled="
!($refs.passwordForm && $refs.passwordForm.checkValidity())
"
</o-field>
<o-button
class="mt-2"
variant="primary"
:disabled="!(passwordForm && passwordForm.checkValidity())"
>
{{ $t("Change my password") }}
</button>
{{ t("Change my password") }}
</o-button>
</form>
<div class="setting-title">
<h2>{{ $t("Delete account") }}</h2>
</div>
<p class="content">
{{ $t("Deleting my account will delete all of my identities.") }}
<h2 class="mt-2">{{ t("Delete account") }}</h2>
<p class="prose dark:prose-invert">
{{ t("Deleting my account will delete all of my identities.") }}
</p>
<b-button @click="openDeleteAccountModal" type="is-danger">
{{ $t("Delete my account") }}
</b-button>
<o-button @click="openDeleteAccountModal" variant="danger" class="mb-4">
{{ t("Delete my account") }}
</o-button>
<b-modal
:close-button-aria-label="$t('Close')"
:active.sync="isDeleteAccountModalActive"
<o-modal
:close-button-aria-label="t('Close')"
v-model:active="isDeleteAccountModalActive"
has-modal-card
full-screen
:can-cancel="false"
>
<section class="hero is-primary is-fullheight">
<div class="hero-body has-text-centered">
<div class="container">
<div class="columns">
<div
class="column is-one-third-desktop is-offset-one-third-desktop"
>
<section class="">
<div class="">
<div class="container mx-auto max-w-md">
<div class="">
<div class="">
<h1 class="title">
{{ $t("Deleting your Mobilizon account") }}
{{ t("Deleting your Mobilizon account") }}
</h1>
<p class="content">
<p class="prose dark:prose-invert">
{{
$t(
t(
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever."
)
}}
<br />
<b>{{
$t("There will be no way to recover your data.")
}}</b>
<b>{{ t("There will be no way to recover your data.") }}</b>
</p>
<p class="content" v-if="hasUserGotAPassword">
<p class="prose dark:prose-invert" v-if="hasUserGotAPassword">
{{
$t("Please enter your password to confirm this action.")
t("Please enter your password to confirm this action.")
}}
</p>
<form @submit.prevent="deleteAccount">
<b-field
<o-field
:type="deleteAccountPasswordFieldType"
v-if="hasUserGotAPassword"
label-for="account-deletion-password"
>
<b-input
<o-input
type="password"
v-model="passwordForAccountDeletion"
password-reveal
id="account-deletion-password"
:aria-label="$t('Password')"
:aria-label="t('Password')"
icon="lock"
:placeholder="$t('Password')"
:placeholder="t('Password')"
/>
<template #message>
<b-message
type="is-danger"
<o-notification
variant="danger"
v-for="message in deletePasswordErrors"
:key="message"
>
{{ message }}
</b-message>
</o-notification>
</template>
</b-field>
<b-button
</o-field>
<o-button
class="mt-2"
native-type="submit"
type="is-danger"
size="is-large"
variant="danger"
size="large"
>
{{ $t("Delete everything") }}
</b-button>
{{ t("Delete everything") }}
</o-button>
</form>
<div class="cancel-button">
<b-button
type="is-light"
<div class="mt-4">
<o-button
variant="light"
@click="isDeleteAccountModalActive = false"
>
{{ $t("Cancel") }}
</b-button>
{{ t("Cancel") }}
</o-button>
</div>
</div>
</div>
</div>
</div>
</section>
</b-modal>
</o-modal>
</section>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { useLoggedUser } from "@/composition/apollo/user";
import { Notifier } from "@/plugins/notifier";
import { IAuthProvider } from "@/types/enums";
import { useMutation } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { GraphQLError } from "graphql/error/GraphQLError";
import { Component, Vue, Ref } from "vue-property-decorator";
import { Route } from "vue-router";
import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import {
CHANGE_EMAIL,
CHANGE_PASSWORD,
DELETE_ACCOUNT,
LOGGED_USER,
} from "../../graphql/user";
import RouteName from "../../router/name";
import { IUser } from "../../types/current-user.model";
import { logout, SELECTED_PROVIDERS } from "../../utils/auth";
import { useProgrammatic } from "@oruga-ui/oruga-next";
@Component({
apollo: {
loggedUser: LOGGED_USER,
},
metaInfo() {
return {
title: this.$t("General settings") as string,
};
},
})
export default class AccountSettings extends Vue {
@Ref("passwordForm") readonly passwordForm!: HTMLElement;
const { loggedUser } = useLoggedUser();
loggedUser!: IUser;
const { t } = useI18n({ useScope: "global" });
passwordForEmailChange = "";
useHead({
title: computed(() => t("General settings")),
});
newEmail = "";
const passwordForm = ref<HTMLElement>();
const emailForm = ref<HTMLElement>();
changeEmailErrors: string[] = [];
const passwordForEmailChange = ref("");
const newEmail = ref("");
const changeEmailErrors = ref<string[]>([]);
const oldPassword = ref("");
const newPassword = ref("");
const changePasswordErrors = ref<string[]>([]);
const deletePasswordErrors = ref<string[]>([]);
const isDeleteAccountModalActive = ref(false);
const passwordForAccountDeletion = ref("");
oldPassword = "";
const notifier = inject<Notifier>("notifier");
newPassword = "";
const {
mutate: changeEmailMutation,
onDone: changeEmailMutationDone,
onError: changeEmailMutationError,
} = useMutation(CHANGE_EMAIL);
changePasswordErrors: string[] = [];
changeEmailMutationDone(() => {
notifier?.info(
t(
"The account's email address was changed. Check your emails to verify it."
)
);
newEmail.value = "";
passwordForEmailChange.value = "";
});
deletePasswordErrors: string[] = [];
changeEmailMutationError((err) => {
handleErrors("email", err);
});
isDeleteAccountModalActive = false;
const resetEmailAction = async (): Promise<void> => {
changeEmailErrors.value = [];
passwordForAccountDeletion = "";
changeEmailMutation({
email: newEmail.value,
password: passwordForEmailChange.value,
});
};
RouteName = RouteName;
const {
mutate: changePasswordMutation,
onDone: onChangePasswordMutationDone,
onError: onChangePasswordMutationError,
} = useMutation(CHANGE_PASSWORD);
async resetEmailAction(): Promise<void> {
this.changeEmailErrors = [];
onChangePasswordMutationDone(() => {
oldPassword.value = "";
newPassword.value = "";
notifier?.success(t("The password was successfully changed"));
});
try {
await this.$apollo.mutate({
mutation: CHANGE_EMAIL,
variables: {
email: this.newEmail,
password: this.passwordForEmailChange,
},
});
onChangePasswordMutationError((err) => {
handleErrors("password", err);
});
this.$notifier.info(
this.$t(
"The account's email address was changed. Check your emails to verify it."
) as string
);
this.newEmail = "";
this.passwordForEmailChange = "";
} catch (err: any) {
this.handleErrors("email", err);
}
const resetPasswordAction = async (): Promise<void> => {
changePasswordErrors.value = [];
changePasswordMutation({
oldPassword: oldPassword.value,
newPassword: newPassword.value,
});
};
const openDeleteAccountModal = (): void => {
passwordForAccountDeletion.value = "";
isDeleteAccountModalActive.value = true;
};
const router = useRouter();
const {
mutate: deleteAccountMutation,
onDone: deleteAccountMutationDone,
onError: deleteAccountMutationError,
} = useMutation<{ deleteAccount: { id: string } }, { password?: string }>(
DELETE_ACCOUNT
);
const { oruga } = useProgrammatic();
deleteAccountMutationDone(async () => {
console.debug("Deleted account, logging out client...");
await logout(false);
oruga.notification.open({
message: t("Your account has been successfully deleted"),
variant: "success",
position: "bottom-right",
duration: 5000,
});
return router.push({ name: RouteName.HOME });
});
deleteAccountMutationError((err) => {
deletePasswordErrors.value = err.graphQLErrors.map(
({ message }: GraphQLError) => message
);
});
const deleteAccount = () => {
deletePasswordErrors.value = [];
console.debug("Asking to delete account...");
deleteAccountMutation({
password: hasUserGotAPassword.value
? passwordForAccountDeletion.value
: undefined,
});
};
const canChangePassword = computed((): boolean => {
return !loggedUser.value?.provider;
});
const canChangeEmail = computed((): boolean => {
return !loggedUser.value?.provider;
});
const providerName = (id: string): string => {
if (SELECTED_PROVIDERS[id]) {
return SELECTED_PROVIDERS[id];
}
return id;
};
async resetPasswordAction(): Promise<void> {
this.changePasswordErrors = [];
const hasUserGotAPassword = computed((): boolean => {
return (
loggedUser.value?.provider == null ||
loggedUser.value?.provider === IAuthProvider.LDAP
);
});
try {
await this.$apollo.mutate({
mutation: CHANGE_PASSWORD,
variables: {
oldPassword: this.oldPassword,
newPassword: this.newPassword,
},
});
const deleteAccountPasswordFieldType = computed((): string | null => {
return deletePasswordErrors.value.length > 0 ? "is-danger" : null;
});
this.oldPassword = "";
this.newPassword = "";
this.$notifier.success(
this.$t("The password was successfully changed") as string
);
} catch (err: any) {
this.handleErrors("password", err);
}
const handleErrors = (type: string, err: any) => {
console.error(err);
if (err.graphQLErrors !== undefined) {
err.graphQLErrors.forEach(({ message }: { message: string }) => {
switch (type) {
case "password":
changePasswordErrors.value.push(message);
break;
case "email":
default:
changeEmailErrors.value.push(message);
break;
}
});
}
protected openDeleteAccountModal(): void {
this.passwordForAccountDeletion = "";
this.isDeleteAccountModalActive = true;
}
async deleteAccount(): Promise<Route | void> {
try {
this.deletePasswordErrors = [];
console.debug("Asking to delete account...");
await this.$apollo.mutate({
mutation: DELETE_ACCOUNT,
variables: {
password: this.hasUserGotAPassword
? this.passwordForAccountDeletion
: null,
},
});
console.debug("Deleted account, logging out client...");
await logout(this.$apollo.provider.defaultClient, false);
this.$buefy.notification.open({
message: this.$t(
"Your account has been successfully deleted"
) as string,
type: "is-success",
position: "is-bottom-right",
duration: 5000,
});
return await this.$router.push({ name: RouteName.HOME });
} catch (err: any) {
this.deletePasswordErrors = err.graphQLErrors.map(
({ message }: GraphQLError) => message
);
}
}
get canChangePassword(): boolean {
return !this.loggedUser.provider;
}
get canChangeEmail(): boolean {
return !this.loggedUser.provider;
}
// eslint-disable-next-line class-methods-use-this
providerName(id: string): string {
if (SELECTED_PROVIDERS[id]) {
return SELECTED_PROVIDERS[id];
}
return id;
}
get hasUserGotAPassword(): boolean {
return (
this.loggedUser &&
(this.loggedUser.provider == null ||
this.loggedUser.provider === IAuthProvider.LDAP)
);
}
get deleteAccountPasswordFieldType(): string | null {
return this.deletePasswordErrors.length > 0 ? "is-danger" : null;
}
private handleErrors(type: string, err: any) {
console.error(err);
if (err.graphQLErrors !== undefined) {
err.graphQLErrors.forEach(({ message }: { message: string }) => {
switch (type) {
case "password":
this.changePasswordErrors.push(message);
break;
case "email":
default:
this.changeEmailErrors.push(message);
break;
}
});
}
}
}
};
</script>
<style lang="scss" scoped>
.modal.is-active.is-full-screen {
.help.is-danger {
font-size: 1rem;
}
}
.cancel-button {
margin-top: 2rem;
}
::v-deep .modal .modal-background {
background-color: initial;
}
</style>

View File

@@ -1,800 +0,0 @@
<template>
<div v-if="loggedUser">
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
},
{
name: RouteName.NOTIFICATIONS,
text: $t('Notifications'),
},
]"
/>
<section>
<div class="setting-title">
<h2>{{ $t("Browser notifications") }}</h2>
</div>
<b-button
v-if="subscribed"
@click="unsubscribeToWebPush()"
@keyup.enter="unsubscribeToWebPush()"
>{{ $t("Unsubscribe to browser push notifications") }}</b-button
>
<b-button
icon-left="rss"
@click="subscribeToWebPush"
@keyup.enter="subscribeToWebPush"
v-else-if="canShowWebPush && webPushEnabled"
>{{ $t("Activate browser push notifications") }}</b-button
>
<b-message type="is-warning" v-else-if="!webPushEnabled">
{{ $t("This instance hasn't got push notifications enabled.") }}
<i18n path="Ask your instance admin to {enable_feature}.">
<a
slot="enable_feature"
href="https://docs.joinmobilizon.org/administration/configure/push/"
target="_blank"
rel="noopener noreferer"
>{{ $t("enable the feature") }}</a
>
</i18n>
</b-message>
<b-message type="is-danger" v-else>{{
$t("You can't use push notifications in this browser.")
}}</b-message>
</section>
<section>
<div class="setting-title">
<h2>{{ $t("Notification settings") }}</h2>
</div>
<p>
{{
$t(
"Select the activities for which you wish to receive an email or a push notification."
)
}}
</p>
<table class="table">
<tbody>
<template v-for="notificationType in notificationTypes">
<tr :key="`${notificationType.label}-title`">
<th colspan="3">
{{ notificationType.label }}
</th>
</tr>
<tr :key="`${notificationType.label}-subtitle`">
<th v-for="(method, key) in notificationMethods" :key="key">
{{ method }}
</th>
<th>{{ $t("Description") }}</th>
</tr>
<tr v-for="subType in notificationType.subtypes" :key="subType.id">
<td v-for="(method, key) in notificationMethods" :key="key">
<b-checkbox
:value="notificationValues[subType.id][key].enabled"
@input="(e) => updateNotificationValue(subType.id, key, e)"
:disabled="notificationValues[subType.id][key].disabled"
/>
</td>
<td>
{{ subType.label }}
</td>
</tr>
</template>
</tbody>
</table>
<b-field
:label="$t('Send notification e-mails')"
label-for="groupNotifications"
:message="
$t(
'Announcements and mentions notifications are always sent straight away.'
)
"
>
<b-select
v-model="groupNotifications"
@input="updateSetting({ groupNotifications })"
id="groupNotifications"
>
<option
v-for="(value, key) in groupNotificationsValues"
:value="key"
:key="key"
>
{{ value }}
</option>
</b-select>
</b-field>
</section>
<section>
<div class="setting-title">
<h2>{{ $t("Participation notifications") }}</h2>
</div>
<div class="field">
<strong>{{
$t(
"Mobilizon will send you an email when the events you are attending have important changes: date and time, address, confirmation or cancellation, etc."
)
}}</strong>
</div>
<p>
{{ $t("Other notification options:") }}
</p>
<div class="field">
<b-checkbox
v-model="notificationOnDay"
@input="updateSetting({ notificationOnDay })"
>
<strong>{{ $t("Notification on the day of the event") }}</strong>
<p>
{{
$t(
"We'll use your timezone settings to send a recap of the morning of the event."
)
}}
</p>
<div v-if="loggedUser.settings && loggedUser.settings.timezone">
<em>{{
$t("Your timezone is currently set to {timezone}.", {
timezone: loggedUser.settings.timezone,
})
}}</em>
<router-link
class="change-timezone"
:to="{ name: RouteName.PREFERENCES }"
>{{ $t("Change timezone") }}</router-link
>
</div>
<span v-else>{{
$t("You can pick your timezone into your preferences.")
}}</span>
</b-checkbox>
</div>
<div class="field">
<b-checkbox
v-model="notificationEachWeek"
@input="updateSetting({ notificationEachWeek })"
>
<strong>{{ $t("Recap every week") }}</strong>
<p>
{{
$t(
"You'll get a weekly recap every Monday for upcoming events, if you have any."
)
}}
</p>
</b-checkbox>
</div>
<div class="field">
<b-checkbox
v-model="notificationBeforeEvent"
@input="updateSetting({ notificationBeforeEvent })"
>
<strong>{{ $t("Notification before the event") }}</strong>
<p>
{{
$t(
"We'll send you an email one hour before the event begins, to be sure you won't forget about it."
)
}}
</p>
</b-checkbox>
</div>
</section>
<section>
<div class="setting-title">
<h2>{{ $t("Organizer notifications") }}</h2>
</div>
<div class="field is-primary">
<label
class="has-text-weight-bold"
for="notificationPendingParticipation"
>{{
$t("Notifications for manually approved participations to an event")
}}</label
>
<p>
{{
$t(
"If you have opted for manual validation of participants, Mobilizon will send you an email to inform you of new participations to be processed. You can choose the frequency of these notifications below."
)
}}
</p>
<b-select
v-model="notificationPendingParticipation"
id="notificationPendingParticipation"
@input="updateSetting({ notificationPendingParticipation })"
>
<option
v-for="(value, key) in notificationPendingParticipationValues"
:value="key"
:key="key"
>
{{ value }}
</option>
</b-select>
</div>
</section>
<section>
<div class="setting-title">
<h2>{{ $t("Personal feeds") }}</h2>
</div>
<p>
{{
$t(
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
)
}}
</p>
<div v-if="feedTokens && feedTokens.length > 0">
<div
class="buttons"
v-for="feedToken in feedTokens"
:key="feedToken.token"
>
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.atom"
always
type="is-success"
position="is-left"
>
<b-button
tag="a"
icon-left="rss"
@click="
(e) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
@keyup.enter="
(e) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
:href="tokenToURL(feedToken.token, 'atom')"
target="_blank"
>{{ $t("RSS/Atom Feed") }}</b-button
>
</b-tooltip>
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.ics"
always
type="is-success"
position="is-left"
>
<b-button
tag="a"
@click="
(e) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
@keyup.enter="
(e) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
icon-left="calendar-sync"
:href="tokenToURL(feedToken.token, 'ics')"
target="_blank"
>{{ $t("ICS/WebCal Feed") }}</b-button
>
</b-tooltip>
<b-button
icon-left="refresh"
type="is-text"
@click="openRegenerateFeedTokensConfirmation"
@keyup.enter="openRegenerateFeedTokensConfirmation"
>{{ $t("Regenerate new links") }}</b-button
>
</div>
</div>
<div v-else>
<b-button
icon-left="refresh"
type="is-text"
@click="generateFeedTokens"
@keyup.enter="generateFeedTokens"
>{{ $t("Create new links") }}</b-button
>
</div>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { INotificationPendingEnum } from "@/types/enums";
import {
SET_USER_SETTINGS,
FEED_TOKENS_LOGGED_USER,
USER_NOTIFICATIONS,
UPDATE_ACTIVITY_SETTING,
} from "../../graphql/user";
import { IActivitySettingMethod, IUser } from "../../types/current-user.model";
import RouteName from "../../router/name";
import { IFeedToken } from "@/types/feedtoken.model";
import { CREATE_FEED_TOKEN, DELETE_FEED_TOKEN } from "@/graphql/feed_tokens";
import {
subscribeUserToPush,
unsubscribeUserToPush,
} from "../../services/push-subscription";
import {
REGISTER_PUSH_MUTATION,
UNREGISTER_PUSH_MUTATION,
} from "@/graphql/webPush";
import merge from "lodash/merge";
import { WEB_PUSH } from "@/graphql/config";
type NotificationSubType = { label: string; id: string };
type NotificationType = { label: string; subtypes: NotificationSubType[] };
@Component({
apollo: {
loggedUser: USER_NOTIFICATIONS,
feedTokens: {
query: FEED_TOKENS_LOGGED_USER,
update: (data) =>
data.loggedUser.feedTokens.filter(
(token: IFeedToken) => token.actor === null
),
},
webPushEnabled: {
query: WEB_PUSH,
update: (data) => data.config.webPush.enabled,
},
},
metaInfo() {
return {
title: this.$t("Notification settings") as string,
};
},
})
export default class Notifications extends Vue {
loggedUser!: IUser;
feedTokens: IFeedToken[] = [];
notificationOnDay: boolean | undefined = true;
notificationEachWeek: boolean | undefined = false;
notificationBeforeEvent: boolean | undefined = false;
notificationPendingParticipation: INotificationPendingEnum | undefined =
INotificationPendingEnum.NONE;
groupNotifications: INotificationPendingEnum | undefined =
INotificationPendingEnum.ONE_DAY;
notificationPendingParticipationValues: Record<string, unknown> = {};
groupNotificationsValues: Record<string, unknown> = {};
RouteName = RouteName;
showCopiedTooltip = { ics: false, atom: false };
subscribed = false;
canShowWebPush = false;
webPushEnabled = false;
notificationMethods = {
email: this.$t("Email") as string,
push: this.$t("Push") as string,
};
defaultNotificationValues = {
participation_event_updated: {
email: { enabled: true, disabled: true },
push: { enabled: true, disabled: true },
},
participation_event_comment: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
event_new_pending_participation: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
event_new_participation: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
event_created: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
event_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
discussion_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
post_published: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
post_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
resource_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
member_request: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
member_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
user_email_password_updated: {
email: { enabled: true, disabled: true },
push: { enabled: false, disabled: true },
},
event_comment_mention: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
discussion_mention: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
event_new_comment: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
};
notificationTypes: NotificationType[] = [
{
label: this.$t("Mentions") as string,
subtypes: [
{
id: "event_comment_mention",
label: this.$t(
"I've been mentionned in a comment under an event"
) as string,
},
{
id: "discussion_mention",
label: this.$t(
"I've been mentionned in a group discussion"
) as string,
},
],
},
{
label: this.$t("Participations") as string,
subtypes: [
{
id: "participation_event_updated",
label: this.$t("An event I'm going to has been updated") as string,
},
{
id: "participation_event_comment",
label: this.$t(
"An event I'm going to has posted an announcement"
) as string,
},
],
},
{
label: this.$t("Organizers") as string,
subtypes: [
{
id: "event_new_pending_participation",
label: this.$t(
"An event I'm organizing has a new pending participation"
) as string,
},
{
id: "event_new_participation",
label: this.$t(
"An event I'm organizing has a new participation"
) as string,
},
{
id: "event_new_comment",
label: this.$t("An event I'm organizing has a new comment") as string,
},
],
},
{
label: this.$t("Group activity") as string,
subtypes: [
{
id: "event_created",
label: this.$t(
"An event from one of my groups has been published"
) as string,
},
{
id: "event_updated",
label: this.$t(
"An event from one of my groups has been updated or deleted"
) as string,
},
{
id: "discussion_updated",
label: this.$t("A discussion has been created or updated") as string,
},
{
id: "post_published",
label: this.$t("A post has been published") as string,
},
{
id: "post_updated",
label: this.$t("A post has been updated") as string,
},
{
id: "resource_updated",
label: this.$t("A resource has been created or updated") as string,
},
{
id: "member_request",
label: this.$t(
"A member requested to join one of my groups"
) as string,
},
{
id: "member_updated",
label: this.$t("A member has been updated") as string,
},
],
},
{
label: this.$t("User settings") as string,
subtypes: [
{
id: "user_email_password_updated",
label: this.$t("You changed your email or password") as string,
},
],
},
];
get userNotificationValues(): Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
> {
return this.loggedUser.activitySettings.reduce((acc, activitySetting) => {
acc[activitySetting.key] = acc[activitySetting.key] || {};
acc[activitySetting.key][activitySetting.method] =
acc[activitySetting.key][activitySetting.method] || {};
acc[activitySetting.key][activitySetting.method].enabled =
activitySetting.enabled;
return acc;
}, {} as Record<string, Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>>);
}
get notificationValues(): Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
> {
const values = merge(
this.defaultNotificationValues,
this.userNotificationValues
);
for (const value in values) {
if (!this.canShowWebPush) {
values[value].push.disabled = true;
}
}
return values;
}
async mounted(): Promise<void> {
this.notificationPendingParticipationValues = {
[INotificationPendingEnum.NONE]: this.$t("Do not receive any mail"),
[INotificationPendingEnum.DIRECT]: this.$t(
"Receive one email per request"
),
[INotificationPendingEnum.ONE_HOUR]: this.$t("Hourly email summary"),
[INotificationPendingEnum.ONE_DAY]: this.$t("Daily email summary"),
[INotificationPendingEnum.ONE_WEEK]: this.$t("Weekly email summary"),
};
this.groupNotificationsValues = {
[INotificationPendingEnum.NONE]: this.$t("Do not receive any mail"),
[INotificationPendingEnum.DIRECT]: this.$t(
"Receive one email for each activity"
),
[INotificationPendingEnum.ONE_HOUR]: this.$t("Hourly email summary"),
[INotificationPendingEnum.ONE_DAY]: this.$t("Daily email summary"),
[INotificationPendingEnum.ONE_WEEK]: this.$t("Weekly email summary"),
};
this.canShowWebPush = await this.checkCanShowWebPush();
}
@Watch("loggedUser")
setSettings(): void {
if (this.loggedUser && this.loggedUser.settings) {
this.notificationOnDay = this.loggedUser.settings.notificationOnDay;
this.notificationEachWeek = this.loggedUser.settings.notificationEachWeek;
this.notificationBeforeEvent =
this.loggedUser.settings.notificationBeforeEvent;
this.notificationPendingParticipation =
this.loggedUser.settings.notificationPendingParticipation;
this.groupNotifications = this.loggedUser.settings.groupNotifications;
}
}
async updateSetting(variables: Record<string, unknown>): Promise<void> {
await this.$apollo.mutate<{ setUserSettings: string }>({
mutation: SET_USER_SETTINGS,
variables,
refetchQueries: [{ query: USER_NOTIFICATIONS }],
});
}
tokenToURL(token: string, format: string): string {
return `${window.location.origin}/events/going/${token}/${format}`;
}
copyURL(e: Event, url: string, format: "ics" | "atom"): void {
if (navigator.clipboard) {
e.preventDefault();
navigator.clipboard.writeText(url);
this.showCopiedTooltip[format] = true;
setTimeout(() => {
this.showCopiedTooltip[format] = false;
}, 2000);
}
}
openRegenerateFeedTokensConfirmation(): void {
this.$buefy.dialog.confirm({
type: "is-warning",
title: this.$t("Regenerate new links") as string,
message: this.$t(
"You'll need to change the URLs where there were previously entered."
) as string,
confirmText: this.$t("Regenerate new links") as string,
cancelText: this.$t("Cancel") as string,
onConfirm: () => this.regenerateFeedTokens(),
});
}
async regenerateFeedTokens(): Promise<void> {
if (this.feedTokens.length < 1) return;
await this.deleteFeedToken(this.feedTokens[0].token);
const newToken = await this.createNewFeedToken();
this.feedTokens.pop();
this.feedTokens.push(newToken);
}
async generateFeedTokens(): Promise<void> {
const newToken = await this.createNewFeedToken();
this.feedTokens.push(newToken);
}
async subscribeToWebPush(): Promise<void> {
try {
if (this.canShowWebPush) {
const subscription = await subscribeUserToPush();
if (subscription) {
const subscriptionJSON = subscription?.toJSON();
await this.$apollo.mutate({
mutation: REGISTER_PUSH_MUTATION,
variables: {
endpoint: subscriptionJSON.endpoint,
auth: subscriptionJSON?.keys?.auth,
p256dh: subscriptionJSON?.keys?.p256dh,
},
});
this.subscribed = true;
} else {
this.$notifier.error(
this.$t("Error while subscribing to push notifications") as string
);
}
} else {
console.error("can't do webpush");
}
} catch (e) {
console.error(e);
}
}
async unsubscribeToWebPush(): Promise<void> {
try {
const endpoint = await unsubscribeUserToPush();
if (endpoint) {
const { data } = await this.$apollo.mutate({
mutation: UNREGISTER_PUSH_MUTATION,
variables: {
endpoint,
},
});
console.log(data);
this.subscribed = false;
}
} catch (e) {
console.error(e);
}
}
async checkCanShowWebPush(): Promise<boolean> {
try {
if (!window.isSecureContext || !("serviceWorker" in navigator))
return Promise.resolve(false);
const registration = await navigator.serviceWorker.getRegistration();
return registration !== undefined;
} catch (e) {
console.error(e);
return Promise.resolve(false);
}
}
async created(): Promise<void> {
this.subscribed = await this.isSubscribed();
}
async updateNotificationValue(
key: string,
method: string,
enabled: boolean
): Promise<void> {
await this.$apollo.mutate({
mutation: UPDATE_ACTIVITY_SETTING,
variables: {
key,
method,
enabled,
userId: this.loggedUser.id,
},
});
}
private async isSubscribed(): Promise<boolean> {
try {
if (!("serviceWorker" in navigator)) return Promise.resolve(false);
const registration = await navigator.serviceWorker.getRegistration();
return (await registration?.pushManager?.getSubscription()) != null;
} catch (e) {
console.error(e);
return Promise.resolve(false);
}
}
private async deleteFeedToken(token: string): Promise<void> {
await this.$apollo.mutate({
mutation: DELETE_FEED_TOKEN,
variables: { token },
});
}
private async createNewFeedToken(): Promise<IFeedToken> {
const { data } = await this.$apollo.mutate({
mutation: CREATE_FEED_TOKEN,
});
return data.createFeedToken;
}
}
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.field {
&:not(:last-child) {
margin-bottom: 1.5rem;
}
a.change-timezone {
color: $primary;
text-decoration: underline;
text-decoration-color: #fea72b;
text-decoration-thickness: 2px;
@include margin-left(5px);
}
}
::v-deep .buttons > *:not(:last-child) .button {
margin-right: 0.5rem;
@include margin-right(0.5rem);
}
</style>

View File

@@ -0,0 +1,847 @@
<template>
<div v-if="loggedUser">
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
},
{
name: RouteName.NOTIFICATIONS,
text: $t('Notifications'),
},
]"
/>
<section class="my-4">
<h2>{{ $t("Browser notifications") }}</h2>
<o-button
v-if="subscribed"
@click="unsubscribeToWebPush()"
@keyup.enter="unsubscribeToWebPush()"
>{{ $t("Unsubscribe to browser push notifications") }}</o-button
>
<o-button
icon-left="rss"
@click="subscribeToWebPush"
@keyup.enter="subscribeToWebPush"
v-else-if="canShowWebPush && webPushEnabled"
>{{ $t("Activate browser push notifications") }}</o-button
>
<o-notification variant="warning" v-else-if="!webPushEnabled">
{{ $t("This instance hasn't got push notifications enabled.") }}
<i18n-t keypath="Ask your instance admin to {enable_feature}.">
<template #enable_feature>
<a
href="https://docs.joinmobilizon.org/administration/configure/push/"
target="_blank"
rel="noopener noreferer"
>{{ $t("enable the feature") }}</a
>
</template>
</i18n-t>
</o-notification>
<o-notification variant="danger" v-else>{{
$t("You can't use push notifications in this browser.")
}}</o-notification>
</section>
<section class="my-4">
<h2>{{ $t("Notification settings") }}</h2>
<p>
{{
$t(
"Select the activities for which you wish to receive an email or a push notification."
)
}}
</p>
<table class="table">
<tbody>
<template
v-for="notificationType in notificationTypes"
:key="notificationType"
>
<tr>
<th colspan="3">
{{ notificationType.label }}
</th>
</tr>
<tr>
<th v-for="(method, key) in notificationMethods" :key="key">
{{ method }}
</th>
<th>{{ $t("Description") }}</th>
</tr>
<tr v-for="subType in notificationType.subtypes" :key="subType.id">
<td v-for="(method, key) in notificationMethods" :key="key">
<o-checkbox
:value="notificationValues[subType.id][key].enabled"
@input="
(e: boolean) =>
updateNotificationValue({
key: subType.id,
method: key,
enabled: e,
})
"
:disabled="notificationValues[subType.id][key].disabled"
/>
</td>
<td>
{{ subType.label }}
</td>
</tr>
</template>
</tbody>
</table>
<o-field
:label="$t('Send notification e-mails')"
label-for="groupNotifications"
:message="
$t(
'Announcements and mentions notifications are always sent straight away.'
)
"
>
<o-select
v-model="groupNotifications"
@input="updateSetting({ groupNotifications })"
id="groupNotifications"
>
<option
v-for="(value, key) in groupNotificationsValues"
:value="key"
:key="key"
>
{{ value }}
</option>
</o-select>
</o-field>
</section>
<section class="my-4">
<h2>{{ $t("Participation notifications") }}</h2>
<div class="field">
<strong>{{
$t(
"Mobilizon will send you an email when the events you are attending have important changes: date and time, address, confirmation or cancellation, etc."
)
}}</strong>
</div>
<p>
{{ $t("Other notification options:") }}
</p>
<div class="field">
<o-checkbox
v-model="notificationOnDay"
@input="updateSetting({ notificationOnDay })"
>
<strong>{{ $t("Notification on the day of the event") }}</strong>
<p>
{{
$t(
"We'll use your timezone settings to send a recap of the morning of the event."
)
}}
</p>
<div v-if="loggedUser.settings && loggedUser.settings.timezone">
<em>{{
$t("Your timezone is currently set to {timezone}.", {
timezone: loggedUser.settings.timezone,
})
}}</em>
<router-link
class="change-timezone"
:to="{ name: RouteName.PREFERENCES }"
>{{ $t("Change timezone") }}</router-link
>
</div>
<span v-else>{{
$t("You can pick your timezone into your preferences.")
}}</span>
</o-checkbox>
</div>
<div class="field">
<o-checkbox
v-model="notificationEachWeek"
@input="updateSetting({ notificationEachWeek })"
>
<strong>{{ $t("Recap every week") }}</strong>
<p>
{{
$t(
"You'll get a weekly recap every Monday for upcoming events, if you have any."
)
}}
</p>
</o-checkbox>
</div>
<div class="field">
<o-checkbox
v-model="notificationBeforeEvent"
@input="updateSetting({ notificationBeforeEvent })"
>
<strong>{{ $t("Notification before the event") }}</strong>
<p>
{{
$t(
"We'll send you an email one hour before the event begins, to be sure you won't forget about it."
)
}}
</p>
</o-checkbox>
</div>
</section>
<section class="my-4">
<h2>{{ $t("Organizer notifications") }}</h2>
<div class="field is-primary">
<label
class="has-text-weight-bold"
for="notificationPendingParticipation"
>{{
$t("Notifications for manually approved participations to an event")
}}</label
>
<p>
{{
$t(
"If you have opted for manual validation of participants, Mobilizon will send you an email to inform you of new participations to be processed. You can choose the frequency of these notifications below."
)
}}
</p>
<o-select
v-model="notificationPendingParticipation"
id="notificationPendingParticipation"
@input="updateSetting({ notificationPendingParticipation })"
>
<option
v-for="(value, key) in notificationPendingParticipationValues"
:value="key"
:key="key"
>
{{ value }}
</option>
</o-select>
</div>
</section>
<section class="my-4">
<h2>{{ $t("Personal feeds") }}</h2>
<p>
{{
$t(
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
)
}}
</p>
<div v-if="feedTokens && feedTokens.length > 0">
<div
class="flex gap-2"
v-for="feedToken in feedTokens"
:key="feedToken.token"
>
<o-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.atom"
always
variant="success"
position="left"
>
<o-button
tag="a"
icon-left="rss"
@click="
(e: Event) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
@keyup.enter="
(e: Event) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
:href="tokenToURL(feedToken.token, 'atom')"
target="_blank"
>{{ $t("RSS/Atom Feed") }}</o-button
>
</o-tooltip>
<o-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.ics"
always
variant="success"
position="left"
>
<o-button
tag="a"
@click="
(e: Event) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
@keyup.enter="
(e: Event) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
icon-left="calendar-sync"
:href="tokenToURL(feedToken.token, 'ics')"
target="_blank"
>{{ $t("ICS/WebCal Feed") }}</o-button
>
</o-tooltip>
<o-button
icon-left="refresh"
type="is-text"
@click="openRegenerateFeedTokensConfirmation"
@keyup.enter="openRegenerateFeedTokensConfirmation"
>{{ $t("Regenerate new links") }}</o-button
>
</div>
</div>
<div v-else>
<o-button
icon-left="refresh"
type="is-text"
@click="generateFeedTokens"
@keyup.enter="generateFeedTokens"
>{{ $t("Create new links") }}</o-button
>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { INotificationPendingEnum } from "@/types/enums";
import {
SET_USER_SETTINGS,
USER_NOTIFICATIONS,
UPDATE_ACTIVITY_SETTING,
USER_FRAGMENT_FEED_TOKENS,
} from "../../graphql/user";
import {
IActivitySetting,
IActivitySettingMethod,
IUser,
} from "../../types/current-user.model";
import RouteName from "../../router/name";
import { IFeedToken } from "@/types/feedtoken.model";
import { CREATE_FEED_TOKEN, DELETE_FEED_TOKEN } from "@/graphql/feed_tokens";
import {
subscribeUserToPush,
unsubscribeUserToPush,
} from "../../services/push-subscription";
import {
REGISTER_PUSH_MUTATION,
UNREGISTER_PUSH_MUTATION,
} from "@/graphql/webPush";
import merge from "lodash/merge";
import { WEB_PUSH } from "@/graphql/config";
import { useMutation, useQuery } from "@vue/apollo-composable";
import {
computed,
inject,
onBeforeMount,
onMounted,
reactive,
ref,
watch,
} from "vue";
import { IConfig } from "@/types/config.model";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import { Dialog } from "@/plugins/dialog";
type NotificationSubType = { label: string; id: string };
type NotificationType = { label: string; subtypes: NotificationSubType[] };
const { result: loggedUserResult } = useQuery<{ loggedUser: IUser }>(
USER_NOTIFICATIONS
);
const loggedUser = computed(() => loggedUserResult.value?.loggedUser);
const feedTokens = computed(() =>
loggedUser.value?.feedTokens.filter(
(token: IFeedToken) => token.actor === null
)
);
const { result: webPushEnabledResult } = useQuery<{
config: Pick<IConfig, "webPush">;
}>(WEB_PUSH);
const webPushEnabled = computed(
() => webPushEnabledResult.value?.config?.webPush.enabled
);
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Notification settings")),
});
const notificationOnDay = ref<boolean | undefined>(true);
const notificationEachWeek = ref<boolean | undefined>(false);
const notificationBeforeEvent = ref<boolean | undefined>(false);
const notificationPendingParticipation = ref<
INotificationPendingEnum | undefined
>(INotificationPendingEnum.NONE);
const groupNotifications = ref<INotificationPendingEnum | undefined>(
INotificationPendingEnum.ONE_DAY
);
const notificationPendingParticipationValues = ref<Record<string, unknown>>({});
const groupNotificationsValues = ref<Record<string, unknown>>({});
const showCopiedTooltip = reactive({ ics: false, atom: false });
const subscribed = ref(false);
const canShowWebPush = ref(false);
const notificationMethods = {
email: t("Email"),
push: t("Push"),
};
const defaultNotificationValues = {
participation_event_updated: {
email: { enabled: true, disabled: true },
push: { enabled: true, disabled: true },
},
participation_event_comment: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
event_new_pending_participation: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
event_new_participation: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
event_created: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
event_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
discussion_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
post_published: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
post_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
resource_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
member_request: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
member_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
user_email_password_updated: {
email: { enabled: true, disabled: true },
push: { enabled: false, disabled: true },
},
event_comment_mention: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
discussion_mention: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
event_new_comment: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
};
const notificationTypes: NotificationType[] = [
{
label: t("Mentions") as string,
subtypes: [
{
id: "event_comment_mention",
label: t("I've been mentionned in a comment under an event") as string,
},
{
id: "discussion_mention",
label: t("I've been mentionned in a group discussion") as string,
},
],
},
{
label: t("Participations") as string,
subtypes: [
{
id: "participation_event_updated",
label: t("An event I'm going to has been updated") as string,
},
{
id: "participation_event_comment",
label: t("An event I'm going to has posted an announcement") as string,
},
],
},
{
label: t("Organizers") as string,
subtypes: [
{
id: "event_new_pending_participation",
label: t(
"An event I'm organizing has a new pending participation"
) as string,
},
{
id: "event_new_participation",
label: t("An event I'm organizing has a new participation") as string,
},
{
id: "event_new_comment",
label: t("An event I'm organizing has a new comment") as string,
},
],
},
{
label: t("Group activity") as string,
subtypes: [
{
id: "event_created",
label: t("An event from one of my groups has been published") as string,
},
{
id: "event_updated",
label: t(
"An event from one of my groups has been updated or deleted"
) as string,
},
{
id: "discussion_updated",
label: t("A discussion has been created or updated") as string,
},
{
id: "post_published",
label: t("A post has been published") as string,
},
{
id: "post_updated",
label: t("A post has been updated") as string,
},
{
id: "resource_updated",
label: t("A resource has been created or updated") as string,
},
{
id: "member_request",
label: t("A member requested to join one of my groups") as string,
},
{
id: "member_updated",
label: t("A member has been updated") as string,
},
],
},
{
label: t("User settings") as string,
subtypes: [
{
id: "user_email_password_updated",
label: t("You changed your email or password") as string,
},
],
},
];
const userNotificationValues = computed(
(): Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
> => {
return (loggedUser.value?.activitySettings ?? []).reduce(
(acc, activitySetting) => {
acc[activitySetting.key] = acc[activitySetting.key] || {};
acc[activitySetting.key][activitySetting.method] =
acc[activitySetting.key][activitySetting.method] || {};
acc[activitySetting.key][activitySetting.method].enabled =
activitySetting.enabled;
return acc;
},
{} as Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
>
);
}
);
const notificationValues = computed(
(): Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
> => {
const values = merge(
defaultNotificationValues,
userNotificationValues.value
);
for (const value in values) {
if (!canShowWebPush.value) {
values[value].push.disabled = true;
}
}
return values;
}
);
onMounted(async () => {
notificationPendingParticipationValues.value = {
[INotificationPendingEnum.NONE]: t("Do not receive any mail"),
[INotificationPendingEnum.DIRECT]: t("Receive one email per request"),
[INotificationPendingEnum.ONE_HOUR]: t("Hourly email summary"),
[INotificationPendingEnum.ONE_DAY]: t("Daily email summary"),
[INotificationPendingEnum.ONE_WEEK]: t("Weekly email summary"),
};
groupNotificationsValues.value = {
[INotificationPendingEnum.NONE]: t("Do not receive any mail"),
[INotificationPendingEnum.DIRECT]: t("Receive one email for each activity"),
[INotificationPendingEnum.ONE_HOUR]: t("Hourly email summary"),
[INotificationPendingEnum.ONE_DAY]: t("Daily email summary"),
[INotificationPendingEnum.ONE_WEEK]: t("Weekly email summary"),
};
canShowWebPush.value = await checkCanShowWebPush();
});
watch(loggedUser, () => {
if (loggedUser.value?.settings) {
notificationOnDay.value = loggedUser.value.settings.notificationOnDay;
notificationEachWeek.value = loggedUser.value.settings.notificationEachWeek;
notificationBeforeEvent.value =
loggedUser.value.settings.notificationBeforeEvent;
notificationPendingParticipation.value =
loggedUser.value.settings.notificationPendingParticipation;
groupNotifications.value = loggedUser.value.settings.groupNotifications;
}
});
const { mutate: updateSetting } = useMutation<{ setUserSettings: string }>(
SET_USER_SETTINGS,
() => ({ refetchQueries: [{ query: USER_NOTIFICATIONS }] })
);
const tokenToURL = (token: string, format: string): string => {
return `${window.location.origin}/events/going/${token}/${format}`;
};
const copyURL = (e: Event, url: string, format: "ics" | "atom"): void => {
if (navigator.clipboard) {
e.preventDefault();
navigator.clipboard.writeText(url);
showCopiedTooltip[format] = true;
setTimeout(() => {
showCopiedTooltip[format] = false;
}, 2000);
}
};
const dialog = inject<Dialog>("dialog");
const openRegenerateFeedTokensConfirmation = () => {
dialog?.confirm({
type: "warning",
title: t("Regenerate new links") as string,
message: t(
"You'll need to change the URLs where there were previously entered."
) as string,
confirmText: t("Regenerate new links") as string,
cancelText: t("Cancel") as string,
onConfirm: () => regenerateFeedTokens(),
});
};
const regenerateFeedTokens = async (): Promise<void> => {
if (!feedTokens.value || feedTokens.value?.length < 1) return;
await deleteFeedToken({ token: feedTokens.value[0].token });
await createNewFeedToken(
{},
{
update(cache, { data }) {
const userId = data?.createFeedToken.user?.id;
const newFeedToken = data?.createFeedToken.token;
if (!newFeedToken) return;
let cachedData = cache.readFragment<{
id: string | undefined;
feedTokens: { token: string }[];
}>({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
});
// Remove the old token
cachedData = {
id: cachedData?.id,
feedTokens: [
...(cachedData?.feedTokens ?? []).slice(0, -1),
{ token: newFeedToken },
],
};
cache.writeFragment({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
data: cachedData,
});
},
}
);
};
const generateFeedTokens = async (): Promise<void> => {
await createNewFeedToken();
};
const {
mutate: registerPushMutation,
onDone: registerPushMutationDone,
onError: registerPushMutationError,
} = useMutation(REGISTER_PUSH_MUTATION);
registerPushMutationDone(() => {
subscribed.value = true;
});
registerPushMutationError((err) => {
console.error(err);
});
const subscribeToWebPush = async (): Promise<void> => {
if (canShowWebPush.value) {
const subscription = await subscribeUserToPush();
if (subscription) {
const subscriptionJSON = subscription?.toJSON();
registerPushMutation({
endpoint: subscriptionJSON.endpoint,
auth: subscriptionJSON?.keys?.auth,
p256dh: subscriptionJSON?.keys?.p256dh,
});
subscribed.value = true;
} else {
// tnotifier.error(
// t("Error while subscribing to push notifications") as string
// );
}
} else {
console.error("can't do webpush");
}
};
const {
mutate: unregisterPushMutation,
onDone: onUnregisterPushMutationDone,
onError: onUnregisterPushMutationError,
} = useMutation(UNREGISTER_PUSH_MUTATION);
onUnregisterPushMutationDone(({ data }) => {
console.log(data);
subscribed.value = false;
});
onUnregisterPushMutationError((e) => {
console.error(e);
});
const unsubscribeToWebPush = async (): Promise<void> => {
const endpoint = await unsubscribeUserToPush();
if (endpoint) {
unregisterPushMutation({
endpoint,
});
}
};
const checkCanShowWebPush = async (): Promise<boolean> => {
try {
if (!window.isSecureContext || !("serviceWorker" in navigator))
return Promise.resolve(false);
const registration = await navigator.serviceWorker.getRegistration();
return registration !== undefined;
} catch (e) {
console.error(e);
return Promise.resolve(false);
}
};
onBeforeMount(async () => {
subscribed.value = await isSubscribed();
});
const { mutate: updateNotificationValue } = useMutation<
{
updateActivitySetting: IActivitySetting;
},
{
key: string;
method: IActivitySettingMethod;
enabled: boolean;
}
>(UPDATE_ACTIVITY_SETTING);
const isSubscribed = async (): Promise<boolean> => {
try {
if (!("serviceWorker" in navigator)) return Promise.resolve(false);
const registration = await navigator.serviceWorker.getRegistration();
return (await registration?.pushManager?.getSubscription()) != null;
} catch (e) {
console.error(e);
return Promise.resolve(false);
}
};
const { mutate: deleteFeedToken } = useMutation(DELETE_FEED_TOKEN);
const { mutate: createNewFeedToken } = useMutation(CREATE_FEED_TOKEN, () => ({
update(cache, { data }) {
const userId = data?.createFeedToken.user?.id;
const newFeedToken = data?.createFeedToken.token;
if (!newFeedToken) return;
let cachedData = cache.readFragment<{
id: string | undefined;
feedTokens: { token: string }[];
}>({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
});
// Add the new token to the list
cachedData = {
id: cachedData?.id,
feedTokens: [...(cachedData?.feedTokens ?? []), { token: newFeedToken }],
};
cache.writeFragment({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
data: cachedData,
});
},
}));
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.field {
&:not(:last-child) {
margin-bottom: 1.5rem;
}
a.change-timezone {
color: $primary;
text-decoration: underline;
text-decoration-color: #fea72b;
text-decoration-thickness: 2px;
@include margin-left(5px);
}
}
:deep(.buttons > *:not(:last-child) .button) {
margin-right: 0.5rem;
@include margin-right(0.5rem);
}
</style>

View File

@@ -1,308 +0,0 @@
<template>
<div>
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
},
{
name: RouteName.PREFERENCES,
text: $t('Preferences'),
},
]"
/>
<div>
<b-field :label="$t('Language')" label-for="setting-language">
<b-select
:loading="!config || !loggedUser"
v-model="locale"
:placeholder="$t('Select a language')"
id="setting-language"
>
<option v-for="(language, lang) in langs" :value="lang" :key="lang">
{{ language }}
</option>
</b-select>
</b-field>
<b-field
:label="$t('Timezone')"
v-if="selectedTimezone"
label-for="setting-timezone"
>
<b-select
:placeholder="$t('Select a timezone')"
:loading="!config || !loggedUser"
v-model="selectedTimezone"
id="setting-timezone"
>
<optgroup
:label="group"
v-for="(groupTimezones, group) in timezones"
:key="group"
>
<option
v-for="timezone in groupTimezones"
:value="`${group}/${timezone}`"
:key="timezone"
>
{{ sanitize(timezone) }}
</option>
</optgroup>
</b-select>
</b-field>
<em v-if="Intl.DateTimeFormat().resolvedOptions().timeZone">{{
$t("Timezone detected as {timezone}.", {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
}}</em>
<b-message v-else type="is-danger">{{
$t("Unable to detect timezone.")
}}</b-message>
<hr role="presentation" />
<b-field grouped>
<b-field
:label="$t('City or region')"
expanded
label-for="setting-city"
>
<address-auto-complete
v-if="loggedUser && loggedUser.settings"
:type="AddressSearchType.ADMINISTRATIVE"
:doGeoLocation="false"
v-model="address"
id="setting-city"
>
</address-auto-complete>
</b-field>
<b-field :label="$t('Radius')" label-for="setting-radius">
<b-select
:placeholder="$t('Select a radius')"
v-model="locationRange"
id="setting-radius"
>
<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-button
:disabled="address == undefined"
@click="resetArea"
@keyup.enter="resetArea"
class="reset-area"
icon-left="close"
:aria-label="$t('Reset')"
/>
</b-field>
<p>
{{
$t(
"Your city or region and the radius will only be used to suggest you events nearby. The event radius will consider the administrative center of the area."
)
}}
</p>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import ngeohash from "ngeohash";
import { saveLocaleData } from "@/utils/auth";
import { TIMEZONES } from "../../graphql/config";
import {
USER_SETTINGS,
SET_USER_SETTINGS,
UPDATE_USER_LOCALE,
} from "../../graphql/user";
import { IConfig } from "../../types/config.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,
},
metaInfo() {
return {
title: this.$t("Preferences") as string,
};
},
})
export default class Preferences extends Vue {
config!: IConfig;
loggedUser!: IUser;
RouteName = RouteName;
langs: Record<string, string> = langs;
AddressSearchType = AddressSearchType;
get selectedTimezone(): string {
if (this.loggedUser?.settings?.timezone) {
return this.loggedUser.settings.timezone;
}
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (this.loggedUser?.settings?.timezone === null) {
this.updateUserSettings({ timezone: detectedTimezone });
}
return detectedTimezone;
}
set selectedTimezone(selectedTimezone: string) {
if (selectedTimezone !== this.loggedUser?.settings?.timezone) {
this.updateUserSettings({ timezone: selectedTimezone });
}
}
get locale(): string {
if (this.loggedUser?.locale) {
return this.loggedUser?.locale;
}
return this.$i18n.locale;
}
set locale(locale: string) {
if (locale) {
this.$apollo.mutate({
mutation: UPDATE_USER_LOCALE,
variables: {
locale,
},
});
saveLocaleData(locale);
}
}
// eslint-disable-next-line class-methods-use-this
sanitize(timezone: string): string {
return timezone
.split("_")
.join(" ")
.replace("St ", "St. ")
.split("/")
.join(" - ");
}
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);
},
{}
);
}
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 | null {
return this.loggedUser?.settings?.location?.range;
}
set locationRange(locationRange: number | undefined | null) {
if (locationRange) {
this.updateUserSettings({
location: {
range: locationRange,
},
});
}
}
resetArea(): void {
this.updateUserSettings({
location: {
geohash: null,
name: null,
range: null,
},
});
}
private async updateUserSettings(userSettings: IUserSettings) {
await this.$apollo.mutate<{ setUserSetting: string }>({
mutation: SET_USER_SETTINGS,
variables: userSettings,
refetchQueries: [{ query: USER_SETTINGS }],
});
}
}
</script>
<style lang="scss" scoped>
.reset-area {
align-self: center;
position: relative;
top: 10px;
}
</style>

View File

@@ -0,0 +1,296 @@
<template>
<div>
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: t('Account'),
},
{
name: RouteName.PREFERENCES,
text: t('Preferences'),
},
]"
/>
<div>
<o-field :label="t('Language')" label-for="setting-language">
<o-select
:loading="loadingTimezones || loadingUserSettings"
v-model="locale"
:placeholder="t('Select a language')"
id="setting-language"
>
<option v-for="(language, lang) in langs" :value="lang" :key="lang">
{{ language }}
</option>
</o-select>
</o-field>
<o-field
:label="t('Timezone')"
v-if="selectedTimezone"
label-for="setting-timezone"
>
<o-select
:placeholder="t('Select a timezone')"
:loading="loadingTimezones || loadingUserSettings"
v-model="selectedTimezone"
id="setting-timezone"
>
<optgroup
:label="group"
v-for="(groupTimezones, group) in timezones"
:key="group"
>
<option
v-for="timezone in groupTimezones"
:value="`${group}/${timezone}`"
:key="timezone"
>
{{ sanitize(timezone) }}
</option>
</optgroup>
</o-select>
</o-field>
<em v-if="Intl.DateTimeFormat().resolvedOptions().timeZone">{{
t("Timezone detected as {timezone}.", {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
}}</em>
<o-notification v-else variant="danger">{{
t("Unable to detect timezone.")
}}</o-notification>
<hr role="presentation" />
<o-field grouped>
<o-field :label="t('City or region')" expanded label-for="setting-city">
<full-address-auto-complete
v-if="loggedUser?.settings"
:type="AddressSearchType.ADMINISTRATIVE"
:doGeoLocation="false"
v-model="address"
id="setting-city"
class="grid"
:placeholder="t('e.g. Nantes, Berlin, Cork, …')"
/>
</o-field>
<o-field :label="t('Radius')" label-for="setting-radius">
<o-select
:placeholder="t('Select a radius')"
v-model="locationRange"
id="setting-radius"
>
<option
v-for="index in [1, 5, 10, 25, 50, 100]"
:key="index"
:value="index"
>
{{ t("{count} km", { count: index }, index) }}
</option>
</o-select>
</o-field>
<o-button
:disabled="address == undefined"
@click="resetArea"
@keyup.enter="resetArea"
class="reset-area self-center"
icon-left="close"
:aria-label="t('Reset')"
/>
</o-field>
<p>
{{
t(
"Your city or region and the radius will only be used to suggest you events nearby. The event radius will consider the administrative center of the area."
)
}}
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import ngeohash from "ngeohash";
import { saveLocaleData } from "@/utils/auth";
import {
USER_SETTINGS,
SET_USER_SETTINGS,
UPDATE_USER_LOCALE,
} from "../../graphql/user";
import langs from "../../i18n/langs.json";
import RouteName from "../../router/name";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import { AddressSearchType } from "@/types/enums";
import { Address, IAddress } from "@/types/address.model";
import { useTimezones } from "@/composition/apollo/config";
import { useUserSettings } from "@/composition/apollo/user";
import { useHead } from "@vueuse/head";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable";
const { timezones: serverTimezones, loading: loadingTimezones } =
useTimezones();
const { loggedUser, loading: loadingUserSettings } = useUserSettings();
const { t, locale: i18nLocale } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Preferences")),
});
// langs: Record<string, string> = langs;
const selectedTimezone = computed({
get() {
if (loggedUser.value?.settings?.timezone) {
return loggedUser.value.settings.timezone;
}
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (loggedUser.value?.settings?.timezone === null) {
updateUserSettings({ timezone: detectedTimezone });
}
return detectedTimezone;
},
set(newSelectedTimezone: string) {
if (newSelectedTimezone !== loggedUser.value?.settings?.timezone) {
updateUserSettings({ timezone: newSelectedTimezone });
}
},
});
const { mutate: updateUserLocale } = useMutation(UPDATE_USER_LOCALE);
const locale = computed({
get(): string {
if (loggedUser.value?.locale) {
return loggedUser.value?.locale;
}
return i18nLocale.value as string;
},
set(newLocale: string) {
if (newLocale) {
updateUserLocale({
locale: newLocale,
});
saveLocaleData(newLocale);
console.log("changing locale", i18nLocale, newLocale);
i18nLocale.value = newLocale;
}
},
});
const sanitize = (timezone: string): string => {
return timezone
.split("_")
.join(" ")
.replace("St ", "St. ")
.split("/")
.join(" - ");
};
const timezones = computed((): Record<string, string[]> => {
if (!serverTimezones.value) return {};
return serverTimezones.value.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, t("Other") as string, prefix);
},
{}
);
});
const address = computed({
get(): IAddress | null {
if (
loggedUser.value?.settings?.location?.name &&
loggedUser.value?.settings?.location?.geohash
) {
const { latitude, longitude } = ngeohash.decode(
loggedUser.value?.settings?.location?.geohash
);
const name = loggedUser.value?.settings?.location?.name;
return {
description: name,
locality: "",
type: "administrative",
geom: `${longitude};${latitude}`,
street: "",
postalCode: "",
region: "",
country: "",
};
}
return null;
},
set(newAddress: IAddress | null) {
if (newAddress?.geom) {
const { geom } = newAddress;
const addressObject = new Address(newAddress);
const queryText = addressObject.poiInfos.name;
const [lon, lat] = geom.split(";");
const geohash = ngeohash.encode(lat, lon, 6);
if (queryText && geom) {
updateUserSettings({
location: {
geohash,
name: queryText,
},
});
}
}
},
});
const locationRange = computed({
get(): number | undefined | null {
return loggedUser.value?.settings?.location?.range;
},
set(newLocationRange: number | undefined | null) {
if (newLocationRange) {
updateUserSettings({
location: {
range: newLocationRange,
},
});
}
},
});
const resetArea = (): void => {
updateUserSettings({
location: {
geohash: null,
name: null,
range: null,
},
});
};
const { mutate: updateUserSettings } = useMutation<{ setUserSetting: string }>(
SET_USER_SETTINGS,
() => ({
refetchQueries: [{ query: USER_SETTINGS }],
})
);
</script>
<style lang="scss" scoped>
.reset-area {
align-self: center;
position: relative;
top: 10px;
}
</style>