Add ability to add message for participation and improve participation

management interface

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-03-05 19:32:34 +01:00
parent 7a5a641a7f
commit acfe75768d
34 changed files with 736 additions and 368 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,6 @@
"Allow all comments": "Autorizar totes los comentaris",
"Allow registrations": "Permetre las inscripcions",
"An error has occurred.": "Una error ses 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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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