Add ability to add message for participation and improve participation

management interface

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
master
Thomas Citharel 3 years ago
parent 130a3cf23f
commit c732ec7f87
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
  1. 4
      CHANGELOG.md
  2. 27
      docs/contribute/activity_pub.md
  3. 1
      js/src/components/Admin/Followers.vue
  4. 12
      js/src/components/Event/ParticipationButton.vue
  5. 164
      js/src/components/Event/ParticipationTable.vue
  6. 6
      js/src/components/NavBar.vue
  7. 34
      js/src/components/Participation/ParticipationWithoutAccount.vue
  8. 42
      js/src/graphql/event.ts
  9. 15
      js/src/i18n/en_US.json
  10. 1
      js/src/i18n/es.json
  11. 1
      js/src/i18n/fi.json
  12. 19
      js/src/i18n/fr_FR.json
  13. 1
      js/src/i18n/oc.json
  14. 1
      js/src/i18n/pt_BR.json
  15. 18
      js/src/plugins/notifier.ts
  16. 2
      js/src/router/event.ts
  17. 10
      js/src/types/event.model.ts
  18. 7
      js/src/utils/asyncForEach.ts
  19. 50
      js/src/views/Event/Event.vue
  20. 148
      js/src/views/Event/Participants.vue
  21. 5
      lib/federation/activity_pub/activity_pub.ex
  22. 11
      lib/federation/activity_pub/transmogrifier.ex
  23. 4
      lib/federation/activity_pub/utils.ex
  24. 3
      lib/federation/activity_stream/converter/participant.ex
  25. 2
      lib/graphql/resolvers/event.ex
  26. 7
      lib/graphql/resolvers/participant.ex
  27. 2
      lib/graphql/schema/event.ex
  28. 10
      lib/graphql/schema/events/participant.ex
  29. 5
      lib/mobilizon/events/events.ex
  30. 9
      lib/mobilizon/events/participant.ex
  31. 213
      schema.graphql
  32. 11
      test/federation/activity_pub/transmogrifier_test.exs
  33. 60
      test/graphql/resolvers/participant_test.exs
  34. 7
      test/mobilizon/events/events_test.exs

@ -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

@ -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"
}
}
]
}
```

@ -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

@ -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() {

@ -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>

@ -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;
}

@ -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;

@ -2,23 +2,28 @@ import gql from 'graphql-tag';
import { COMMENT_FIELDS_FRAGMENT } from '@/graphql/comment';
const participantQuery = `
role,
id,
actor {
preferredUsername,
avatar {
url
},
name,
id,
domain
},
event {
total,
elements {
role,
id,
uuid
},
metadata {
cancellationToken
actor {
preferredUsername,
avatar {
url
},
name,
id,
domain
},
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}
}

@ -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"
}

@ -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",

@ -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",

@ -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"
}

@ -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",

@ -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",

@ -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;
}
}

@ -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 },

@ -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[] = [];

@ -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 };

@ -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;

@ -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,21 +180,18 @@ export default class Participants extends Vue {
hasMoreParticipants: boolean = false;
activeTab: number = 0;
get participantStats(): IEventParticipantStats | null {
if (!this.event) return null;
return this.event.participantStats;
}
PARTICIPANTS_PER_PAGE = PARTICIPANTS_PER_PAGE;
get participantsAndCreators(): IParticipant[] {
if (this.event) {
return [...this.organizers, ...this.event.participants]
.filter(participant => [ParticipantRole.PARTICIPANT, ParticipantRole.CREATOR].includes(participant.role));
}
return [];
dataTransform(data): Paginate<Participant> {
return {
total: data.event.participants.total,
elements: data.event.participants.elements.map(participation => new Participant(participation)),
};
}
get confirmedAnonymousParticipantsCountCount(): number {
return this.participantsAndCreators.filter(({ actor: { id } }) => id === this.config.anonymous.actorId).length;
get participantStats(): IEventParticipantStats | null {
if (!this.event) return null;
return this.event.participantStats;
}
@Watch('participantStats', { deep: true })
@ -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);

@ -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 <-

@ -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 ->

@ -77,6 +77,10 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
"anonymousParticipationEnabled" => %{
"@id" => "mz:anonymousParticipationEnabled",
"@type" => "sc:Boolean"
},
"participationMessage" => %{
"@id" => "mz:participationMessage",
"@type" => "sc:Text"
}
}
]

@ -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

@ -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

@ -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} <-

@ -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: "")