diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2fb1bef..81a615e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,9 @@ A minimal file template [is available](https://framagit.org/framasoft/mobilizon/ Also make sure to remove the `EnvironmentFile=` line from the systemd service and set `Environment=MIX_ENV=prod` instead. See [the updated file](https://framagit.org/framasoft/mobilizon/blob/master/support/systemd/mobilizon.service). ### Added -- Possibility to participate anonymously to an event +- Possibility to participate to an event without an account - Possibility to participate to a remote event (being redirected by providing federated identity) +- Possibility to add a note as a participant when event participation is manually validated (required when participating without an account) - Possibility to change email address for the account - Possibility to delete your account @@ -26,6 +27,7 @@ Also make sure to remove the `EnvironmentFile=` line from the systemd service an - Signature validation also now checks if `Date` header has acceptable values - Actor profiles are now stale after two days and have to be refetched - Actor keys are rotated some time after sending a `Delete` activity +- Improved event participations managing interface ### Fixed - Fixed URL search diff --git a/docs/contribute/activity_pub.md b/docs/contribute/activity_pub.md index 8f8b901ca..da2756680 100644 --- a/docs/contribute/activity_pub.md +++ b/docs/contribute/activity_pub.md @@ -21,7 +21,8 @@ Supported Activity | Supported Object `Create` | `Note`, `Event` `Delete` | `Object` `Flag` | `Object` -`Follow` | `Object` +`Follow` | `Object` +`Join` | `Event` `Reject` | `Follow`, `Join` `Remove` | `Note`, `Event` `Undo` | `Announce`, `Follow` @@ -155,4 +156,28 @@ We add [an `address` property](https://schema.org/address), which we assume to b }, "type": "Event" } +``` + +### Join + +#### participationMessage + +We add a `participationMessage` property on a `Join` activity so that participants may transmit a note to event organizers, to motivate their participation when event participations are manually approved. This field is restricted to plain text. + +```json +{ + "type": "Join", + "object": "http://mobilizon.test/events/some-uuid", + "id": "http://mobilizon2.test/@admin/join/event/1", + "actor": "http://mobilizon2.test/@admin", + "participationMessage": "I want to join !", + "@context": [ + { + "participationMessage": { + "@id": "mz:participationMessage", + "@type": "sc:Text" + } + } + ] +} ``` \ No newline at end of file diff --git a/js/src/components/Admin/Followers.vue b/js/src/components/Admin/Followers.vue index bda7bbc66..7f44e9407 100644 --- a/js/src/components/Admin/Followers.vue +++ b/js/src/components/Admin/Followers.vue @@ -6,7 +6,6 @@ :loading="$apollo.queries.relayFollowers.loading" ref="table" :checked-rows.sync="checkedRows" - :is-row-checkable="(row) => row.id !== 3" detailed :show-detail-icon="false" paginated diff --git a/js/src/components/Event/ParticipationButton.vue b/js/src/components/Event/ParticipationButton.vue index 9af503210..6e59e01c0 100644 --- a/js/src/components/Event/ParticipationButton.vue +++ b/js/src/components/Event/ParticipationButton.vue @@ -1,3 +1,4 @@ +import {EventJoinOptions} from "@/types/event.model"; <docs> A button to set your participation @@ -80,7 +81,7 @@ A button to set your participation </figure> </div> <div class="media-content"> - <span>{{ $t('as {identity}', {identity: currentActor.preferredUsername }) }}</span> + <span>{{ $t('as {identity}', {identity: currentActor.name || `@${currentActor.preferredUsername}` }) }}</span> </div> </div> </b-dropdown-item> @@ -96,14 +97,13 @@ A button to set your participation <script lang="ts"> import { Component, Prop, Vue } from 'vue-property-decorator'; -import { EventModel, IEvent, IParticipant, ParticipantRole } from '@/types/event.model'; +import { EventJoinOptions, IEvent, IParticipant, ParticipantRole } from '@/types/event.model'; import { IPerson, Person } from '@/types/actor'; import { CURRENT_ACTOR_CLIENT, IDENTITIES } from '@/graphql/actor'; import { CURRENT_USER_CLIENT } from '@/graphql/user'; import { CONFIG } from '@/graphql/config'; import { IConfig } from '@/types/config.model'; import { RouteName } from '@/router'; -import { FETCH_EVENT } from '@/graphql/event'; @Component({ apollo: { @@ -132,7 +132,11 @@ export default class ParticipationButton extends Vue { RouteName = RouteName; joinEvent(actor: IPerson) { - this.$emit('joinEvent', actor); + if (this.event.joinOptions === EventJoinOptions.RESTRICTED) { + this.$emit('joinEventWithConfirmation', actor); + } else { + this.$emit('joinEvent', actor); + } } joinModal() { diff --git a/js/src/components/Event/ParticipationTable.vue b/js/src/components/Event/ParticipationTable.vue new file mode 100644 index 000000000..511b786d7 --- /dev/null +++ b/js/src/components/Event/ParticipationTable.vue @@ -0,0 +1,164 @@ +<template> + <b-table + :data="data" + ref="queueTable" + detailed + detail-key="id" + :checked-rows.sync="checkedRows" + checkable + :is-row-checkable="row => row.role !== ParticipantRole.CREATOR" + checkbox-position="left" + default-sort="insertedAt" + default-sort-direction="asc" + :show-detail-icon="false" + :loading="this.$apollo.loading" + paginated + backend-pagination + :aria-next-label="$t('Next page')" + :aria-previous-label="$t('Previous page')" + :aria-page-label="$t('Page')" + :aria-current-label="$t('Current page')" + :total="total" + :per-page="perPage" + backend-sorting + :default-sort-direction="'desc'" + :default-sort="['insertedAt', 'desc']" + @page-change="page => $emit('page-change', page)" + @sort="(field, order) => $emit('sort', field, order)" + > + <template slot-scope="props"> + <b-table-column field="insertedAt" :label="$t('Date')" sortable> + <b-tag type="is-success" class="has-text-centered">{{ props.row.insertedAt | formatDateString }}<br>{{ props.row.insertedAt | formatTimeString }}</b-tag> + </b-table-column> + <b-table-column field="role" :label="$t('Role')" sortable v-if="showRole"> + <span v-if="props.row.role === ParticipantRole.CREATOR"> + {{ $t('Organizer') }} + </span> + <span v-else-if="props.row.role === ParticipantRole.PARTICIPANT"> + {{ $t('Participant') }} + </span> + </b-table-column> + <b-table-column field="actor.preferredUsername" :label="$t('Participant')" sortable> + <article class="media"> + <figure class="media-left" v-if="props.row.actor.avatar"> + <p class="image is-48x48"> + <img :src="props.row.actor.avatar.url" alt=""> + </p> + </figure> + <b-icon class="media-left" v-else-if="props.row.actor.preferredUsername === 'anonymous'" size="is-large" icon="incognito" /> + <b-icon class="media-left" v-else size="is-large" icon="account-circle" /> + <div class="media-content"> + <div class="content"> + <span v-if="props.row.actor.preferredUsername !== 'anonymous'"> + <span v-if="props.row.actor.name">{{ props.row.actor.name }}</span><br /> + <span class="is-size-7 has-text-grey">@{{ props.row.actor.preferredUsername }}</span> + </span> + <span v-else> + {{ $t('Anonymous participant') }} + </span> + </div> + </div> + </article> + </b-table-column> + <b-table-column field="metadata.message" :label="$t('Message')"> + <span @click="toggleQueueDetails(props.row)" :class="{ 'ellipsed-message': props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH }" v-if="props.row.metadata && props.row.metadata.message"> + {{ props.row.metadata.message | ellipsize }} + </span> + <span v-else class="has-text-grey"> + {{ $t('No message') }} + </span> + </b-table-column> + </template> + <template slot="detail" slot-scope="props"> + <article v-html="nl2br(props.row.metadata.message)" /> + </template> + <template slot="bottom-left" v-if="checkedRows.length > 0"> + <div class="buttons"> + <b-button @click="acceptParticipants(checkedRows)" type="is-success" v-if="canAcceptParticipants"> + {{ $tc('No participant to approve|Approve participant|Approve {number} participants', checkedRows.length, { number: checkedRows.length }) }} + </b-button> + <b-button @click="refuseParticipants(checkedRows)" type="is-danger" v-if="canRefuseParticipants"> + {{ $tc('No participant to reject|Reject participant|Reject {number} participants', checkedRows.length, { number: checkedRows.length }) }} + </b-button> + </div> + </template> + </b-table> +</template> +<script lang="ts"> +import { Component, Prop, Vue } from 'vue-property-decorator'; +import { IParticipant, ParticipantRole } from '@/types/event.model'; +import { Refs } from '@/shims-vue'; +import { nl2br } from '@/utils/html'; +import { asyncForEach } from '@/utils/asyncForEach'; + +const MESSAGE_ELLIPSIS_LENGTH = 130; + +@Component({ + filters: { + ellipsize: (text?: string) => text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat('…'), + }, +}) +export default class ParticipationTable extends Vue { + @Prop({ required: true, type: Array }) data!: IParticipant[]; + @Prop({ required: true, type: Number }) total!: number; + @Prop({ required: true, type: Function }) acceptParticipant; + @Prop({ required: true, type: Function }) refuseParticipant; + @Prop({ required: false, type: Boolean, default: false }) showRole; + @Prop({ required: false, type: Number, default: 20 }) perPage; + checkedRows: IParticipant[] = []; + + MESSAGE_ELLIPSIS_LENGTH = MESSAGE_ELLIPSIS_LENGTH; + nl2br = nl2br; + ParticipantRole = ParticipantRole; + + $refs!: Refs<{ + queueTable: any, + }>; + + toggleQueueDetails(row: IParticipant) { + if (row.metadata.message && row.metadata.message.length < MESSAGE_ELLIPSIS_LENGTH) return; + this.$refs.queueTable.toggleDetails(row); + } + + async acceptParticipants(participants: IParticipant[]) { + await asyncForEach(participants, async (participant: IParticipant) => { + await this.acceptParticipant(participant); + }); + this.checkedRows = []; + } + + async refuseParticipants(participants: IParticipant[]) { + await asyncForEach(participants, async (participant: IParticipant) => { + await this.refuseParticipant(participant); + }); + this.checkedRows = []; + } + + /** + * We can accept participants if at least one of them is not approved + */ + get canAcceptParticipants(): boolean { + return this.checkedRows.some( + (participant: IParticipant) => [ParticipantRole.NOT_APPROVED, ParticipantRole.REJECTED].includes(participant.role), + ); + } + + /** + * We can refuse participants if at least one of them is something different than not approved + */ + get canRefuseParticipants(): boolean { + return this.checkedRows.some((participant: IParticipant) => participant.role !== ParticipantRole.REJECTED); + } +} +</script> +<style lang="scss" scoped> + .ellipsed-message { + cursor: pointer; + } + + .table { + span.tag { + height: initial; + } + } +</style> \ No newline at end of file diff --git a/js/src/components/NavBar.vue b/js/src/components/NavBar.vue index 922f9d2ca..9af4041ff 100644 --- a/js/src/components/NavBar.vue +++ b/js/src/components/NavBar.vue @@ -29,7 +29,7 @@ <figure class="image is-32x32" v-if="identity.avatar"> <img class="is-rounded" :src="identity.avatar.url" alt="" /> </figure> - <b-icon v-else icon="account-circle" /> + <b-icon v-else size="is-medium" icon="account-circle" /> </div> <div class="media-content"> @@ -180,6 +180,10 @@ nav { background: $secondary; } + span.icon.is-medium { + display: flex; + } + img { max-height: 2.5em; } diff --git a/js/src/components/Participation/ParticipationWithoutAccount.vue b/js/src/components/Participation/ParticipationWithoutAccount.vue index 73d333b44..1c4c8602d 100644 --- a/js/src/components/Participation/ParticipationWithoutAccount.vue +++ b/js/src/components/Participation/ParticipationWithoutAccount.vue @@ -7,18 +7,24 @@ <b-message type="is-info">{{ $t("Your email will only be used to confirm that you're a real person and send you eventual updates for this event. It will NOT be transmitted to other instances or to the event organizer.") }}</b-message> <b-message type="is-danger" v-if="error">{{ error }}</b-message> <b-field :label="$t('Email')"> - <b-field> - <b-input - type="email" - v-model="anonymousParticipation.email" - placeholder="Your email" - required> - </b-input> - <p class="control"> - <b-button type="is-primary" native-type="submit">{{ $t('Send email') }}</b-button> - </p> - </b-field> + <b-input + type="email" + v-model="anonymousParticipation.email" + placeholder="Your email" + required> + </b-input> </b-field> + <p v-if="event.joinOptions === EventJoinOptions.RESTRICTED">{{ $t("The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.") }}</p> + <p v-else>{{ $t("If you want, you may send a message to the event organizer here.") }}</p> + <b-field :label="$t('Message')"> + <b-input + type="textarea" + v-model="anonymousParticipation.message" + minlength="10" + :required="event.joinOptions === EventJoinOptions.RESTRICTED"> + </b-input> + </b-field> + <b-button type="is-primary" native-type="submit">{{ $t('Send email') }}</b-button> <div class="has-text-centered"> <b-button native-type="button" tag="a" type="is-text" @click="$router.go(-1)"> {{ $t('Back to previous page') }} @@ -31,7 +37,7 @@ </template> <script lang="ts"> import { Component, Prop, Vue } from 'vue-property-decorator'; -import { EventModel, IEvent, IParticipant, ParticipantRole } from '@/types/event.model'; +import { EventModel, IEvent, IParticipant, ParticipantRole, EventJoinOptions } from '@/types/event.model'; import { FETCH_EVENT, JOIN_EVENT } from '@/graphql/event'; import { IConfig } from '@/types/config.model'; import { CONFIG } from '@/graphql/config'; @@ -55,10 +61,11 @@ import { RouteName } from '@/router'; }) export default class ParticipationWithoutAccount extends Vue { @Prop({ type: String, required: true }) uuid!: string; - anonymousParticipation: { email: String } = { email: '' }; + anonymousParticipation: { email: String, message: String } = { email: '', message: '' }; event!: IEvent; config!: IConfig; error: String|boolean = false; + EventJoinOptions = EventJoinOptions; async joinEvent() { this.error = false; @@ -69,6 +76,7 @@ export default class ParticipationWithoutAccount extends Vue { eventId: this.event.id, actorId: this.config.anonymous.actorId, email: this.anonymousParticipation.email, + message: this.anonymousParticipation.message, }, update: (store, { data }) => { if (data == null) return; diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts index b229ad325..2b81e2213 100644 --- a/js/src/graphql/event.ts +++ b/js/src/graphql/event.ts @@ -2,23 +2,28 @@ import gql from 'graphql-tag'; import { COMMENT_FIELDS_FRAGMENT } from '@/graphql/comment'; const participantQuery = ` - role, - id, - actor { - preferredUsername, - avatar { - url + total, + elements { + role, + id, + actor { + preferredUsername, + avatar { + url + }, + name, + id, + domain }, - name, - id, - domain - }, - event { - id, - uuid - }, - metadata { - cancellationToken + event { + id, + uuid + }, + metadata { + cancellationToken, + message + }, + insertedAt } `; @@ -371,11 +376,12 @@ export const EDIT_EVENT = gql` `; export const JOIN_EVENT = gql` - mutation JoinEvent($eventId: ID!, $actorId: ID!, $email: String) { + mutation JoinEvent($eventId: ID!, $actorId: ID!, $email: String, $message: String) { joinEvent( eventId: $eventId, actorId: $actorId, - email: $email + email: $email, + message: $message ) { ${participantQuery} } diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json index 07ce2c0fe..6185fdaff 100644 --- a/js/src/i18n/en_US.json +++ b/js/src/i18n/en_US.json @@ -23,7 +23,6 @@ "Allow all comments": "Allow all comments", "Allow registrations": "Allow registrations", "An error has occurred.": "An error has occurred.", - "And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "And no anonymous participations|And one anonymous participation|And {count} anonymous participations", "Anonymous participants will be asked to confirm their participation through e-mail.": "Anonymous participants will be asked to confirm their participation through e-mail.", "Anonymous participations": "Anonymous participations", "Approve": "Approve", @@ -467,5 +466,17 @@ "{count} requests waiting": "{count} requests waiting", "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.", "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks", - "© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors" + "© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors", + "The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.": "The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.", + "If you want, you may send a message to the event organizer here.": "If you want, you may send a message to the event organizer here.", + "Message": "Message", + "Anonymous participant": "Anonymous participant", + "No message": "No message", + "No participant to approve|Approve participant|Approve {number} participants": "No participant to approve|Approve participant|Approve {number} participants", + "No participant to reject|Reject participant|Reject {number} participants": "No participant to reject|Reject participant|Reject {number} participants", + "Role": "Role", + "Participant": "Participant", + "Participation confirmation": "Participation confirmation", + "The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?": "The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?", + "Confirm my participation": "Confirm my participation" } \ No newline at end of file diff --git a/js/src/i18n/es.json b/js/src/i18n/es.json index 4d012eaf8..d52fea964 100644 --- a/js/src/i18n/es.json +++ b/js/src/i18n/es.json @@ -23,7 +23,6 @@ "Allow all comments": "Permitir todos los comentarios", "Allow registrations": "Permitir registros", "An error has occurred.": "Se ha producido un error.", - "And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "Y sin participaciones anónimas|Y una participación anónima|Y {count} participaciones anónimas", "Anonymous participants will be asked to confirm their participation through e-mail.": "Los participantes anónimos deberán confirmar su participación por correo electrónico.", "Anonymous participations": "Participaciones anónimas", "Approve": "Aprobar", diff --git a/js/src/i18n/fi.json b/js/src/i18n/fi.json index ab6ea63f5..ca5568d9f 100644 --- a/js/src/i18n/fi.json +++ b/js/src/i18n/fi.json @@ -22,7 +22,6 @@ "Allow all comments": "Salli kaikki kommentit", "Allow registrations": "Salli rekisteröityminen", "An error has occurred.": "Tapahtui virhe.", - "And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "Ei anonyymejä osallistujia|Myös yksi anonyymi osallistuja|Myös {count} anonyymiä osallistujaa", "Anonymous participants will be asked to confirm their participation through e-mail.": "Anonyymejä osallistujia pyydetään vahvistamaan osallistumisensa sähköpostitse.", "Anonymous participations": "Anonyymit osallistujat", "Approve": "Hyväksy", diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json index eb6987bfa..22bb46c53 100644 --- a/js/src/i18n/fr_FR.json +++ b/js/src/i18n/fr_FR.json @@ -23,7 +23,6 @@ "Allow all comments": "Autoriser tous les commentaires", "Allow registrations": "Autoriser les inscriptions", "An error has occurred.": "Une erreur est survenue.", - "And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "Et aucune participation anonyme|Et une participation anonyme|Et {count} participations anonymes", "Anonymous participants will be asked to confirm their participation through e-mail.": "Les participants anonymes devront confirmer leur participation par email.", "Anonymous participations": "Participations anonymes", "Approve": "Approuver", @@ -252,14 +251,14 @@ "Or": "Ou", "Organized": "Organisés", "Organized by {name}": "Organisé par {name}", - "Organizer": "Organisateur", + "Organizer": "Organisateur⋅ice", "Other software may also support this.": "D'autres logiciels peuvent également supporter cette fonctionnalité.", "Otherwise this identity will just be removed from the group administrators.": "Sinon cette identité sera juste supprimée des administrateurs du groupe.", "Page limited to my group (asks for auth)": "Accès limité à mon groupe (demande authentification)", "Page not found": "Page non trouvée", "Participant already was rejected.": "Le participant a déjà été refusé.", "Participant has already been approved as participant.": "Le participant a déjà été approuvé en tant que participant.", - "Participants": "Participants", + "Participants": "Participant⋅e⋅s", "Participate": "Participer", "Participate using your email address": "Participer en utilisant votre adresse email", "Participation approval": "Validation des participations", @@ -473,5 +472,17 @@ "{count} requests waiting": "Une demande en attente|{count} demandes en attente", "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} garantit {respect} des personnes qui l'utiliseront. Puisque {source}, il est publiquement auditable, ce qui garantit sa transparence.", "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines", - "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap" + "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap", + "The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.": "L'organisateur⋅ice de l'événement valide les participations manuellement. Comme vous avez choisi de participer sans compte, merci d'expliquer pourquoi vous voulez participer à cet événement.", + "If you want, you may send a message to the event organizer here.": "Si vous le désirez, vous pouvez laisser un message pour l'organisateur⋅ice de l'événement ci-dessous.", + "Message": "Message", + "Anonymous participant": "Participant⋅e anonyme", + "No message": "Pas de message", + "No participant to approve|Approve participant|Approve {number} participants": "Aucun⋅e participant⋅e à valider|Valider le ou la participant⋅e|Valider {number} participant⋅es", + "No participant to reject|Reject participant|Reject {number} participants": "Aucun⋅e participant⋅e à refuser|Refuser le ou la participant⋅e|Refuser {number} participant⋅es", + "Role": "Rôle", + "Participant": "Participant⋅e", + "Participation confirmation": "Confirmation de votre participation", + "The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?": "L'organisateur⋅ice de l'événement a choisi de valider manuellement les demandes de participation. Voulez-vous ajouter un petit message pour expliquer pourquoi vous souhaitez participer à cet événement ?", + "Confirm my participation": "Confirmer ma participation" } diff --git a/js/src/i18n/oc.json b/js/src/i18n/oc.json index 03d8839f6..eadb25bbb 100644 --- a/js/src/i18n/oc.json +++ b/js/src/i18n/oc.json @@ -25,7 +25,6 @@ "Allow all comments": "Autorizar totes los comentaris", "Allow registrations": "Permetre las inscripcions", "An error has occurred.": "Una error s’es producha.", - "And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "E cap de participacion anonima|E una participacion anonima|E {count} participacions anonima", "Anonymous participants will be asked to confirm their participation through e-mail.": "Òm demandarà als participants anonims de confirmar lor venguda via un corrièl.", "Anonymous participations": "Participacions anonimas", "Approve": "Aprovar", diff --git a/js/src/i18n/pt_BR.json b/js/src/i18n/pt_BR.json index de388042a..6fd8a23bd 100644 --- a/js/src/i18n/pt_BR.json +++ b/js/src/i18n/pt_BR.json @@ -22,7 +22,6 @@ "Allow all comments": "Permitir todos comentários", "Allow registrations": "Permitir inscrições", "An error has occurred.": "Ocorreu um erro.", - "And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "E nenhuma participação anônima|E uma participação anônima|E {count} participações anônimas", "Anonymous participants will be asked to confirm their participation through e-mail.": "Os participantes anônimos deverão confirmar sua participação por email.", "Anonymous participations": "Participações anônimas", "Approve": "Aprovar", diff --git a/js/src/plugins/notifier.ts b/js/src/plugins/notifier.ts index e0be7146f..9920a1130 100644 --- a/js/src/plugins/notifier.ts +++ b/js/src/plugins/notifier.ts @@ -1,5 +1,6 @@ import Vue from 'vue'; import { ColorModifiers } from 'buefy/types/helpers'; +import { Route, RawLocation } from 'vue-router'; declare module 'vue/types/vue' { interface Vue { @@ -8,6 +9,23 @@ declare module 'vue/types/vue' { error: (message: string) => void; info: (message: string) => void; }; + beforeRouteEnter?( + to: Route, + from: Route, + next: (to?: RawLocation | false | ((vm: Vue) => void)) => void, + ): void; + + beforeRouteLeave?( + to: Route, + from: Route, + next: (to?: RawLocation | false | ((vm: Vue) => void)) => void, + ): void; + + beforeRouteUpdate?( + to: Route, + from: Route, + next: (to?: RawLocation | false | ((vm: Vue) => void)) => void, + ): void; } } diff --git a/js/src/router/event.ts b/js/src/router/event.ts index 02f711f9c..e0f50987c 100644 --- a/js/src/router/event.ts +++ b/js/src/router/event.ts @@ -63,7 +63,7 @@ export const eventRoutes: RouteConfig[] = [ props: { isUpdate: true }, }, { - path: '/events/participations/:eventId', + path: '/events/:eventId/participations', name: EventRouteName.PARTICIPATIONS, component: participations, meta: { requiredAuth: true }, diff --git a/js/src/types/event.model.ts b/js/src/types/event.model.ts index c84e7e61b..41055f17a 100644 --- a/js/src/types/event.model.ts +++ b/js/src/types/event.model.ts @@ -3,6 +3,7 @@ import { Address, IAddress } from '@/types/address.model'; import { ITag } from '@/types/tag.model'; import { IPicture } from '@/types/picture.model'; import { IComment } from '@/types/comment.model'; +import { Paginate } from '@/types/paginate'; export enum EventStatus { TENTATIVE = 'TENTATIVE', @@ -59,7 +60,8 @@ export interface IParticipant { role: ParticipantRole; actor: IActor; event: IEvent; - metadata: { cancellationToken?: string }; + metadata: { cancellationToken?: string, message?: string }; + insertedAt?: Date; } export class Participant implements IParticipant { @@ -68,6 +70,7 @@ export class Participant implements IParticipant { actor!: IActor; role: ParticipantRole = ParticipantRole.NOT_APPROVED; metadata = {}; + insertedAt?: Date; constructor(hash?: IParticipant) { if (!hash) return; @@ -77,6 +80,7 @@ export class Participant implements IParticipant { this.actor = new Actor(hash.actor); this.role = hash.role; this.metadata = hash.metadata; + this.insertedAt = hash.insertedAt; } } @@ -132,7 +136,7 @@ export interface IEvent { organizerActor?: IActor; attributedTo: IActor; participantStats: IEventParticipantStats; - participants: IParticipant[]; + participants: Paginate<IParticipant>; relatedEvents: IEvent[]; comments: IComment[]; @@ -205,7 +209,7 @@ export class EventModel implements IEvent { publishAt = new Date(); participantStats = { notApproved: 0, notConfirmed: 0, rejected: 0, participant: 0, moderator: 0, administrator: 0, creator: 0, going: 0 }; - participants: IParticipant[] = []; + participants!: Paginate<IParticipant>; relatedEvents: IEvent[] = []; comments: IComment[] = []; diff --git a/js/src/utils/asyncForEach.ts b/js/src/utils/asyncForEach.ts new file mode 100644 index 000000000..3e68a5b18 --- /dev/null +++ b/js/src/utils/asyncForEach.ts @@ -0,0 +1,7 @@ +async function asyncForEach(array, callback) { + for (let index = 0; index < array.length; index += 1) { + await callback(array[index], index, array); + } +} + +export { asyncForEach }; diff --git a/js/src/views/Event/Event.vue b/js/src/views/Event/Event.vue index fc08dfb5a..128121768 100644 --- a/js/src/views/Event/Event.vue +++ b/js/src/views/Event/Event.vue @@ -45,6 +45,7 @@ :current-actor="currentActor" @joinEvent="joinEvent" @joinModal="isJoinModalActive = true" + @joinEventWithConfirmation="joinEventWithConfirmation" @confirmLeave="confirmLeave" /> <b-button type="is-text" v-if="anonymousParticipation !== null" @click="cancelAnonymousParticipation">{{ $t('Cancel anonymous participation')}}</b-button> @@ -263,13 +264,44 @@ <button class="button is-primary" ref="confirmButton" - @click="joinEvent(identity)"> + @click="event.joinOptions === EventJoinOptions.RESTRICTED ? joinEventWithConfirmation(identity) : joinEvent(identity)"> {{ $t('Confirm my particpation') }} </button> </footer> </template> </identity-picker> </b-modal> + <b-modal :active.sync="isJoinConfirmationModalActive" has-modal-card ref="joinConfirmationModal"> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title">{{ $t('Participation confirmation')}}</p> + </header> + + <section class="modal-card-body"> + <p>{{ $t('The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?') }}</p> + <form @submit.prevent="joinEvent(actorForConfirmation, messageForConfirmation)"> + <b-field :label="$t('Message')"> + <b-input + type="textarea" + size="is-medium" + v-model="messageForConfirmation" + minlength="10"> + </b-input> + </b-field> + <div class="buttons"> + <b-button + native-type="button" + class="button" + ref="cancelButton" + @click="isJoinConfirmationModalActive = false"> + {{ $t('Cancel') }} + </b-button> + <b-button type="is-primary" native-type="submit">{{ $t('Confirm my participation') }}</b-button> + </div> + </form> + </section> + </div> + </b-modal> </div> </transition> </div> @@ -281,11 +313,10 @@ import { EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED, FETCH_EVENT, JOIN_EVENT, - LEAVE_EVENT, } from '@/graphql/event'; import { Component, Prop, Watch } from 'vue-property-decorator'; import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor'; -import { EventModel, EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model'; +import { EventModel, EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole, EventJoinOptions } from '@/types/event.model'; import { IPerson, Person } from '@/types/actor'; import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint'; import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue'; @@ -398,12 +429,16 @@ export default class Event extends EventMixin { showMap: boolean = false; isReportModalActive: boolean = false; isJoinModalActive: boolean = false; + isJoinConfirmationModalActive: boolean = false; EventVisibility = EventVisibility; EventStatus = EventStatus; + EventJoinOptions = EventJoinOptions; RouteName = RouteName; observer!: IntersectionObserver; loadComments: boolean = false; anonymousParticipation: boolean|null = null; + actorForConfirmation!: IPerson; + messageForConfirmation: string = ''; get eventTitle() { if (!this.event) return undefined; @@ -506,7 +541,13 @@ export default class Event extends EventMixin { } } - async joinEvent(identity: IPerson) { + joinEventWithConfirmation(actor: IPerson) { + this.isJoinConfirmationModalActive = true; + this.actorForConfirmation = actor; + } + + async joinEvent(identity: IPerson, message: string|null = null) { + this.isJoinConfirmationModalActive = false; this.isJoinModalActive = false; try { const { data } = await this.$apollo.mutate<{ joinEvent: IParticipant }>({ @@ -514,6 +555,7 @@ export default class Event extends EventMixin { variables: { eventId: this.event.id, actorId: identity.id, + message, }, update: (store, { data }) => { if (data == null) return; diff --git a/js/src/views/Event/Participants.vue b/js/src/views/Event/Participants.vue index 60cf05482..acd3148a2 100644 --- a/js/src/views/Event/Participants.vue +++ b/js/src/views/Event/Participants.vue @@ -1,3 +1,4 @@ +import {ParticipantRole} from "@/types/event.model"; <template> <main class="container"> <b-tabs type="is-boxed" v-if="event" v-model="activeTab"> @@ -7,22 +8,17 @@ <span>{{ $t('Participants')}} <b-tag rounded> {{ participantStats.going }} </b-tag> </span> </template> <template> - <section v-if="participantsAndCreators.length > 0"> + <section v-if="participants && participants.total > 0"> <h2 class="title">{{ $t('Participants') }}</h2> - <p v-if="confirmedAnonymousParticipantsCountCount > 1"> - {{ $tc('And no anonymous participations|And one anonymous participation|And {count} anonymous participations', confirmedAnonymousParticipantsCountCount, { count: confirmedAnonymousParticipantsCountCount}) }} - </p> - <div class="columns is-multiline"> - <div class="column is-one-quarter-desktop" v-for="participant in participantsAndCreators" :key="participant.actor.id"> - <participant-card - v-if="participant.actor.id !== config.anonymous.actorId" - :participant="participant" - :accept="acceptParticipant" - :reject="refuseParticipant" - :exclude="refuseParticipant" - /> - </div> - </div> + <ParticipationTable + :data="participants.elements" + :accept-participant="acceptParticipant" + :refuse-participant="refuseParticipant" + :showRole="true" + :total="participants.total" + :perPage="PARTICIPANTS_PER_PAGE" + @page-change="(page) => participantPage = page" + /> </section> </template> </b-tab-item> @@ -32,18 +28,16 @@ <span>{{ $t('Requests') }} <b-tag rounded> {{ participantStats.notApproved }} </b-tag> </span> </template> <template> - <section v-if="queue.length > 0"> + <section v-if="queue && queue.total > 0"> <h2 class="title">{{ $t('Waiting list') }}</h2> - <div class="columns"> - <div class="column is-one-quarter-desktop" v-for="participant in queue" :key="participant.actor.id"> - <participant-card - :participant="participant" - :accept="acceptParticipant" - :reject="refuseParticipant" - :exclude="refuseParticipant" - /> - </div> - </div> + <ParticipationTable + :data="queue.elements" + :accept-participant="acceptParticipant" + :refuse-participant="refuseParticipant" + :total="queue.total" + :perPage="PARTICIPANTS_PER_PAGE" + @page-change="(page) => queuePage = page" + /> </section> </template> </b-tab-item> @@ -53,18 +47,16 @@ <span>{{ $t('Rejected')}} <b-tag rounded> {{ participantStats.rejected }} </b-tag> </span> </template> <template> - <section v-if="rejected.length > 0"> + <section v-if="rejected && rejected.total > 0"> <h2 class="title">{{ $t('Rejected participations') }}</h2> - <div class="columns"> - <div class="column is-one-quarter-desktop" v-for="participant in rejected" :key="participant.actor.id"> - <participant-card - :participant="participant" - :accept="acceptParticipant" - :reject="refuseParticipant" - :exclude="refuseParticipant" - /> - </div> - </div> + <ParticipationTable + :data="rejected.elements" + :accept-participant="acceptParticipant" + :refuse-participant="refuseParticipant" + :total="rejected.total" + :perPage="PARTICIPANTS_PER_PAGE" + @page-change="(page) => rejectedPage = page" + /> </section> </template> </b-tab-item> @@ -81,9 +73,15 @@ import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor'; import { IPerson } from '@/types/actor'; import { CONFIG } from '@/graphql/config'; import { IConfig } from '@/types/config.model'; +import ParticipationTable from '@/components/Event/ParticipationTable.vue'; +import { Paginate } from '@/types/paginate'; + +const PARTICIPANTS_PER_PAGE = 20; +const MESSAGE_ELLIPSIS_LENGTH = 130; @Component({ components: { + ParticipationTable, ParticipantCard, }, apollo: { @@ -97,7 +95,7 @@ import { IConfig } from '@/types/config.model'; return { uuid: this.eventId, page: 1, - limit: 10, + limit: PARTICIPANTS_PER_PAGE, roles: [ParticipantRole.PARTICIPANT].join(), actorId: this.currentActor.id, }; @@ -106,18 +104,18 @@ import { IConfig } from '@/types/config.model'; return !this.currentActor.id; }, }, - organizers: { + participants: { query: PARTICIPANTS, variables() { return { uuid: this.eventId, - page: 1, - limit: 20, - roles: [ParticipantRole.CREATOR].join(), + page: this.participantPage, + limit: PARTICIPANTS_PER_PAGE, + roles: [ParticipantRole.CREATOR, ParticipantRole.PARTICIPANT].join(), actorId: this.currentActor.id, }; }, - update: data => data.event.participants.map(participation => new Participant(participation)), + update(data) { return this.dataTransform(data); }, skip() { return !this.currentActor.id; }, @@ -127,13 +125,13 @@ import { IConfig } from '@/types/config.model'; variables() { return { uuid: this.eventId, - page: 1, - limit: 20, + page: this.queuePage, + limit: PARTICIPANTS_PER_PAGE, roles: [ParticipantRole.NOT_APPROVED].join(), actorId: this.currentActor.id, }; }, - update: data => data.event.participants.map(participation => new Participant(participation)), + update(data) { return this.dataTransform(data); }, skip() { return !this.currentActor.id; }, @@ -143,27 +141,36 @@ import { IConfig } from '@/types/config.model'; variables() { return { uuid: this.eventId, - page: 1, - limit: 20, + page: this.rejectedPage, + limit: PARTICIPANTS_PER_PAGE, roles: [ParticipantRole.REJECTED].join(), actorId: this.currentActor.id, }; }, - update: data => data.event.participants.map(participation => new Participant(participation)), + update(data) { return this.dataTransform(data); }, skip() { return !this.currentActor.id; }, }, }, + filters: { + ellipsize: (text?: string) => text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat('…'), + }, }) export default class Participants extends Vue { @Prop({ required: true }) eventId!: string; page: number = 1; limit: number = 10; - organizers: IParticipant[] = []; - queue: IParticipant[] = []; - rejected: IParticipant[] = []; + participants!: Paginate<IParticipant>; + participantPage: number = 1; + + queue!: Paginate<IParticipant>; + queuePage: number = 1; + + rejected!: Paginate<IParticipant>; + rejectedPage: number = 1; + event!: IEvent; config!: IConfig; @@ -173,23 +180,20 @@ export default class Participants extends Vue { hasMoreParticipants: boolean = false; activeTab: number = 0; + PARTICIPANTS_PER_PAGE = PARTICIPANTS_PER_PAGE; + + dataTransform(data): Paginate<Participant> { + return { + total: data.event.participants.total, + elements: data.event.participants.elements.map(participation => new Participant(participation)), + }; + } + get participantStats(): IEventParticipantStats | null { if (!this.event) return null; return this.event.participantStats; } - get participantsAndCreators(): IParticipant[] { - if (this.event) { - return [...this.organizers, ...this.event.participants] - .filter(participant => [ParticipantRole.PARTICIPANT, ParticipantRole.CREATOR].includes(participant.role)); - } - return []; - } - - get confirmedAnonymousParticipantsCountCount(): number { - return this.participantsAndCreators.filter(({ actor: { id } }) => id === this.config.anonymous.actorId).length; - } - @Watch('participantStats', { deep: true }) watchParticipantStats(stats: IEventParticipantStats) { if (!stats) return; @@ -232,8 +236,8 @@ export default class Participants extends Vue { }, }); if (data) { - this.queue = this.queue.filter(participant => participant.id !== data.updateParticipation.id); - this.rejected = this.rejected.filter(participant => participant.id !== data.updateParticipation.id); + this.queue.elements = this.queue.elements.filter(participant => participant.id !== data.updateParticipation.id); + this.rejected.elements = this.rejected.elements.filter(participant => participant.id !== data.updateParticipation.id); this.event.participantStats.going += 1; if (participant.role === ParticipantRole.NOT_APPROVED) { this.event.participantStats.notApproved -= 1; @@ -242,7 +246,7 @@ export default class Participants extends Vue { this.event.participantStats.rejected -= 1; } participant.role = ParticipantRole.PARTICIPANT; - this.event.participants.push(participant); + this.event.participants.elements.push(participant); } } catch (e) { console.error(e); @@ -260,8 +264,10 @@ export default class Participants extends Vue { }, }); if (data) { - this.event.participants = this.event.participants.filter(participant => participant.id !== data.updateParticipation.id); - this.queue = this.queue.filter(participant => participant.id !== data.updateParticipation.id); + this.event.participants.elements = this.event.participants.elements.filter( + participant => participant.id !== data.updateParticipation.id, + ); + this.queue.elements = this.queue.elements.filter(participant => participant.id !== data.updateParticipation.id); this.event.participantStats.rejected += 1; if (participant.role === ParticipantRole.PARTICIPANT) { this.event.participantStats.participant -= 1; @@ -271,8 +277,8 @@ export default class Participants extends Vue { this.event.participantStats.notApproved -= 1; } participant.role = ParticipantRole.REJECTED; - this.rejected = this.rejected.filter(participantIn => participantIn.id !== participant.id); - this.rejected.push(participant); + this.rejected.elements = this.rejected.elements.filter(participantIn => participantIn.id !== participant.id); + this.rejected.elements.push(participant); } } catch (e) { console.error(e); diff --git a/lib/federation/activity_pub/activity_pub.ex b/lib/federation/activity_pub/activity_pub.ex index 697d278f8..5f5b1cce4 100644 --- a/lib/federation/activity_pub/activity_pub.ex +++ b/lib/federation/activity_pub/activity_pub.ex @@ -439,7 +439,10 @@ defmodule Mobilizon.Federation.ActivityPub do event_id: event.id, actor_id: actor.id, url: Map.get(additional, :url), - metadata: Map.get(additional, :metadata) + metadata: + additional + |> Map.get(:metadata, %{}) + |> Map.update(:message, nil, &String.trim(HtmlSanitizeEx.strip_tags(&1))) }), join_data <- Convertible.model_to_as(participant), audience <- diff --git a/lib/federation/activity_pub/transmogrifier.ex b/lib/federation/activity_pub/transmogrifier.ex index 0efbd73e2..4ba30cab2 100644 --- a/lib/federation/activity_pub/transmogrifier.ex +++ b/lib/federation/activity_pub/transmogrifier.ex @@ -306,13 +306,20 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do end def handle_incoming( - %{"type" => "Join", "object" => object, "actor" => _actor, "id" => id} = data + %{ + "type" => "Join", + "object" => object, + "actor" => _actor, + "id" => id, + "participationMessage" => note + } = data ) do with actor <- Utils.get_actor(data), {:ok, %Actor{url: _actor_url} = actor} <- Actors.get_actor_by_url(actor), object <- Utils.get_url(object), {:ok, object} <- ActivityPub.fetch_object_from_url(object), - {:ok, activity, object} <- ActivityPub.join(object, actor, false, %{url: id}) do + {:ok, activity, object} <- + ActivityPub.join(object, actor, false, %{url: id, metadata: %{message: note}}) do {:ok, activity, object} else e -> diff --git a/lib/federation/activity_pub/utils.ex b/lib/federation/activity_pub/utils.ex index 0ae9fc459..8b21626ed 100644 --- a/lib/federation/activity_pub/utils.ex +++ b/lib/federation/activity_pub/utils.ex @@ -77,6 +77,10 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do "anonymousParticipationEnabled" => %{ "@id" => "mz:anonymousParticipationEnabled", "@type" => "sc:Boolean" + }, + "participationMessage" => %{ + "@id" => "mz:participationMessage", + "@type" => "sc:Text" } } ] diff --git a/lib/federation/activity_stream/converter/participant.ex b/lib/federation/activity_stream/converter/participant.ex index 1581b6547..a1e21824f 100644 --- a/lib/federation/activity_stream/converter/participant.ex +++ b/lib/federation/activity_stream/converter/participant.ex @@ -25,7 +25,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Participant do "type" => "Join", "id" => participant.url, "actor" => participant.actor.url, - "object" => participant.event.url + "object" => participant.event.url, + "participationMessage" => Map.get(participant.metadata, :message) } end end diff --git a/lib/graphql/resolvers/event.ex b/lib/graphql/resolvers/event.ex index ee519a51f..12b1eb4aa 100644 --- a/lib/graphql/resolvers/event.ex +++ b/lib/graphql/resolvers/event.ex @@ -102,7 +102,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do end def list_participants_for_event(_, _args, _resolution) do - {:ok, []} + {:ok, %{total: 0, elements: []}} end def stats_participants_going(%EventParticipantStats{} = stats, _args, _resolution) do diff --git a/lib/graphql/resolvers/participant.ex b/lib/graphql/resolvers/participant.ex index f06b74239..16294c7c8 100644 --- a/lib/graphql/resolvers/participant.ex +++ b/lib/graphql/resolvers/participant.ex @@ -17,12 +17,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do """ def actor_join_event( _parent, - %{actor_id: actor_id, event_id: event_id}, + %{actor_id: actor_id, event_id: event_id} = args, %{context: %{current_user: %User{} = user}} ) do case User.owns_actor(user, actor_id) do {:is_owned, %Actor{} = actor} -> - do_actor_join_event(actor, event_id) + do_actor_join_event(actor, event_id, args) _ -> {:error, "Actor id is not owned by authenticated user"} @@ -136,7 +136,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do _parent, %{actor_id: actor_id, event_id: event_id, token: token}, _resolution - ) do + ) + when not is_nil(token) do with {:anonymous_participation_enabled, true} <- {:anonymous_participation_enabled, Config.anonymous_participation?()}, {:anonymous_actor_id, true} <- diff --git a/lib/graphql/schema/event.ex b/lib/graphql/schema/event.ex index 7f0acfe2c..d285fa9ec 100644 --- a/lib/graphql/schema/event.ex +++ b/lib/graphql/schema/event.ex @@ -65,7 +65,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do field(:participant_stats, :participant_stats) - field(:participants, list_of(:participant), description: "The event's participants") do + field(:participants, :paginated_participant_list, description: "The event's participants") do arg(:page, :integer, default_value: 1) arg(:limit, :integer, default_value: 10) arg(:roles, :string, default_value: "") diff --git a/lib/graphql/schema/events/participant.ex b/lib/graphql/schema/events/participant.ex index bf7ded106..067c637af 100644 --- a/lib/graphql/schema/events/participant.ex +++ b/lib/graphql/schema/events/participant.ex @@ -33,12 +33,21 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do field(:metadata, :participant_metadata, description: "The metadata associated to this participant" ) + + field(:inserted_at, :datetime, description: "The datetime this participant was created") end object :participant_metadata do field(:cancellation_token, :string, description: "The eventual token to leave an event when user is anonymous" ) + + field(:message, :string, description: "The eventual message the participant left") + end + + object :paginated_participant_list do + field(:elements, list_of(:participant), description: "A list of participants") + field(:total, :integer, description: "The total number of participants in the list") end enum :participant_role_enum do @@ -64,6 +73,7 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do arg(:event_id, non_null(:id)) arg(:actor_id, non_null(:id)) arg(:email, :string) + arg(:message, :string) resolve(&Participant.actor_join_event/3) end diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex index 6b25de1fc..c97a0f00c 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -759,7 +759,7 @@ defmodule Mobilizon.Events do Default behaviour is to not return :not_approved or :not_confirmed participants """ @spec list_participants_for_event(String.t(), list(atom()), integer | nil, integer | nil) :: - [Participant.t()] + Page.t() def list_participants_for_event( id, roles \\ @default_participant_roles, @@ -769,8 +769,7 @@ defmodule Mobilizon.Events do id |> list_participants_for_event_query() |> filter_role(roles) - |> Page.paginate(page, limit) - |> Repo.all() + |> Page.build_page(page, limit) end @spec list_actors_participants_for_event(String.t()) :: [Actor.t()] diff --git a/lib/mobilizon/events/participant.ex b/lib/mobilizon/events/participant.ex index f246b1db0..5a309757c 100644 --- a/lib/mobilizon/events/participant.ex +++ b/lib/mobilizon/events/participant.ex @@ -18,11 +18,15 @@ defmodule Mobilizon.Events.Participant do role: ParticipantRole.t(), url: String.t(), event: Event.t(), - actor: Actor.t() + actor: Actor.t(), + metadata: Map.t() } @required_attrs [:url, :role, :event_id, :actor_id] @attrs @required_attrs + @metadata_attrs [:email, :confirmation_token, :cancellation_token, :message] + + @timestamps_opts [type: :utc_datetime] @primary_key {:id, :binary_id, autogenerate: true} schema "participants" do @@ -33,6 +37,7 @@ defmodule Mobilizon.Events.Participant do field(:email, :string) field(:confirmation_token, :string) field(:cancellation_token, :string) + field(:message, :string) end belongs_to(:event, Event, primary_key: true) @@ -70,7 +75,7 @@ defmodule Mobilizon.Events.Participant do defp metadata_changeset(schema, params) do schema - |> cast(params, [:email, :confirmation_token, :cancellation_token]) + |> cast(params, @metadata_attrs) |> Checker.validate_changeset() end diff --git a/schema.graphql b/schema.graphql index 33ff3d2e8..ae9f71377 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,5 +1,5 @@ # source: http://localhost:4000/api -# timestamp: Thu Feb 13 2020 11:32:20 GMT+0100 (GMT+01:00) +# timestamp: Wed Mar 04 2020 10:26:53 GMT+0100 (GMT+01:00) schema { query: RootQueryType @@ -839,6 +839,9 @@ type Participant { """The participation ID""" id: ID + """The datetime this participant was created""" + insertedAt: DateTime + """The metadata associated to this participant""" metadata: ParticipantMetadata @@ -849,6 +852,9 @@ type Participant { type ParticipantMetadata { """The eventual token to leave an event when user is anonymous""" cancellationToken: String + + """The eventual message the participant left""" + message: String } enum ParticipantRoleEnum { @@ -1082,203 +1088,9 @@ enum ReportStatus { } type RootMutationType { - saveAdminSettings(instanceDescription: String, instanceName: String, instanceTerms: String, instanceTermsType: InstanceTermsType, instanceTermsUrl: String, registrationsOpen: Boolean): AdminSettings - changeEmail(email: String!, password: String!): User - - """Create a comment""" - createComment(actorId: ID!, eventId: ID, inReplyToCommentId: ID, text: String!): Comment - - """Create an user""" - createUser(email: String!, locale: String, password: String!): User - - """Update an identity""" - updatePerson( - """ - The avatar for the profile, either as an object or directly the ID of an existing Picture - """ - avatar: PictureInput - - """ - The banner for the profile, either as an object or directly the ID of an existing Picture - """ - banner: PictureInput - id: ID! - - """The displayed name for this profile""" - name: String - - """The summary for this profile""" - summary: String - ): Person - - """Create an event""" - createEvent( - beginsOn: DateTime! - category: String = "meeting" - description: String! - draft: Boolean = false - endsOn: DateTime - joinOptions: EventJoinOptions = FREE - onlineAddress: String - options: EventOptionsInput - organizerActorId: ID! - phoneAddress: String - physicalAddress: AddressInput - - """ - The picture for the event, either as an object or directly the ID of an existing Picture - """ - picture: PictureInput - publishAt: DateTime - status: EventStatus - - """The list of tags associated to the event""" - tags: [String] = [""] - title: String! - visibility: EventVisibility = PUBLIC - ): Event - validateEmail(token: String!): User - - """Delete an event""" - deleteEvent(actorId: ID!, eventId: ID!): DeletedObject - - """Accept a participation""" - updateParticipation(id: ID!, moderatorActorId: ID!, role: ParticipantRoleEnum!): Participant - - """Leave an event""" - leaveEvent(actorId: ID!, eventId: ID!, token: String): DeletedParticipant - - """Delete an identity""" - deletePerson(id: ID!): Person - - """Refresh a token""" - refreshToken(refreshToken: String!): RefreshedToken - - """Validate an user after registration""" - validateUser(token: String!): Login - - """Upload a picture""" - uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture - - """Delete a feed token""" - deleteFeedToken(token: String!): DeletedFeedToken - - """Create a note on a report""" - createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote - - """Leave an event""" - leaveGroup(actorId: ID!, groupId: ID!): DeletedMember - - """Create a Feed Token""" - createFeedToken(actorId: ID): FeedToken - """Send a link through email to reset user password""" sendResetPassword(email: String!, locale: String): String - """Delete a relay subscription""" - removeRelay(address: String!): Follower - - """Change default actor for user""" - changeDefaultActor(preferredUsername: String!): User - deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject - - """Create a report""" - createReport(commentsIds: [ID] = [""], content: String, eventId: ID, forward: Boolean = false, reportedId: ID!, reporterId: ID!): Report - - """Register a first profile on registration""" - registerPerson( - """ - The avatar for the profile, either as an object or directly the ID of an existing Picture - """ - avatar: PictureInput - - """ - The banner for the profile, either as an object or directly the ID of an existing Picture - """ - banner: PictureInput - - """The email from the user previously created""" - email: String! - - """The displayed name for the new profile""" - name: String = "" - preferredUsername: String! - - """The summary for the new profile""" - summary: String = "" - ): Person - - """Delete a group""" - deleteGroup(actorId: ID!, groupId: ID!): DeletedObject - deleteAccount(password: String!): DeletedObject - - """Add a relay subscription""" - addRelay(address: String!): Follower - - """Reset user password""" - resetPassword(locale: String = "en", password: String!, token: String!): Login - - """Create a group""" - createGroup( - """ - The avatar for the group, either as an object or directly the ID of an existing Picture - """ - avatar: PictureInput - - """ - The banner for the group, either as an object or directly the ID of an existing Picture - """ - banner: PictureInput - - """The identity that creates the group""" - creatorActorId: ID! - - """The displayed name for the group""" - name: String - - """The name for the group""" - preferredUsername: String! - - """The summary for the group""" - summary: String = "" - ): Group - - """Confirm a participation""" - confirmParticipation(confirmationToken: String!): Participant - deleteComment(actorId: ID!, commentId: ID!): Comment - - """Join an event""" - joinEvent(actorId: ID!, email: String, eventId: ID!): Participant - - """Accept a relay subscription""" - acceptRelay(address: String!): Follower - - """Join a group""" - joinGroup(actorId: ID!, groupId: ID!): Member - - """Reject a relay subscription""" - rejectRelay(address: String!): Follower - - """Create a new person for user""" - createPerson( - """ - The avatar for the profile, either as an object or directly the ID of an existing Picture - """ - avatar: PictureInput - - """ - The banner for the profile, either as an object or directly the ID of an existing Picture - """ - banner: PictureInput - - """The displayed name for the new profile""" - name: String = "" - preferredUsername: String! - - """The summary for the new profile""" - summary: String = "" - ): Person - """Update an event""" updateEvent( beginsOn: DateTime @@ -1305,18 +1117,211 @@ type RootMutationType { title: String visibility: EventVisibility = PUBLIC ): Event + validateEmail(token: String!): User + + """Change default actor for user""" + changeDefaultActor(preferredUsername: String!): User + + """Leave an event""" + leaveEvent(actorId: ID!, eventId: ID!, token: String): DeletedParticipant + deleteAccount(password: String!): DeletedObject + + """Delete a group""" + deleteGroup(actorId: ID!, groupId: ID!): DeletedObject + + """Create an user""" + createUser(email: String!, locale: String, password: String!): User + + """Add a relay subscription""" + addRelay(address: String!): Follower + + """Delete an identity""" + deletePerson(id: ID!): Person + + """Accept a relay subscription""" + acceptRelay(address: String!): Follower + + """Refresh a token""" + refreshToken(refreshToken: String!): RefreshedToken + + """Update an identity""" + updatePerson( + """ + The avatar for the profile, either as an object or directly the ID of an existing Picture + """ + avatar: PictureInput + + """ + The banner for the profile, either as an object or directly the ID of an existing Picture + """ + banner: PictureInput + id: ID! + + """The displayed name for this profile""" + name: String + + """The summary for this profile""" + summary: String + ): Person + + """Create a report""" + createReport(commentsIds: [ID] = [""], content: String, eventId: ID, forward: Boolean = false, reportedId: ID!, reporterId: ID!): Report + + """Delete a feed token""" + deleteFeedToken(token: String!): DeletedFeedToken + + """Reset user password""" + resetPassword(locale: String = "en", password: String!, token: String!): Login + + """Login an user""" + login(email: String!, password: String!): Login + + """Leave an event""" + leaveGroup(actorId: ID!, groupId: ID!): DeletedMember + + """Register a first profile on registration""" + registerPerson( + """ + The avatar for the profile, either as an object or directly the ID of an existing Picture + """ + avatar: PictureInput + + """ + The banner for the profile, either as an object or directly the ID of an existing Picture + """ + banner: PictureInput + + """The email from the user previously created""" + email: String! + + """The displayed name for the new profile""" + name: String = "" + preferredUsername: String! + + """The summary for the new profile""" + summary: String = "" + ): Person + + """Delete a relay subscription""" + removeRelay(address: String!): Follower """Change an user password""" changePassword(newPassword: String!, oldPassword: String!): User - """Update a report""" - updateReportStatus(moderatorId: ID!, reportId: ID!, status: ReportStatus!): Report - """Resend registration confirmation token""" resendConfirmationEmail(email: String!, locale: String): String - """Login an user""" - login(email: String!, password: String!): Login + """Confirm a participation""" + confirmParticipation(confirmationToken: String!): Participant + + """Delete an event""" + deleteEvent(actorId: ID!, eventId: ID!): DeletedObject + + """Update a report""" + updateReportStatus(moderatorId: ID!, reportId: ID!, status: ReportStatus!): Report + + """Create a group""" + createGroup( + """ + The avatar for the group, either as an object or directly the ID of an existing Picture + """ + avatar: PictureInput + + """ + The banner for the group, either as an object or directly the ID of an existing Picture + """ + banner: PictureInput + + """The identity that creates the group""" + creatorActorId: ID! + + """The displayed name for the group""" + name: String + + """The name for the group""" + preferredUsername: String! + + """The summary for the group""" + summary: String = "" + ): Group + + """Validate an user after registration""" + validateUser(token: String!): Login + + """Join an event""" + joinEvent(actorId: ID!, email: String, eventId: ID!, message: String): Participant + deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject + deleteComment(actorId: ID!, commentId: ID!): Comment + + """Reject a relay subscription""" + rejectRelay(address: String!): Follower + + """Create a comment""" + createComment(actorId: ID!, eventId: ID, inReplyToCommentId: ID, text: String!): Comment + + """Create a note on a report""" + createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote + + """Accept a participation""" + updateParticipation(id: ID!, moderatorActorId: ID!, role: ParticipantRoleEnum!): Participant + + """Create a Feed Token""" + createFeedToken(actorId: ID): FeedToken + + """Join a group""" + joinGroup(actorId: ID!, groupId: ID!): Member + + """Create a new person for user""" + createPerson( + """ + The avatar for the profile, either as an object or directly the ID of an existing Picture + """ + avatar: PictureInput + + """ + The banner for the profile, either as an object or directly the ID of an existing Picture + """ + banner: PictureInput + + """The displayed name for the new profile""" + name: String = "" + preferredUsername: String! + + """The summary for the new profile""" + summary: String = "" + ): Person + + """Upload a picture""" + uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture + + """Create an event""" + createEvent( + beginsOn: DateTime! + category: String = "meeting" + description: String! + draft: Boolean = false + endsOn: DateTime + joinOptions: EventJoinOptions = FREE + onlineAddress: String + options: EventOptionsInput + organizerActorId: ID! + phoneAddress: String + physicalAddress: AddressInput + + """ + The picture for the event, either as an object or directly the ID of an existing Picture + """ + picture: PictureInput + publishAt: DateTime + status: EventStatus + + """The list of tags associated to the event""" + tags: [String] = [""] + title: String! + visibility: EventVisibility = PUBLIC + ): Event + saveAdminSettings(instanceDescription: String, instanceName: String, instanceTerms: String, instanceTermsType: InstanceTermsType, instanceTermsUrl: String, registrationsOpen: Boolean): AdminSettings + changeEmail(email: String!, password: String!): User } """ diff --git a/test/federation/activity_pub/transmogrifier_test.exs b/test/federation/activity_pub/transmogrifier_test.exs index 08c1fae40..9d5127dd2 100644 --- a/test/federation/activity_pub/transmogrifier_test.exs +++ b/test/federation/activity_pub/transmogrifier_test.exs @@ -818,6 +818,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do assert activity.data["cc"] == [] end + @join_message "I want to get in!" test "it accepts Join activities" do %Actor{url: organizer_url} = organizer = insert(:actor) %Actor{url: participant_url} = _participant = insert(:actor) @@ -829,13 +830,19 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do |> Jason.decode!() |> Map.put("actor", participant_url) |> Map.put("object", event_url) + |> Map.put("participationMessage", @join_message) - assert {:ok, activity, _} = Transmogrifier.handle_incoming(join_data) + assert {:ok, activity, %Participant{} = participant} = + Transmogrifier.handle_incoming(join_data) + + assert participant.metadata.message == @join_message + assert participant.role == :participant assert activity.data["type"] == "Accept" assert activity.data["object"]["object"] == event_url assert activity.data["object"]["id"] =~ "/join/event/" assert activity.data["object"]["type"] =~ "Join" + assert activity.data["object"]["participationMessage"] == @join_message assert activity.data["actor"] == organizer_url assert activity.data["id"] =~ "/accept/join/" end @@ -894,6 +901,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do # Organiser is not present since we use factories directly assert event.id |> Events.list_participants_for_event() + |> Map.get(:elements) |> Enum.map(& &1.id) == [] end @@ -924,6 +932,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do # The only participant left is the organizer assert event.id |> Events.list_participants_for_event() + |> Map.get(:elements) |> Enum.map(& &1.id) == [organizer_participation.id] end diff --git a/test/graphql/resolvers/participant_test.exs b/test/graphql/resolvers/participant_test.exs index 4a4e35310..27607c818 100644 --- a/test/graphql/resolvers/participant_test.exs +++ b/test/graphql/resolvers/participant_test.exs @@ -7,6 +7,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do alias Mobilizon.Events alias Mobilizon.Events.{Event, EventParticipantStats, Participant} alias Mobilizon.GraphQL.AbsintheHelpers + alias Mobilizon.Storage.Page alias Mobilizon.Web.Email import Mobilizon.Factory @@ -446,9 +447,11 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do participants(roles: "participant,moderator,administrator,creator", actor_id: "#{ actor.id }") { - role, - actor { - preferredUsername + elements { + role, + actor { + preferredUsername + } } } } @@ -462,7 +465,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do assert json_response(res, 200)["errors"] == nil - assert json_response(res, 200)["data"]["event"]["participants"] == [ + assert json_response(res, 200)["data"]["event"]["participants"]["elements"] == [ %{ "actor" => %{ "preferredUsername" => actor.preferred_username @@ -485,9 +488,11 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do participants(page: 1, limit: 1, roles: "participant,moderator,administrator,creator", actorId: "#{ actor.id }") { - role, - actor { - preferredUsername + elements { + role, + actor { + preferredUsername + } } } } @@ -500,7 +505,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do |> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) sorted_participants = - json_response(res, 200)["data"]["event"]["participants"] + json_response(res, 200)["data"]["event"]["participants"]["elements"] |> Enum.filter(&(&1["role"] == "PARTICIPANT")) assert sorted_participants == [ @@ -518,9 +523,11 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do participants(page: 2, limit: 1, roles: "participant,moderator,administrator,creator", actorId: "#{ actor.id }") { - role, - actor { - preferredUsername + elements { + role, + actor { + preferredUsername + } } } } @@ -533,7 +540,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do |> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) sorted_participants = - json_response(res, 200)["data"]["event"]["participants"] + json_response(res, 200)["data"]["event"]["participants"]["elements"] |> Enum.sort_by( &(&1 |> Map.get("actor") @@ -1053,7 +1060,8 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do assert res["data"]["joinEvent"]["event"]["id"] == to_string(event.id) assert res["data"]["joinEvent"]["actor"]["id"] == to_string(actor_id) - %Participant{} = participant = event.id |> Events.list_participants_for_event() |> hd + %Participant{} = + participant = event.id |> Events.list_participants_for_event() |> Map.get(:elements) |> hd assert participant.metadata.email == @email @@ -1093,7 +1101,9 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do assert %Participant{ metadata: %{confirmation_token: confirmation_token}, role: :not_confirmed - } = participant = event.id |> Events.list_participants_for_event([]) |> hd() + } = + participant = + event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd() # hack to avoid preloading event in participant participant = Map.put(participant, :event, event) @@ -1118,7 +1128,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do ) assert %Participant{role: :participant} = - event.id |> Events.list_participants_for_event() |> hd() + event.id |> Events.list_participants_for_event() |> Map.get(:elements) |> hd() end test "I can participate anonymously and and confirm my participation with bad token", @@ -1140,7 +1150,9 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do assert res["data"]["joinEvent"]["event"]["id"] == to_string(event.id) assert res["data"]["joinEvent"]["actor"]["id"] == to_string(actor_id) - %Participant{} = participant = event.id |> Events.list_participants_for_event([]) |> hd + %Participant{} = + participant = + event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd assert participant.metadata.email == @email @@ -1157,7 +1169,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do assert hd(res["errors"])["message"] == "This token is invalid" assert %Participant{role: :not_confirmed} = - event.id |> Events.list_participants_for_event([]) |> hd() + event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd() end test "I can participate anonymously but change my mind and cancel my participation", @@ -1181,7 +1193,9 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do {:ok, %Event{participant_stats: %{not_confirmed: 1}}} = Events.get_event(event.id) - %Participant{} = participant = event.id |> Events.list_participants_for_event([]) |> hd + %Participant{} = + participant = + event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd assert participant.metadata.email == @email @@ -1205,7 +1219,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do id: participant_id, role: :not_confirmed, metadata: %{cancellation_token: cancellation_token} - } = event.id |> Events.list_participants_for_event([]) |> hd() + } = event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd() res = conn @@ -1221,7 +1235,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do assert res["data"]["leaveEvent"]["id"] == participant_id {:ok, %Event{participant_stats: %{not_confirmed: 0}}} = Events.get_event(event.id) - assert Events.list_participants_for_event(event.id, []) == [] + assert Events.list_participants_for_event(event.id, []) == %Page{elements: [], total: 0} end test "I can participate anonymously, confirm my participation and then be confirmed by the organizer", @@ -1274,7 +1288,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do assert %Participant{ role: :not_confirmed, metadata: %{confirmation_token: confirmation_token, email: @email} - } = event.id |> Events.list_participants_for_event([]) |> hd + } = event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd conn |> AbsintheHelpers.graphql_query( @@ -1293,7 +1307,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do }} = Events.get_event(event.id) assert %Participant{role: :not_approved, id: participant_id} = - event.id |> Events.list_participants_for_event([]) |> hd + event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd update_participation_mutation = """ mutation UpdateParticipation($participantId: ID!, $role: String!, $moderatorActorId: ID!) { @@ -1325,7 +1339,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do assert res["errors"] == nil assert %Participant{role: :participant} = - event.id |> Events.list_participants_for_event([]) |> hd + event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd assert {:ok, %Event{ diff --git a/test/mobilizon/events/events_test.exs b/test/mobilizon/events/events_test.exs index 6a34b4720..7dda47589 100644 --- a/test/mobilizon/events/events_test.exs +++ b/test/mobilizon/events/events_test.exs @@ -110,8 +110,11 @@ defmodule Mobilizon.EventsTest do assert event.ends_on == DateTime.from_naive!(~N[2010-04-17 14:00:00Z], "Etc/UTC") assert event.title == "some title" - assert hd(Events.list_participants_for_event(event.id)).actor.id == actor.id - assert hd(Events.list_participants_for_event(event.id)).role == :creator + assert %Participant{} = + participant = hd(Events.list_participants_for_event(event.id).elements) + + assert participant.actor.id == actor.id + assert participant.role == :creator end test "create_event/1 with invalid data returns error changeset" do