Merge branch 'allow-join-public-group' into 'master'

Allow join public group

See merge request framasoft/mobilizon!690
This commit is contained in:
Thomas Citharel 2020-11-06 16:02:08 +01:00
commit 66834f6f6b
88 changed files with 1271 additions and 759 deletions

View File

@ -178,6 +178,28 @@ config :ex_cldr,
config :http_signatures, config :http_signatures,
adapter: Mobilizon.Federation.HTTPSignatures.Signature adapter: Mobilizon.Federation.HTTPSignatures.Signature
config :mobilizon, :cldr,
locales: [
"ar",
"be",
"ca",
"cs",
"de",
"en",
"es",
"fi",
"fr",
"gl",
"it",
"ja",
"nl",
"oc",
"pl",
"pt",
"ru",
"sv"
]
config :mobilizon, :activitypub, config :mobilizon, :activitypub,
# One day # One day
actor_stale_period: 3_600 * 48, actor_stale_period: 3_600 * 48,
@ -241,7 +263,8 @@ config :mobilizon, Oban,
log: false, log: false,
queues: [default: 10, search: 5, mailers: 10, background: 5], queues: [default: 10, search: 5, mailers: 10, background: 5],
crontab: [ crontab: [
{"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background} {"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background},
{"17 * * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background}
] ]
config :mobilizon, :rich_media, config :mobilizon, :rich_media,

View File

@ -92,6 +92,13 @@ config :mobilizon, :instance,
# config :mobilizon, :activitypub, sign_object_fetches: false # config :mobilizon, :activitypub, sign_object_fetches: false
# No need to compile every locale in development environment
config :mobilizon, :cldr,
locales: [
"fr",
"en"
]
config :mobilizon, :anonymous, config :mobilizon, :anonymous,
reports: [ reports: [
allowed: true allowed: true

View File

@ -65,8 +65,8 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { Person } from "../../types/actor"; import { IParticipant, ParticipantRole } from "../../types/participant.model";
import { IParticipant, ParticipantRole } from "../../types/event.model"; import { IPerson, Person } from "../../types/actor";
@Component @Component
export default class ParticipantCard extends Vue { export default class ParticipantCard extends Vue {
@ -81,7 +81,7 @@ export default class ParticipantCard extends Vue {
ParticipantRole = ParticipantRole; ParticipantRole = ParticipantRole;
get actorDisplayName(): string { get actorDisplayName(): string {
const actor = new Person(this.participant.actor); const actor = new Person(this.participant.actor as IPerson);
return actor.displayName(); return actor.displayName();
} }
} }

View File

@ -137,11 +137,12 @@ import { Component, Prop, Vue, Ref } from "vue-property-decorator";
import EditorComponent from "@/components/Editor.vue"; import EditorComponent from "@/components/Editor.vue";
import { SnackbarProgrammatic as Snackbar } from "buefy"; import { SnackbarProgrammatic as Snackbar } from "buefy";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { CommentModeration } from "../../types/event-options.model";
import { CommentModel, IComment } from "../../types/comment.model"; import { CommentModel, IComment } from "../../types/comment.model";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson, usernameWithDomain } from "../../types/actor"; import { IPerson, usernameWithDomain } from "../../types/actor";
import { COMMENTS_THREADS, FETCH_THREAD_REPLIES } from "../../graphql/comment"; import { COMMENTS_THREADS, FETCH_THREAD_REPLIES } from "../../graphql/comment";
import { IEvent, CommentModeration } from "../../types/event.model"; import { IEvent } from "../../types/event.model";
import ReportModal from "../Report/ReportModal.vue"; import ReportModal from "../Report/ReportModal.vue";
import { IReport } from "../../types/report.model"; import { IReport } from "../../types/report.model";
import { CREATE_REPORT } from "../../graphql/report"; import { CREATE_REPORT } from "../../graphql/report";

View File

@ -51,6 +51,7 @@
import { Prop, Vue, Component, Watch } from "vue-property-decorator"; import { Prop, Vue, Component, Watch } from "vue-property-decorator";
import Comment from "@/components/Comment/Comment.vue"; import Comment from "@/components/Comment/Comment.vue";
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue"; import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
import { CommentModeration } from "../../types/event-options.model";
import { CommentModel, IComment } from "../../types/comment.model"; import { CommentModel, IComment } from "../../types/comment.model";
import { import {
CREATE_COMMENT_FROM_EVENT, CREATE_COMMENT_FROM_EVENT,
@ -60,7 +61,7 @@ import {
} from "../../graphql/comment"; } from "../../graphql/comment";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson } from "../../types/actor"; import { IPerson } from "../../types/actor";
import { IEvent, CommentModeration } from "../../types/event.model"; import { IEvent } from "../../types/event.model";
@Component({ @Component({
apollo: { apollo: {

View File

@ -73,10 +73,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { IEvent, IEventCardOptions, ParticipantRole } from "@/types/event.model"; import { IEvent, IEventCardOptions } from "@/types/event.model";
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue"; import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { Actor, Person } from "@/types/actor"; import { Actor, Person } from "@/types/actor";
import { ParticipantRole } from "../../types/participant.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({

View File

@ -187,12 +187,8 @@ import { Component, Prop } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue"; import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { mixins } from "vue-class-component"; import { mixins } from "vue-class-component";
import { RawLocation, Route } from "vue-router"; import { RawLocation, Route } from "vue-router";
import { import { IParticipant, ParticipantRole } from "../../types/participant.model";
IParticipant, import { EventVisibility, IEventCardOptions } from "../../types/event.model";
ParticipantRole,
EventVisibility,
IEventCardOptions,
} from "../../types/event.model";
import { IPerson } from "../../types/actor"; import { IPerson } from "../../types/actor";
import ActorMixin from "../../mixins/actor"; import ActorMixin from "../../mixins/actor";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";

View File

@ -51,7 +51,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { ParticipantRole, EventVisibility, IEventCardOptions, IEvent } from "@/types/event.model"; import { EventVisibility, IEventCardOptions, IEvent } from "@/types/event.model";
import { Component, Prop } from "vue-property-decorator"; import { Component, Prop } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue"; import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { IPerson, usernameWithDomain } from "@/types/actor"; import { IPerson, usernameWithDomain } from "@/types/actor";
@ -59,6 +59,7 @@ import { mixins } from "vue-class-component";
import ActorMixin from "@/mixins/actor"; import ActorMixin from "@/mixins/actor";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import EventMixin from "@/mixins/event"; import EventMixin from "@/mixins/event";
import { ParticipantRole } from "../../types/participant.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
const defaultOptions: IEventCardOptions = { const defaultOptions: IEventCardOptions = {

View File

@ -56,6 +56,7 @@
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IEvent } from "@/types/event.model"; import { IEvent } from "@/types/event.model";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue"; import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { ParticipantRole } from "../../types/participant.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({
@ -67,6 +68,8 @@ export default class EventMinimalistCard extends Vue {
@Prop({ required: true, type: Object }) event!: IEvent; @Prop({ required: true, type: Object }) event!: IEvent;
RouteName = RouteName; RouteName = RouteName;
ParticipantRole = ParticipantRole;
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -142,7 +142,8 @@ A button to set your participation
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { EventJoinOptions, IEvent, IParticipant, ParticipantRole } from "../../types/event.model"; import { IParticipant, ParticipantRole } from "../../types/participant.model";
import { EventJoinOptions, IEvent } from "../../types/event.model";
import { IPerson, Person } from "../../types/actor"; import { IPerson, Person } from "../../types/actor";
import { CURRENT_ACTOR_CLIENT, IDENTITIES } from "../../graphql/actor"; import { CURRENT_ACTOR_CLIENT, IDENTITIES } from "../../graphql/actor";
import { CURRENT_USER_CLIENT } from "../../graphql/user"; import { CURRENT_USER_CLIENT } from "../../graphql/user";
@ -182,20 +183,20 @@ export default class ParticipationButton extends Vue {
RouteName = RouteName; RouteName = RouteName;
joinEvent(actor: IPerson) { joinEvent(actor: IPerson): void {
if (this.event.joinOptions === EventJoinOptions.RESTRICTED) { if (this.event.joinOptions === EventJoinOptions.RESTRICTED) {
this.$emit("joinEventWithConfirmation", actor); this.$emit("join-event-with-confirmation", actor);
} else { } else {
this.$emit("joinEvent", actor); this.$emit("join-event", actor);
} }
} }
joinModal() { joinModal(): void {
this.$emit("joinModal"); this.$emit("join-modal");
} }
confirmLeave() { confirmLeave(): void {
this.$emit("confirmLeave"); this.$emit("confirm-leave");
} }
get hasAnonymousParticipationMethods(): boolean { get hasAnonymousParticipationMethods(): boolean {

View File

@ -41,7 +41,7 @@ section {
.create-slot { .create-slot {
display: flex; display: flex;
justify-content: end; justify-content: flex-end;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
padding-right: 0.5rem; padding-right: 0.5rem;
} }

View File

@ -0,0 +1,30 @@
<template>
<redirect-with-account
:uri="uri"
:pathAfterLogin="`/@${preferredUsername}`"
:sentence="sentence"
/>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import RedirectWithAccount from "@/components/Utils/RedirectWithAccount.vue";
import RouteName from "../../router/name";
@Component({
components: { RedirectWithAccount },
})
export default class JoinGroupWithAccount extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
get uri(): string {
return `${window.location.origin}${
this.$router.resolve({
name: RouteName.GROUP,
params: { preferredUsername: this.preferredUsername },
}).href
}`;
}
sentence = this.$t("We will redirect you to your instance in order to interact with this group");
}
</script>

View File

@ -38,8 +38,9 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { confirmLocalAnonymousParticipation } from "@/services/AnonymousParticipationStorage"; import { confirmLocalAnonymousParticipation } from "@/services/AnonymousParticipationStorage";
import { IParticipant } from "../../types/participant.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { EventJoinOptions, IParticipant } from "../../types/event.model"; import { EventJoinOptions } from "../../types/event.model";
import { CONFIRM_PARTICIPATION } from "../../graphql/event"; import { CONFIRM_PARTICIPATION } from "../../graphql/event";
@Component @Component

View File

@ -1,71 +1,17 @@
<template> <template>
<section class="section container hero is-fullheight"> <redirect-with-account :uri="uri" :pathAfterLogin="`/events/${uuid}`" :sentence="sentence" />
<div class="hero-body">
<div class="container">
<div class="columns is-vcentered">
<div class="column has-text-centered">
<b-button
type="is-primary"
size="is-medium"
tag="router-link"
:to="{ name: RouteName.LOGIN }"
>{{ $t("Login on {instance}", { instance: host }) }}</b-button
>
</div>
<vertical-divider :content="$t('Or')" />
<div class="column">
<subtitle>{{ $t("I have an account on another Mobilizon instance.") }}</subtitle>
<p>{{ $t("Other software may also support this.") }}</p>
<p>
{{ $t("We will redirect you to your instance in order to interact with this event") }}
</p>
<form @submit.prevent="redirectToInstance">
<b-field :label="$t('Your federated identity')">
<b-field>
<b-input
expanded
autocapitalize="none"
autocorrect="off"
v-model="remoteActorAddress"
:placeholder="$t('profile@instance')"
></b-input>
<p class="control">
<button class="button is-primary" type="submit">{{ $t("Go") }}</button>
</p>
</b-field>
</b-field>
</form>
</div>
</div>
<div class="has-text-centered">
<b-button tag="a" type="is-text" @click="$router.go(-1)">{{
$t("Back to previous page")
}}</b-button>
</div>
</div>
</div>
</section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import VerticalDivider from "@/components/Utils/VerticalDivider.vue"; import RedirectWithAccount from "@/components/Utils/RedirectWithAccount.vue";
import Subtitle from "@/components/Utils/Subtitle.vue";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({
components: { Subtitle, VerticalDivider }, components: { RedirectWithAccount },
}) })
export default class ParticipationWithAccount extends Vue { export default class ParticipationWithAccount extends Vue {
@Prop({ type: String, required: true }) uuid!: string; @Prop({ type: String, required: true }) uuid!: string;
remoteActorAddress = "";
RouteName = RouteName;
get host() {
return window.location.hostname;
}
get uri(): string { get uri(): string {
return `${window.location.origin}${ return `${window.location.origin}${
this.$router.resolve({ this.$router.resolve({
@ -75,31 +21,6 @@ export default class ParticipationWithAccount extends Vue {
}`; }`;
} }
async redirectToInstance() { sentence = this.$t("We will redirect you to your instance in order to interact with this event");
let res;
const [_, host] = (res = this.remoteActorAddress.split("@", 2));
const remoteInteractionURI = await this.webFingerFetch(host, this.remoteActorAddress);
window.open(remoteInteractionURI);
}
private async webFingerFetch(hostname: string, identity: string): Promise<string> {
const scheme = process.env.NODE_ENV === "production" ? "https" : "http";
const data = await (
await fetch(`${scheme}://${hostname}/.well-known/webfinger?resource=acct:${identity}`)
).json();
if (data && Array.isArray(data.links)) {
const link: { template: string } = data.links.find(
(link: any) =>
link &&
typeof link.template === "string" &&
link.rel === "http://ostatus.org/schema/1.0/subscribe"
);
if (link && link.template.includes("{uri}")) {
return link.template.replace("{uri}", encodeURIComponent(this.uri));
}
}
throw new Error("No interaction path found in webfinger data");
}
} }
</script> </script>

View File

@ -74,19 +74,12 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { import { EventModel, IEvent, EventJoinOptions } from "@/types/event.model";
EventModel,
IEvent,
IParticipant,
ParticipantRole,
EventJoinOptions,
} from "@/types/event.model";
import { FETCH_EVENT, JOIN_EVENT } from "@/graphql/event"; import { FETCH_EVENT, JOIN_EVENT } from "@/graphql/event";
import { IConfig } from "@/types/config.model"; import { IConfig } from "@/types/config.model";
import { CONFIG } from "@/graphql/config"; import { CONFIG } from "@/graphql/config";
import { addLocalUnconfirmedAnonymousParticipation } from "@/services/AnonymousParticipationStorage"; import { addLocalUnconfirmedAnonymousParticipation } from "@/services/AnonymousParticipationStorage";
import { Route } from "vue-router"; import { IParticipant, ParticipantRole } from "../../types/participant.model";
import RouteName from "../../router/name";
@Component({ @Component({
apollo: { apollo: {
@ -139,8 +132,8 @@ export default class ParticipationWithoutAccount extends Vue {
message: this.anonymousParticipation.message, message: this.anonymousParticipation.message,
locale: this.$i18n.locale, locale: this.$i18n.locale,
}, },
update: (store, { data }) => { update: (store, { data: updateData }) => {
if (data == null) { if (updateData == null) {
console.error("Cannot update event participant cache, because of data null value."); console.error("Cannot update event participant cache, because of data null value.");
return; return;
} }
@ -159,7 +152,7 @@ export default class ParticipationWithoutAccount extends Vue {
return; return;
} }
if (data.joinEvent.role === ParticipantRole.NOT_CONFIRMED) { if (updateData.joinEvent.role === ParticipantRole.NOT_CONFIRMED) {
event.participantStats.notConfirmed += 1; event.participantStats.notConfirmed += 1;
} else { } else {
event.participantStats.going += 1; event.participantStats.going += 1;

View File

@ -26,12 +26,9 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user"; import { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user";
import { import { ICurrentUser } from "../../types/current-user.model";
ICurrentUser,
INotificationPendingParticipationEnum,
} from "../../types/current-user.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({
@ -46,7 +43,7 @@ export default class SettingsOnboarding extends Vue {
RouteName = RouteName; RouteName = RouteName;
async updateSetting(variables: object) { async updateSetting(variables: Record<string, unknown>): Promise<void> {
await this.$apollo.mutate<{ setUserSettings: string }>({ await this.$apollo.mutate<{ setUserSettings: string }>({
mutation: SET_USER_SETTINGS, mutation: SET_USER_SETTINGS,
variables, variables,

View File

@ -0,0 +1,107 @@
<template>
<section class="section container hero is-fullheight">
<div class="hero-body">
<div class="container">
<div class="columns is-vcentered">
<div class="column has-text-centered">
<b-button
type="is-primary"
size="is-medium"
tag="router-link"
:to="{
name: RouteName.LOGIN,
query: {
code: LoginErrorCode.NEED_TO_LOGIN,
redirect: pathAfterLogin,
},
}"
>{{ $t("Login on {instance}", { instance: host }) }}</b-button
>
</div>
<vertical-divider :content="$t('Or')" />
<div class="column">
<subtitle>{{ $t("I have an account on another Mobilizon instance.") }}</subtitle>
<p>{{ $t("Other software may also support this.") }}</p>
<p>{{ sentence }}</p>
<form @submit.prevent="redirectToInstance">
<b-field :label="$t('Your federated identity')">
<b-field>
<b-input
expanded
autocapitalize="none"
autocorrect="off"
v-model="remoteActorAddress"
:placeholder="$t('profile@instance')"
></b-input>
<p class="control">
<button class="button is-primary" type="submit">{{ $t("Go") }}</button>
</p>
</b-field>
</b-field>
</form>
</div>
</div>
<div class="has-text-centered">
<b-button tag="a" type="is-text" @click="$router.go(-1)">{{
$t("Back to previous page")
}}</b-button>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import VerticalDivider from "@/components/Utils/VerticalDivider.vue";
import Subtitle from "@/components/Utils/Subtitle.vue";
import { LoginErrorCode } from "@/types/login-error-code.model";
import RouteName from "../../router/name";
@Component({
components: { Subtitle, VerticalDivider },
})
export default class RedirectWithAccount extends Vue {
@Prop({ type: String, required: true }) uri!: string;
@Prop({ type: String, required: false }) pathAfterLogin!: string;
@Prop({ type: String, required: false }) sentence!: string;
remoteActorAddress = "";
RouteName = RouteName;
LoginErrorCode = LoginErrorCode;
// eslint-disable-next-line class-methods-use-this
get host(): string {
return window.location.hostname;
}
async redirectToInstance(): Promise<void> {
const [, host] = this.remoteActorAddress.split("@", 2);
const remoteInteractionURI = await this.webFingerFetch(host, this.remoteActorAddress);
window.open(remoteInteractionURI);
}
private async webFingerFetch(hostname: string, identity: string): Promise<string> {
const scheme = process.env.NODE_ENV === "production" ? "https" : "http";
const data = await (
await fetch(`${scheme}://${hostname}/.well-known/webfinger?resource=acct:${identity}`)
).json();
if (data && Array.isArray(data.links)) {
const link: { template: string } = data.links.find(
(someLink: any) =>
someLink &&
typeof someLink.template === "string" &&
someLink.rel === "http://ostatus.org/schema/1.0/subscribe"
);
if (link && link.template.includes("{uri}")) {
return link.template.replace("{uri}", encodeURIComponent(this.uri));
}
}
throw new Error("No interaction path found in webfinger data");
}
}
</script>

View File

@ -574,3 +574,35 @@ export const EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED = gql`
} }
} }
`; `;
export const GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED = gql`
subscription($actorId: ID!) {
groupMembershipChanged(personId: $actorId) {
id
memberships {
total
elements {
id
role
parent {
id
preferredUsername
name
domain
avatar {
id
url
}
}
invitedBy {
id
preferredUsername
name
}
insertedAt
updatedAt
}
}
}
}
`;

View File

@ -63,6 +63,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
preferredUsername preferredUsername
suspended suspended
visibility visibility
openness
physicalAddress { physicalAddress {
description description
street street
@ -262,6 +263,7 @@ export const UPDATE_GROUP = gql`
$avatar: PictureInput $avatar: PictureInput
$banner: PictureInput $banner: PictureInput
$visibility: GroupVisibility $visibility: GroupVisibility
$openness: Openness
$physicalAddress: AddressInput $physicalAddress: AddressInput
) { ) {
updateGroup( updateGroup(
@ -271,12 +273,15 @@ export const UPDATE_GROUP = gql`
banner: $banner banner: $banner
avatar: $avatar avatar: $avatar
visibility: $visibility visibility: $visibility
openness: $openness
physicalAddress: $physicalAddress physicalAddress: $physicalAddress
) { ) {
id id
preferredUsername preferredUsername
name name
summary summary
visibility
openness
avatar { avatar {
id id
url url

View File

@ -100,3 +100,12 @@ export const REMOVE_MEMBER = gql`
} }
} }
`; `;
export const JOIN_GROUP = gql`
mutation JoinGroup($groupId: ID!) {
joinGroup(groupId: $groupId) {
...MemberFragment
}
}
${MEMBER_FRAGMENT}
`;

View File

@ -78,3 +78,36 @@ export const SEARCH_PERSONS = gql`
} }
} }
`; `;
export const INTERACT = gql`
query Interact($uri: String!) {
interact(uri: $uri) {
... on Event {
id
title
uuid
beginsOn
picture {
id
url
}
tags {
slug
title
}
__typename
}
... on Group {
id
avatar {
id
url
}
domain
preferredUsername
name
__typename
}
}
}
`;

View File

@ -119,6 +119,7 @@ export const USER_SETTINGS_FRAGMENT = gql`
notificationEachWeek notificationEachWeek
notificationBeforeEvent notificationBeforeEvent
notificationPendingParticipation notificationPendingParticipation
notificationPendingMembership
} }
`; `;
@ -141,7 +142,8 @@ export const SET_USER_SETTINGS = gql`
$notificationOnDay: Boolean $notificationOnDay: Boolean
$notificationEachWeek: Boolean $notificationEachWeek: Boolean
$notificationBeforeEvent: Boolean $notificationBeforeEvent: Boolean
$notificationPendingParticipation: NotificationPendingParticipationEnum $notificationPendingParticipation: NotificationPendingEnum
$notificationPendingMembership: NotificationPendingEnum
) { ) {
setUserSettings( setUserSettings(
timezone: $timezone timezone: $timezone
@ -149,6 +151,7 @@ export const SET_USER_SETTINGS = gql`
notificationEachWeek: $notificationEachWeek notificationEachWeek: $notificationEachWeek
notificationBeforeEvent: $notificationBeforeEvent notificationBeforeEvent: $notificationBeforeEvent
notificationPendingParticipation: $notificationPendingParticipation notificationPendingParticipation: $notificationPendingParticipation
notificationPendingMembership: $notificationPendingMembership
) { ) {
...UserSettingFragment ...UserSettingFragment
} }

View File

@ -277,7 +277,6 @@
"Publish": "Publish", "Publish": "Publish",
"Published events with <b>{comments}</b> comments and <b>{participations}</b> confirmed participations": "Published events with <b>{comments}</b> comments and <b>{participations}</b> confirmed participations", "Published events with <b>{comments}</b> comments and <b>{participations}</b> confirmed participations": "Published events with <b>{comments}</b> comments and <b>{participations}</b> confirmed participations",
"RSS/Atom Feed": "RSS/Atom Feed", "RSS/Atom Feed": "RSS/Atom Feed",
"Redirecting to event…": "Redirecting to event…",
"Region": "Region", "Region": "Region",
"Register an account on Mobilizon!": "Register an account on Mobilizon!", "Register an account on Mobilizon!": "Register an account on Mobilizon!",
"Registration is allowed, anyone can register.": "Registration is allowed, anyone can register.", "Registration is allowed, anyone can register.": "Registration is allowed, anyone can register.",
@ -733,7 +732,6 @@
"Group {groupTitle} reported": "Group {groupTitle} reported", "Group {groupTitle} reported": "Group {groupTitle} reported",
"Error while reporting group {groupTitle}": "Error while reporting group {groupTitle}", "Error while reporting group {groupTitle}": "Error while reporting group {groupTitle}",
"Reported group": "Reported group", "Reported group": "Reported group",
"You can only get invited to groups right now.": "You can only get invited to groups right now.",
"Join group": "Join group", "Join group": "Join group",
"Created by {username}": "Created by {username}", "Created by {username}": "Created by {username}",
"Accessible through link": "Accessible through link", "Accessible through link": "Accessible through link",
@ -790,5 +788,14 @@
"This group doesn't have a description yet.": "This group doesn't have a description yet.", "This group doesn't have a description yet.": "This group doesn't have a description yet.",
"Find another instance": "Find another instance", "Find another instance": "Find another instance",
"Pick an instance": "Pick an instance", "Pick an instance": "Pick an instance",
"This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone).": "This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone)." "This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone).": "This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone).",
"We will redirect you to your instance in order to interact with this group": "",
"New members": "New members",
"Anyone can join freely": "Anyone can join freely",
"Anyone wanting to be a member from your group will be able to from your group page.": "Anyone wanting to be a member from your group will be able to from your group page.",
"Manually invite new members": "Manually invite new members",
"The only way for your group to get new members is if an admininistrator invites them.": "The only way for your group to get new members is if an admininistrator invites them.",
"Redirecting to content…": "Redirecting to content…",
"This URL is not supported": "This URL is not supported",
"This group is invite-only": "This group is invite-only"
} }

View File

@ -870,5 +870,14 @@
"{profile} (by default)": "{profile} (par défault)", "{profile} (by default)": "{profile} (par défault)",
"{title} ({count} todos)": "{title} ({count} todos)", "{title} ({count} todos)": "{title} ({count} todos)",
"{username} was invited to {group}": "{username} a été invité à {group}", "{username} was invited to {group}": "{username} a été invité à {group}",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap" "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"We will redirect you to your instance in order to interact with this group": "",
"New members": "Nouveaux·elles membres",
"Anyone can join freely": "N'importe qui peut rejoindre",
"Anyone wanting to be a member from your group will be able to from your group page.": "N'importe qui voulant devenir membre pourra le faire depuis votre page de groupe.",
"Manually invite new members": "Inviter des nouveaux·elles membres manuellement",
"The only way for your group to get new members is if an admininistrator invites them.": "La seule manière pour votre groupe d'obtenir de nouveaux·elles membres sera si un·e administrateur·ice les invite.",
"Redirecting to content…": "Redirection vers le contenu…",
"This URL is not supported": "Cette URL n'est pas supportée",
"This group is invite-only": "Ce groupe est accessible uniquement sur invitation"
} }

View File

@ -10,7 +10,7 @@
"fi": "suomi", "fi": "suomi",
"fr": "Français", "fr": "Français",
"gl": "Galego", "gl": "Galego",
"hu": "Magyar nyelv", "hu": "Magyar",
"it": "Italiano", "it": "Italiano",
"ja": "日本語", "ja": "日本語",
"kn": "Kannada", "kn": "Kannada",

View File

@ -1,13 +1,14 @@
import { mixins } from "vue-class-component"; import { mixins } from "vue-class-component";
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import { IEvent, IParticipant, ParticipantRole } from "../types/event.model"; import { SnackbarProgrammatic as Snackbar } from "buefy";
import { IParticipant, ParticipantRole } from "../types/participant.model";
import { IEvent } from "../types/event.model";
import { import {
DELETE_EVENT, DELETE_EVENT,
EVENT_PERSON_PARTICIPATION, EVENT_PERSON_PARTICIPATION,
FETCH_EVENT, FETCH_EVENT,
LEAVE_EVENT, LEAVE_EVENT,
} from "../graphql/event"; } from "../graphql/event";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { IPerson } from "../types/actor"; import { IPerson } from "../types/actor";
@Component @Component
@ -17,7 +18,7 @@ export default class EventMixin extends mixins(Vue) {
actorId: string, actorId: string,
token: string | null = null, token: string | null = null,
anonymousParticipationConfirmed: boolean | null = null anonymousParticipationConfirmed: boolean | null = null
) { ): Promise<void> {
try { try {
const { data: resultData } = await this.$apollo.mutate<{ leaveEvent: IParticipant }>({ const { data: resultData } = await this.$apollo.mutate<{ leaveEvent: IParticipant }>({
mutation: LEAVE_EVENT, mutation: LEAVE_EVENT,
@ -89,7 +90,7 @@ export default class EventMixin extends mixins(Vue) {
this.$notifier.success(this.$t("You have cancelled your participation") as string); this.$notifier.success(this.$t("You have cancelled your participation") as string);
} }
protected async openDeleteEventModal(event: IEvent, currentActor: IPerson) { protected async openDeleteEventModal(event: IEvent, currentActor: IPerson): Promise<void> {
function escapeRegExp(string: string) { function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
} }
@ -135,7 +136,7 @@ export default class EventMixin extends mixins(Vue) {
* *
* @type {string} * @type {string}
*/ */
this.$emit("eventDeleted", event.id); this.$emit("event-deleted", event.id);
this.$buefy.notification.open({ this.$buefy.notification.open({
message: this.$t("Event {eventTitle} deleted", { eventTitle }) as string, message: this.$t("Event {eventTitle} deleted", { eventTitle }) as string,
@ -150,6 +151,7 @@ export default class EventMixin extends mixins(Vue) {
} }
} }
// eslint-disable-next-line class-methods-use-this
urlToHostname(url: string): string | null { urlToHostname(url: string): string | null {
try { try {
return new URL(url).hostname; return new URL(url).hostname;

View File

@ -1,4 +1,5 @@
import { PERSON_MEMBERSHIPS, CURRENT_ACTOR_CLIENT } from "@/graphql/actor"; import { PERSON_MEMBERSHIPS, CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED } from "@/graphql/event";
import { FETCH_GROUP } from "@/graphql/group"; import { FETCH_GROUP } from "@/graphql/group";
import RouteName from "@/router/name"; import RouteName from "@/router/name";
import { Group, IActor, IGroup, IPerson, MemberRole } from "@/types/actor"; import { Group, IActor, IGroup, IPerson, MemberRole } from "@/types/actor";
@ -29,6 +30,17 @@ import { Component, Vue } from "vue-property-decorator";
id: this.currentActor.id, id: this.currentActor.id,
}; };
}, },
subscribeToMore: {
document: GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED,
variables() {
return {
actorId: this.currentActor.id,
};
},
skip() {
return !this.currentActor || !this.currentActor.id;
},
},
skip() { skip() {
return !this.currentActor || !this.currentActor.id; return !this.currentActor || !this.currentActor.id;
}, },

View File

@ -15,6 +15,7 @@ export enum GroupsRouteName {
POST = "POST", POST = "POST",
POSTS = "POSTS", POSTS = "POSTS",
GROUP_EVENTS = "GROUP_EVENTS", GROUP_EVENTS = "GROUP_EVENTS",
GROUP_JOIN = "GROUP_JOIN",
} }
const resourceFolder = () => import("@/views/Resources/ResourceFolder.vue"); const resourceFolder = () => import("@/views/Resources/ResourceFolder.vue");
@ -97,17 +98,27 @@ export const groupsRoutes: RouteConfig[] = [
component: () => import("@/views/Posts/Post.vue"), component: () => import("@/views/Posts/Post.vue"),
props: true, props: true,
name: GroupsRouteName.POST, name: GroupsRouteName.POST,
meta: { requiredAuth: false },
}, },
{ {
path: "/@:preferredUsername/p", path: "/@:preferredUsername/p",
component: () => import("@/views/Posts/List.vue"), component: () => import("@/views/Posts/List.vue"),
props: true, props: true,
name: GroupsRouteName.POSTS, name: GroupsRouteName.POSTS,
meta: { requiredAuth: false },
}, },
{ {
path: "/@:preferredUsername/events", path: "/@:preferredUsername/events",
component: groupEvents, component: groupEvents,
props: true, props: true,
name: GroupsRouteName.GROUP_EVENTS, name: GroupsRouteName.GROUP_EVENTS,
meta: { requiredAuth: false },
},
{
path: "/@:preferredUsername/join",
component: () => import("@/components/Group/JoinGroupWithAccount.vue"),
props: true,
name: GroupsRouteName.GROUP_JOIN,
meta: { requiredAuth: false },
}, },
]; ];

View File

@ -42,7 +42,7 @@ export class Actor implements IActor {
type: ActorType = ActorType.PERSON; type: ActorType = ActorType.PERSON;
constructor(hash: IActor | {} = {}) { constructor(hash: IActor | Record<any, unknown> = {}) {
Object.assign(this, hash); Object.assign(this, hash);
} }

View File

@ -18,13 +18,10 @@ export enum MemberRole {
REJECTED = "REJECTED", REJECTED = "REJECTED",
} }
export interface IGroup extends IActor { export enum Openness {
members: Paginate<IMember>; INVITE_ONLY = "INVITE_ONLY",
resources: Paginate<IResource>; MODERATED = "MODERATED",
todoLists: Paginate<ITodoList>; OPEN = "OPEN",
discussions: Paginate<IDiscussion>;
organizedEvents: Paginate<IEvent>;
physicalAddress: IAddress;
} }
export interface IMember { export interface IMember {
@ -37,6 +34,16 @@ export interface IMember {
updatedAt: string; updatedAt: string;
} }
export interface IGroup extends IActor {
members: Paginate<IMember>;
resources: Paginate<IResource>;
todoLists: Paginate<ITodoList>;
discussions: Paginate<IDiscussion>;
organizedEvents: Paginate<IEvent>;
physicalAddress: IAddress;
openness: Openness;
}
export class Group extends Actor implements IGroup { export class Group extends Actor implements IGroup {
members: Paginate<IMember> = { elements: [], total: 0 }; members: Paginate<IMember> = { elements: [], total: 0 };
@ -50,16 +57,18 @@ export class Group extends Actor implements IGroup {
posts: Paginate<IPost> = { elements: [], total: 0 }; posts: Paginate<IPost> = { elements: [], total: 0 };
constructor(hash: IGroup | {} = {}) { constructor(hash: IGroup | Record<string, unknown> = {}) {
super(hash); super(hash);
this.type = ActorType.GROUP; this.type = ActorType.GROUP;
this.patch(hash); this.patch(hash);
} }
openness: Openness = Openness.INVITE_ONLY;
physicalAddress: IAddress = new Address(); physicalAddress: IAddress = new Address();
patch(hash: any) { patch(hash: IGroup | Record<string, unknown>): void {
Object.assign(this, hash); Object.assign(this, hash);
} }
} }

View File

@ -1,8 +1,9 @@
import { ICurrentUser } from "../current-user.model"; import { ICurrentUser } from "../current-user.model";
import { IEvent, IParticipant } from "../event.model"; import { IEvent } from "../event.model";
import { Actor, IActor } from "./actor.model"; import { Actor, IActor } from "./actor.model";
import { Paginate } from "../paginate"; import { Paginate } from "../paginate";
import { IMember } from "./group.model"; import { IMember } from "./group.model";
import { IParticipant } from "../participant.model";
export interface IFeedToken { export interface IFeedToken {
token: string; token: string;
@ -29,13 +30,13 @@ export class Person extends Actor implements IPerson {
user!: ICurrentUser; user!: ICurrentUser;
constructor(hash: IPerson | {} = {}) { constructor(hash: IPerson | Record<string, unknown> = {}) {
super(hash); super(hash);
this.patch(hash); this.patch(hash);
} }
patch(hash: any) { patch(hash: IPerson | Record<string, unknown>): void {
Object.assign(this, hash); Object.assign(this, hash);
} }
} }

View File

@ -1,6 +1,7 @@
import { IEvent, IParticipant } from "@/types/event.model"; import { IEvent } from "@/types/event.model";
import { IPerson } from "@/types/actor/person.model"; import { IPerson } from "@/types/actor/person.model";
import { Paginate } from "./paginate"; import { Paginate } from "./paginate";
import { IParticipant } from "./participant.model";
export enum ICurrentUserRole { export enum ICurrentUserRole {
USER = "USER", USER = "USER",
@ -16,7 +17,7 @@ export interface ICurrentUser {
defaultActor?: IPerson; defaultActor?: IPerson;
} }
export enum INotificationPendingParticipationEnum { export enum INotificationPendingEnum {
NONE = "NONE", NONE = "NONE",
DIRECT = "DIRECT", DIRECT = "DIRECT",
ONE_DAY = "ONE_DAY", ONE_DAY = "ONE_DAY",
@ -28,7 +29,8 @@ export interface IUserSettings {
notificationOnDay: boolean; notificationOnDay: boolean;
notificationEachWeek: boolean; notificationEachWeek: boolean;
notificationBeforeEvent: boolean; notificationBeforeEvent: boolean;
notificationPendingParticipation: INotificationPendingParticipationEnum; notificationPendingParticipation: INotificationPendingEnum;
notificationPendingMembership: INotificationPendingEnum;
} }
export interface IUser extends ICurrentUser { export interface IUser extends ICurrentUser {

View File

@ -0,0 +1,61 @@
export interface IParticipationCondition {
title: string;
content: string;
url: string;
}
export interface IOffer {
price: number;
priceCurrency: string;
url: string;
}
export enum CommentModeration {
ALLOW_ALL = "ALLOW_ALL",
MODERATED = "MODERATED",
CLOSED = "CLOSED",
}
export interface IEventOptions {
maximumAttendeeCapacity: number;
remainingAttendeeCapacity: number;
showRemainingAttendeeCapacity: boolean;
anonymousParticipation: boolean;
hideOrganizerWhenGroupEvent: boolean;
offers: IOffer[];
participationConditions: IParticipationCondition[];
attendees: string[];
program: string;
commentModeration: CommentModeration;
showParticipationPrice: boolean;
showStartTime: boolean;
showEndTime: boolean;
}
export class EventOptions implements IEventOptions {
maximumAttendeeCapacity = 0;
remainingAttendeeCapacity = 0;
showRemainingAttendeeCapacity = false;
anonymousParticipation = false;
hideOrganizerWhenGroupEvent = false;
offers: IOffer[] = [];
participationConditions: IParticipationCondition[] = [];
attendees: string[] = [];
program = "";
commentModeration = CommentModeration.ALLOW_ALL;
showParticipationPrice = false;
showStartTime = true;
showEndTime = true;
}

View File

@ -3,7 +3,9 @@ import { ITag } from "@/types/tag.model";
import { IPicture } from "@/types/picture.model"; import { IPicture } from "@/types/picture.model";
import { IComment } from "@/types/comment.model"; import { IComment } from "@/types/comment.model";
import { Paginate } from "@/types/paginate"; import { Paginate } from "@/types/paginate";
import { Actor, Group, IActor, IPerson } from "./actor"; import { Actor, Group, IActor, IGroup, IPerson } from "./actor";
import { IParticipant } from "./participant.model";
import { EventOptions, IEventOptions } from "./event-options.model";
export enum EventStatus { export enum EventStatus {
TENTATIVE = "TENTATIVE", TENTATIVE = "TENTATIVE",
@ -30,16 +32,6 @@ export enum EventVisibilityJoinOptions {
LIMITED = "LIMITED", LIMITED = "LIMITED",
} }
export enum ParticipantRole {
NOT_APPROVED = "NOT_APPROVED",
NOT_CONFIRMED = "NOT_CONFIRMED",
REJECTED = "REJECTED",
PARTICIPANT = "PARTICIPANT",
MODERATOR = "MODERATOR",
ADMINISTRATOR = "ADMINISTRATOR",
CREATOR = "CREATOR",
}
export enum Category { export enum Category {
BUSINESS = "business", BUSINESS = "business",
CONFERENCE = "conference", CONFERENCE = "conference",
@ -56,58 +48,6 @@ export interface IEventCardOptions {
memberofGroup: boolean; memberofGroup: boolean;
} }
export interface IParticipant {
id?: string;
role: ParticipantRole;
actor: IActor;
event: IEvent;
metadata: { cancellationToken?: string; message?: string };
insertedAt?: Date;
}
export class Participant implements IParticipant {
id?: string;
event!: IEvent;
actor!: IActor;
role: ParticipantRole = ParticipantRole.NOT_APPROVED;
metadata = {};
insertedAt?: Date;
constructor(hash?: IParticipant) {
if (!hash) return;
this.id = hash.id;
this.event = new EventModel(hash.event);
this.actor = new Actor(hash.actor);
this.role = hash.role;
this.metadata = hash.metadata;
this.insertedAt = hash.insertedAt;
}
}
export interface IOffer {
price: number;
priceCurrency: string;
url: string;
}
export interface IParticipationCondition {
title: string;
content: string;
url: string;
}
export enum CommentModeration {
ALLOW_ALL = "ALLOW_ALL",
MODERATED = "MODERATED",
CLOSED = "CLOSED",
}
export interface IEventParticipantStats { export interface IEventParticipantStats {
notApproved: number; notApproved: number;
notConfirmed: number; notConfirmed: number;
@ -119,6 +59,26 @@ export interface IEventParticipantStats {
going: number; going: number;
} }
interface IEventEditJSON {
id?: string;
title: string;
description: string;
beginsOn: string;
endsOn: string | null;
status: EventStatus;
visibility: EventVisibility;
joinOptions: EventJoinOptions;
draft: boolean;
picture: IPicture | { pictureId: string } | null;
attributedToId: string | null;
onlineAddress?: string;
phoneAddress?: string;
physicalAddress?: IAddress;
tags: string[];
options: IEventOptions;
contacts: { id?: string }[];
}
export interface IEvent { export interface IEvent {
id?: string; id?: string;
uuid: string; uuid: string;
@ -139,7 +99,7 @@ export interface IEvent {
picture: IPicture | null; picture: IPicture | null;
organizerActor?: IActor; organizerActor?: IActor;
attributedTo?: IActor; attributedTo?: IGroup;
participantStats: IEventParticipantStats; participantStats: IEventParticipantStats;
participants: Paginate<IParticipant>; participants: Paginate<IParticipant>;
@ -157,50 +117,6 @@ export interface IEvent {
toEditJSON(): IEventEditJSON; toEditJSON(): IEventEditJSON;
} }
export interface IEventOptions {
maximumAttendeeCapacity: number;
remainingAttendeeCapacity: number;
showRemainingAttendeeCapacity: boolean;
anonymousParticipation: boolean;
hideOrganizerWhenGroupEvent: boolean;
offers: IOffer[];
participationConditions: IParticipationCondition[];
attendees: string[];
program: string;
commentModeration: CommentModeration;
showParticipationPrice: boolean;
showStartTime: boolean;
showEndTime: boolean;
}
export class EventOptions implements IEventOptions {
maximumAttendeeCapacity = 0;
remainingAttendeeCapacity = 0;
showRemainingAttendeeCapacity = false;
anonymousParticipation = false;
hideOrganizerWhenGroupEvent = false;
offers: IOffer[] = [];
participationConditions: IParticipationCondition[] = [];
attendees: string[] = [];
program = "";
commentModeration = CommentModeration.ALLOW_ALL;
showParticipationPrice = false;
showStartTime = true;
showEndTime = true;
}
export class EventModel implements IEvent { export class EventModel implements IEvent {
id?: string; id?: string;
@ -255,7 +171,7 @@ export class EventModel implements IEvent {
comments: IComment[] = []; comments: IComment[] = [];
attributedTo?: IActor = new Actor(); attributedTo?: IGroup = new Group();
organizerActor?: IActor = new Actor(); organizerActor?: IActor = new Actor();
@ -323,7 +239,6 @@ export class EventModel implements IEvent {
phoneAddress: this.phoneAddress, phoneAddress: this.phoneAddress,
physicalAddress: this.physicalAddress, physicalAddress: this.physicalAddress,
options: this.options, options: this.options,
// organizerActorId: this.organizerActor && this.organizerActor.id ? this.organizerActor.id : null,
attributedToId: this.attributedTo && this.attributedTo.id ? this.attributedTo.id : null, attributedToId: this.attributedTo && this.attributedTo.id ? this.attributedTo.id : null,
contacts: this.contacts.map(({ id }) => ({ contacts: this.contacts.map(({ id }) => ({
id, id,
@ -331,23 +246,3 @@ export class EventModel implements IEvent {
}; };
} }
} }
interface IEventEditJSON {
id?: string;
title: string;
description: string;
beginsOn: string;
endsOn: string | null;
status: EventStatus;
visibility: EventVisibility;
joinOptions: EventJoinOptions;
draft: boolean;
picture: IPicture | { pictureId: string } | null;
attributedToId: string | null;
onlineAddress?: string;
phoneAddress?: string;
physicalAddress?: IAddress;
tags: string[];
options: IEventOptions;
contacts: { id?: string }[];
}

View File

@ -0,0 +1,46 @@
import { Actor, IActor } from "./actor";
import { EventModel, IEvent } from "./event.model";
export enum ParticipantRole {
NOT_APPROVED = "NOT_APPROVED",
NOT_CONFIRMED = "NOT_CONFIRMED",
REJECTED = "REJECTED",
PARTICIPANT = "PARTICIPANT",
MODERATOR = "MODERATOR",
ADMINISTRATOR = "ADMINISTRATOR",
CREATOR = "CREATOR",
}
export interface IParticipant {
id?: string;
role: ParticipantRole;
actor: IActor;
event: IEvent;
metadata: { cancellationToken?: string; message?: string };
insertedAt?: Date;
}
export class Participant implements IParticipant {
id?: string;
event!: IEvent;
actor!: IActor;
role: ParticipantRole = ParticipantRole.NOT_APPROVED;
metadata = {};
insertedAt?: Date;
constructor(hash?: IParticipant) {
if (!hash) return;
this.id = hash.id;
this.event = new EventModel(hash.event);
this.actor = new Actor(hash.actor);
this.role = hash.role;
this.metadata = hash.metadata;
this.insertedAt = hash.insertedAt;
}
}

View File

@ -63,13 +63,14 @@ export default class CreateDiscussion extends Vue {
async createDiscussion(): Promise<void> { async createDiscussion(): Promise<void> {
try { try {
if (!this.group.id || !this.currentActor.id) return;
const { data } = await this.$apollo.mutate({ const { data } = await this.$apollo.mutate({
mutation: CREATE_DISCUSSION, mutation: CREATE_DISCUSSION,
variables: { variables: {
title: this.discussion.title, title: this.discussion.title,
text: this.discussion.text, text: this.discussion.text,
actorId: this.group.id, actorId: parseInt(this.group.id, 10),
creatorId: this.currentActor.id, creatorId: parseInt(this.currentActor.id, 10),
}, },
}); });

View File

@ -67,27 +67,6 @@
/> />
</p> </p>
</div> </div>
<!-- <div class="field" v-if="event.attributedTo.id">-->
<!-- <label class="label">{{ $t('Hide the organizer') }}</label>-->
<!-- <b-switch v-model="event.options.hideOrganizerWhenGroupEvent">-->
<!-- {{ $t("Don't show @{organizer} as event host alongside @{group}", {organizer: event.organizerActor.preferredUsername, group: event.attributedTo.preferredUsername}) }}-->
<!-- <small>-->
<!-- <br>-->
<!-- {{ $t('All group members and other eventual server admins will still be able to view this information.') }}-->
<!-- </small>-->
<!-- </b-switch>-->
<!-- </div>-->
<!--<b-field :label="$t('Category')">
<b-select placeholder="Select a category" v-model="event.category">
<option
v-for="category in categories"
:value="category"
:key="category"
>{{ $t(category) }}</option>
</b-select>
</b-field>-->
<subtitle>{{ $t("Who can view this event and participate") }}</subtitle> <subtitle>{{ $t("Who can view this event and participate") }}</subtitle>
<div class="field"> <div class="field">
<b-radio <b-radio
@ -370,6 +349,8 @@ import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
import Subtitle from "@/components/Utils/Subtitle.vue"; import Subtitle from "@/components/Utils/Subtitle.vue";
import { Route } from "vue-router"; import { Route } from "vue-router";
import { formatList } from "@/utils/i18n"; import { formatList } from "@/utils/i18n";
import { CommentModeration } from "../../types/event-options.model";
import { ParticipantRole } from "../../types/participant.model";
import OrganizerPickerWrapper from "../../components/Event/OrganizerPickerWrapper.vue"; import OrganizerPickerWrapper from "../../components/Event/OrganizerPickerWrapper.vue";
import { import {
CREATE_EVENT, CREATE_EVENT,
@ -378,13 +359,11 @@ import {
FETCH_EVENT, FETCH_EVENT,
} from "../../graphql/event"; } from "../../graphql/event";
import { import {
CommentModeration,
EventJoinOptions, EventJoinOptions,
EventModel, EventModel,
EventStatus, EventStatus,
EventVisibility, EventVisibility,
IEvent, IEvent,
ParticipantRole,
} from "../../types/event.model"; } from "../../types/event.model";
import { import {
CURRENT_ACTOR_CLIENT, CURRENT_ACTOR_CLIENT,

View File

@ -88,10 +88,10 @@
:participation="participations[0]" :participation="participations[0]"
:event="event" :event="event"
:current-actor="currentActor" :current-actor="currentActor"
@joinEvent="joinEvent" @join-event="joinEvent"
@joinModal="isJoinModalActive = true" @join-modal="isJoinModalActive = true"
@joinEventWithConfirmation="joinEventWithConfirmation" @join-event-with-confirmation="joinEventWithConfirmation"
@confirmLeave="confirmLeave" @confirm-leave="confirmLeave"
/> />
<b-button <b-button
type="is-text" type="is-text"
@ -504,7 +504,6 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Watch } from "vue-property-decorator"; import { Component, Prop, Watch } from "vue-property-decorator";
import BIcon from "buefy/src/components/icon/Icon.vue"; import BIcon from "buefy/src/components/icon/Icon.vue";
import { GraphQLError } from "graphql";
import { import {
EVENT_PERSON_PARTICIPATION, EVENT_PERSON_PARTICIPATION,
EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED, EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
@ -517,8 +516,6 @@ import {
EventStatus, EventStatus,
EventVisibility, EventVisibility,
IEvent, IEvent,
IParticipant,
ParticipantRole,
EventJoinOptions, EventJoinOptions,
} from "../../types/event.model"; } from "../../types/event.model";
import { IActor, IPerson, Person, usernameWithDomain } from "../../types/actor"; import { IActor, IPerson, Person, usernameWithDomain } from "../../types/actor";
@ -549,6 +546,7 @@ import Tag from "../../components/Tag.vue";
import EventMetadataBlock from "../../components/Event/EventMetadataBlock.vue"; import EventMetadataBlock from "../../components/Event/EventMetadataBlock.vue";
import ActorCard from "../../components/Account/ActorCard.vue"; import ActorCard from "../../components/Account/ActorCard.vue";
import PopoverActorCard from "../../components/Account/PopoverActorCard.vue"; import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
import { IParticipant, ParticipantRole } from "../../types/participant.model";
@Component({ @Component({
components: { components: {

View File

@ -38,71 +38,9 @@ export default class EventList extends Vue {
locationText = ""; locationText = "";
// created() { viewEvent(event: IEvent): void {
// this.fetchData(this.$router.currentRoute.params.location);
// }
// beforeRouteUpdate(to, from, next) {
// this.fetchData(to.params.location);
// next();
// }
// @Watch('locationChip')
// onLocationChipChange(val) {
// if (val === false) {
// this.$router.push({ name: RouteName.EVENT_LIST });
// }
// }
// geocode(lat, lon) {
// console.log({ lat, lon });
// console.log(ngeohash.encode(lat, lon, 10));
// return ngeohash.encode(lat, lon, 10);
// }
// fetchData(location: string) {
// let queryString = '/events';
// if (location) {
// queryString += `?geohash=${location}`;
// const { latitude, longitude } = ngeohash.decode(location);
// this.locationText = `${latitude.toString()} : ${longitude.toString()}`;
// }
// this.locationChip = true;
// // FIXME: remove eventFetch
// // eventFetch(queryString, this.$store)
// // .then(response => response.json())
// // .then((response) => {
// // this.loading = false;
// // this.events = response.data;
// // console.log(this.events);
// // });
// }
// deleteEvent(event: IEvent) {
// const router = this.$router;
// // FIXME: remove eventFetch
// // eventFetch(`/events/${event.uuid}`, this.$store, { method: 'DELETE' })
// // .then(() => router.push('/events'));
// }
viewEvent(event: IEvent) {
this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } }); this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
} }
// downloadIcsEvent(event: IEvent) {
// // FIXME: remove eventFetch
// // eventFetch(`/events/${event.uuid}/ics`, this.$store, { responseType: 'arraybuffer' })
// // .then(response => response.text())
// // .then((response) => {
// // const blob = new Blob([ response ], { type: 'text/calendar' });
// // const link = document.createElement('a');
// // link.href = window.URL.createObjectURL(blob);
// // link.download = `${event.title}.ics`;
// // document.body.appendChild(link);
// // link.click();
// // document.body.removeChild(link);
// // });
// }
} }
</script> </script>

View File

@ -16,7 +16,7 @@
:key="participation.id" :key="participation.id"
:participation="participation" :participation="participation"
:options="{ hideDate: false }" :options="{ hideDate: false }"
@eventDeleted="eventDeleted" @event-deleted="eventDeleted"
class="participation" class="participation"
/> />
</div> </div>
@ -57,7 +57,7 @@
:key="participation.id" :key="participation.id"
:participation="participation" :participation="participation"
:options="{ hideDate: false }" :options="{ hideDate: false }"
@eventDeleted="eventDeleted" @event-deleted="eventDeleted"
class="participation" class="participation"
/> />
</div> </div>
@ -88,14 +88,9 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import { IParticipant, Participant, ParticipantRole } from "../../types/participant.model";
import { LOGGED_USER_PARTICIPATIONS, LOGGED_USER_DRAFTS } from "../../graphql/actor"; import { LOGGED_USER_PARTICIPATIONS, LOGGED_USER_DRAFTS } from "../../graphql/actor";
import { import { EventModel, IEvent } from "../../types/event.model";
EventModel,
IEvent,
IParticipant,
Participant,
ParticipantRole,
} from "../../types/event.model";
import EventListCard from "../../components/Event/EventListCard.vue"; import EventListCard from "../../components/Event/EventListCard.vue";
import EventCard from "../../components/Event/EventCard.vue"; import EventCard from "../../components/Event/EventCard.vue";
import Subtitle from "../../components/Utils/Subtitle.vue"; import Subtitle from "../../components/Utils/Subtitle.vue";
@ -207,7 +202,7 @@ export default class MyEvents extends Vue {
return MyEvents.monthlyParticipations(this.pastParticipations, true); return MyEvents.monthlyParticipations(this.pastParticipations, true);
} }
loadMoreFutureParticipations() { loadMoreFutureParticipations(): void {
this.futurePage += 1; this.futurePage += 1;
this.$apollo.queries.futureParticipations.fetchMore({ this.$apollo.queries.futureParticipations.fetchMore({
// New variables // New variables
@ -236,7 +231,7 @@ export default class MyEvents extends Vue {
}); });
} }
loadMorePastParticipations() { loadMorePastParticipations(): void {
this.pastPage += 1; this.pastPage += 1;
this.$apollo.queries.pastParticipations.fetchMore({ this.$apollo.queries.pastParticipations.fetchMore({
// New variables // New variables
@ -265,7 +260,7 @@ export default class MyEvents extends Vue {
}); });
} }
eventDeleted(eventid: string) { eventDeleted(eventid: string): void {
this.futureParticipations = this.futureParticipations.filter( this.futureParticipations = this.futureParticipations.filter(
(participation) => participation.event.id !== eventid (participation) => participation.event.id !== eventid
); );

View File

@ -185,12 +185,8 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch, Ref } from "vue-property-decorator"; import { Component, Prop, Vue, Watch, Ref } from "vue-property-decorator";
import { import { IParticipant, ParticipantRole } from "../../types/participant.model";
IEvent, import { IEvent, IEventParticipantStats } from "../../types/event.model";
IEventParticipantStats,
IParticipant,
ParticipantRole,
} from "../../types/event.model";
import { PARTICIPANTS, UPDATE_PARTICIPANT } from "../../graphql/event"; import { PARTICIPANTS, UPDATE_PARTICIPANT } from "../../graphql/event";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson, usernameWithDomain } from "../../types/actor"; import { IPerson, usernameWithDomain } from "../../types/actor";

View File

@ -109,11 +109,25 @@
> >
<p class="buttons"> <p class="buttons">
<b-tooltip <b-tooltip
:label="$t('You can only get invited to groups right now.')" v-if="group.openness !== Openness.OPEN"
:label="$t('This group is invite-only')"
position="is-bottom" position="is-bottom"
> >
<b-button disabled type="is-primary">{{ $t("Join group") }}</b-button> <b-button disabled type="is-primary">{{ $t("Join group") }}</b-button></b-tooltip
</b-tooltip> >
<b-button v-else-if="currentActor.id" @click="joinGroup" type="is-primary">{{
$t("Join group")
}}</b-button>
<b-button
tag="router-link"
:to="{
name: RouteName.GROUP_JOIN,
params: { preferredUsername: usernameWithDomain(group) },
}"
v-else
type="is-primary"
>{{ $t("Join group") }}</b-button
>
<b-dropdown aria-role="list" position="is-bottom-left"> <b-dropdown aria-role="list" position="is-bottom-left">
<b-button slot="trigger" role="button" icon-right="dots-horizontal"> </b-button> <b-button slot="trigger" role="button" icon-right="dots-horizontal"> </b-button>
<b-dropdown-item <b-dropdown-item
@ -348,7 +362,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Watch } from "vue-property-decorator"; import { Component, Prop, Watch } from "vue-property-decorator";
import EventCard from "@/components/Event/EventCard.vue"; import EventCard from "@/components/Event/EventCard.vue";
import { IActor, usernameWithDomain, MemberRole, IMember } from "@/types/actor"; import { IActor, usernameWithDomain, MemberRole, IMember, Openness } from "@/types/actor";
import Subtitle from "@/components/Utils/Subtitle.vue"; import Subtitle from "@/components/Utils/Subtitle.vue";
import CompactTodo from "@/components/Todo/CompactTodo.vue"; import CompactTodo from "@/components/Todo/CompactTodo.vue";
import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue"; import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue";
@ -365,6 +379,7 @@ import { IReport } from "@/types/report.model";
import { IConfig } from "@/types/config.model"; import { IConfig } from "@/types/config.model";
import GroupMixin from "@/mixins/group"; import GroupMixin from "@/mixins/group";
import { mixins } from "vue-class-component"; import { mixins } from "vue-class-component";
import { JOIN_GROUP } from "@/graphql/member";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import GroupSection from "../../components/Group/GroupSection.vue"; import GroupSection from "../../components/Group/GroupSection.vue";
import ReportModal from "../../components/Report/ReportModal.vue"; import ReportModal from "../../components/Report/ReportModal.vue";
@ -414,6 +429,8 @@ export default class Group extends mixins(GroupMixin) {
usernameWithDomain = usernameWithDomain; usernameWithDomain = usernameWithDomain;
Openness = Openness;
showMap = false; showMap = false;
isReportModalActive = false; isReportModalActive = false;
@ -425,6 +442,15 @@ export default class Group extends mixins(GroupMixin) {
} }
} }
async joinGroup(): Promise<void> {
this.$apollo.mutate({
mutation: JOIN_GROUP,
variables: {
groupId: this.group.id,
},
});
}
acceptInvitation(): void { acceptInvitation(): void {
if (this.groupMember) { if (this.groupMember) {
const index = this.person.memberships.elements.findIndex( const index = this.person.memberships.elements.findIndex(
@ -508,6 +534,12 @@ export default class Group extends mixins(GroupMixin) {
.map(({ parent: { id } }) => id); .map(({ parent: { id } }) => id);
} }
@Watch("isCurrentActorAGroupMember")
refetchGroupData(): void {
console.log("refetchGroupData");
this.$apollo.queries.group.refetch();
}
get isCurrentActorAGroupMember(): boolean { get isCurrentActorAGroupMember(): boolean {
return this.groupMemberships !== undefined && this.groupMemberships.includes(this.group.id); return this.groupMemberships !== undefined && this.groupMemberships.includes(this.group.id);
} }

View File

@ -77,22 +77,24 @@
</b-select> </b-select>
</b-field> </b-field>
<b-table <b-table
:data="group.members.elements" v-if="members"
:data="members.elements"
ref="queueTable" ref="queueTable"
:loading="this.$apollo.loading" :loading="this.$apollo.loading"
paginated paginated
backend-pagination backend-pagination
:current-page.sync="page"
:pagination-simple="true" :pagination-simple="true"
:aria-next-label="$t('Next page')" :aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')" :aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')" :aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')" :aria-current-label="$t('Current page')"
:total="group.members.total" :total="members.total"
:per-page="MEMBERS_PER_PAGE" :per-page="MEMBERS_PER_PAGE"
backend-sorting backend-sorting
:default-sort-direction="'desc'" :default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']" :default-sort="['insertedAt', 'desc']"
@page-change="(newPage) => (page = newPage)" @page-change="triggerLoadMoreMemberPageChange"
@sort="(field, order) => $emit('sort', field, order)" @sort="(field, order) => $emit('sort', field, order)"
> >
<b-table-column field="actor.preferredUsername" :label="$t('Member')" v-slot="props"> <b-table-column field="actor.preferredUsername" :label="$t('Member')" v-slot="props">
@ -184,7 +186,7 @@ import { mixins } from "vue-class-component";
import { FETCH_GROUP } from "@/graphql/group"; import { FETCH_GROUP } from "@/graphql/group";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { INVITE_MEMBER, GROUP_MEMBERS, REMOVE_MEMBER, UPDATE_MEMBER } from "../../graphql/member"; import { INVITE_MEMBER, GROUP_MEMBERS, REMOVE_MEMBER, UPDATE_MEMBER } from "../../graphql/member";
import { IGroup, usernameWithDomain } from "../../types/actor"; import { usernameWithDomain } from "../../types/actor";
import { IMember, MemberRole } from "../../types/actor/group.model"; import { IMember, MemberRole } from "../../types/actor/group.model";
@Component({ @Component({
@ -194,7 +196,7 @@ import { IMember, MemberRole } from "../../types/actor/group.model";
variables() { variables() {
return { return {
name: this.$route.params.preferredUsername, name: this.$route.params.preferredUsername,
page: 1, page: this.page,
limit: this.MEMBERS_PER_PAGE, limit: this.MEMBERS_PER_PAGE,
roles: this.roles, roles: this.roles,
}; };
@ -216,7 +218,7 @@ export default class GroupMembers extends mixins(GroupMixin) {
RouteName = RouteName; RouteName = RouteName;
page = 1; page = parseInt((this.$route.query.page as string) || "1", 10);
MEMBERS_PER_PAGE = 10; MEMBERS_PER_PAGE = 10;
@ -227,10 +229,20 @@ export default class GroupMembers extends mixins(GroupMixin) {
if (Object.values(MemberRole).includes(roleQuery as MemberRole)) { if (Object.values(MemberRole).includes(roleQuery as MemberRole)) {
this.roles = roleQuery as MemberRole; this.roles = roleQuery as MemberRole;
} }
this.page = parseInt((this.$route.query.page as string) || "1", 10);
} }
async inviteMember(): Promise<void> { async inviteMember(): Promise<void> {
try { try {
this.inviteError = "";
const { roles, MEMBERS_PER_PAGE, group, page } = this;
const variables = {
name: usernameWithDomain(group),
page,
limit: MEMBERS_PER_PAGE,
roles,
};
console.log("variables", variables);
await this.$apollo.mutate<{ inviteMember: IMember }>({ await this.$apollo.mutate<{ inviteMember: IMember }>({
mutation: INVITE_MEMBER, mutation: INVITE_MEMBER,
variables: { variables: {
@ -238,7 +250,10 @@ export default class GroupMembers extends mixins(GroupMixin) {
targetActorUsername: this.newMemberUsername, targetActorUsername: this.newMemberUsername,
}, },
refetchQueries: [ refetchQueries: [
{ query: FETCH_GROUP, variables: { name: this.$route.params.preferredUsername } }, {
query: GROUP_MEMBERS,
variables,
},
], ],
}); });
this.$notifier.success( this.$notifier.success(
@ -257,34 +272,49 @@ export default class GroupMembers extends mixins(GroupMixin) {
} }
@Watch("page") @Watch("page")
loadMoreMembers(): void { triggerLoadMoreMemberPageChange(page: string): void {
this.$apollo.queries.event.fetchMore({ this.$router.replace({
name: RouteName.GROUP_MEMBERS_SETTINGS,
query: { ...this.$route.query, page },
});
}
@Watch("roles")
triggerLoadMoreMemberRoleChange(roles: string): void {
this.$router.replace({
name: RouteName.GROUP_MEMBERS_SETTINGS,
query: { ...this.$route.query, roles },
});
}
async loadMoreMembers(): Promise<void> {
const { roles, MEMBERS_PER_PAGE, group, page } = this;
await this.$apollo.queries.members.fetchMore({
// New variables // New variables
variables: { variables() {
page: this.page, return {
limit: this.MEMBERS_PER_PAGE, name: usernameWithDomain(group),
page,
limit: MEMBERS_PER_PAGE,
roles,
};
}, },
// Transform the previous result with new data // Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => { updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const oldMembers = previousResult.group.members; const oldMembers = previousResult.group.members;
const newMembers = fetchMoreResult.group.members; const newMembers = fetchMoreResult.group.members;
return { return {
group: {
...previousResult.event,
members: {
elements: [...oldMembers.elements, ...newMembers.elements], elements: [...oldMembers.elements, ...newMembers.elements],
total: newMembers.total, total: newMembers.total,
__typename: oldMembers.__typename, __typename: oldMembers.__typename,
},
},
}; };
}, },
}); });
} }
async removeMember(memberId: string): Promise<void> { async removeMember(memberId: string): Promise<void> {
console.log("removeMember", memberId); const { roles, MEMBERS_PER_PAGE, group, page } = this;
try { try {
await this.$apollo.mutate<{ removeMember: IMember }>({ await this.$apollo.mutate<{ removeMember: IMember }>({
mutation: REMOVE_MEMBER, mutation: REMOVE_MEMBER,
@ -293,7 +323,17 @@ export default class GroupMembers extends mixins(GroupMixin) {
memberId, memberId,
}, },
refetchQueries: [ refetchQueries: [
{ query: FETCH_GROUP, variables: { name: this.$route.params.preferredUsername } }, {
query: GROUP_MEMBERS,
variables() {
return {
name: usernameWithDomain(group),
page,
limit: MEMBERS_PER_PAGE,
roles,
};
},
},
], ],
}); });
this.$notifier.success( this.$notifier.success(

View File

@ -102,6 +102,31 @@
</p> </p>
</div> </div>
<p class="label">{{ $t("New members") }}</p>
<div class="field">
<b-radio v-model="group.openness" name="groupOpenness" :native-value="Openness.OPEN">
{{ $t("Anyone can join freely") }}<br />
<small>{{
$t(
"Anyone wanting to be a member from your group will be able to from your group page."
)
}}</small>
</b-radio>
</div>
<div class="field">
<b-radio
v-model="group.openness"
name="groupOpenness"
:native-value="Openness.INVITE_ONLY"
>{{ $t("Manually invite new members") }}<br />
<small>{{
$t(
"The only way for your group to get new members is if an admininistrator invites them."
)
}}</small>
</b-radio>
</div>
<full-address-auto-complete <full-address-auto-complete
:label="$t('Group address')" :label="$t('Group address')"
v-model="group.physicalAddress" v-model="group.physicalAddress"
@ -129,7 +154,7 @@ import { mixins } from "vue-class-component";
import GroupMixin from "@/mixins/group"; import GroupMixin from "@/mixins/group";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { UPDATE_GROUP, DELETE_GROUP } from "../../graphql/group"; import { UPDATE_GROUP, DELETE_GROUP } from "../../graphql/group";
import { IGroup, usernameWithDomain } from "../../types/actor"; import { IGroup, usernameWithDomain, Openness } from "../../types/actor";
import { Address, IAddress } from "../../types/address.model"; import { Address, IAddress } from "../../types/address.model";
@Component({ @Component({
@ -157,6 +182,8 @@ export default class GroupSettings extends mixins(GroupMixin) {
UNLISTED: "UNLISTED", UNLISTED: "UNLISTED",
}; };
Openness = Openness;
showCopiedTooltip = false; showCopiedTooltip = false;
async updateGroup(): Promise<void> { async updateGroup(): Promise<void> {

View File

@ -27,6 +27,16 @@
:member="member" :member="member"
@leave="leaveGroup(member.parent)" @leave="leaveGroup(member.parent)"
/> />
<b-pagination
:total="membershipsPages.total"
v-model="page"
:per-page="limit"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</section> </section>
<b-message v-if="$apollo.loading === false && memberships.length === 0" type="is-danger"> <b-message v-if="$apollo.loading === false && memberships.length === 0" type="is-danger">
{{ $t("No groups found") }} {{ $t("No groups found") }}
@ -54,10 +64,11 @@ import RouteName from "../../router/name";
membershipsPages: { membershipsPages: {
query: LOGGED_USER_MEMBERSHIPS, query: LOGGED_USER_MEMBERSHIPS,
fetchPolicy: "cache-and-network", fetchPolicy: "cache-and-network",
variables: { variables() {
page: 1, return {
limit: 10, page: this.page,
beforeDateTime: new Date().toISOString(), limit: this.limit,
};
}, },
update: (data) => data.loggedUser.memberships, update: (data) => data.loggedUser.memberships,
}, },
@ -76,6 +87,10 @@ export default class MyGroups extends Vue {
RouteName = RouteName; RouteName = RouteName;
page = 1;
limit = 10;
acceptInvitation(member: IMember): Promise<Route> { acceptInvitation(member: IMember): Promise<Route> {
return this.$router.push({ return this.$router.push({
name: RouteName.GROUP, name: RouteName.GROUP,
@ -94,11 +109,21 @@ export default class MyGroups extends Vue {
} }
async leaveGroup(group: IGroup): Promise<void> { async leaveGroup(group: IGroup): Promise<void> {
const { page, limit } = this;
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: LEAVE_GROUP, mutation: LEAVE_GROUP,
variables: { variables: {
groupId: group.id, groupId: group.id,
}, },
refetchQueries: [
{
query: LOGGED_USER_MEMBERSHIPS,
variables: {
page,
limit,
},
},
],
}); });
} }

View File

@ -128,7 +128,7 @@
<EventListCard <EventListCard
v-for="participation in row[1]" v-for="participation in row[1]"
v-if="isInLessThanSevenDays(row[0])" v-if="isInLessThanSevenDays(row[0])"
@eventDeleted="eventDeleted" @event-deleted="eventDeleted"
:key="participation[1].id" :key="participation[1].id"
:participation="participation[1]" :participation="participation[1]"
/> />
@ -148,7 +148,7 @@
v-for="participation in lastWeekEvents" v-for="participation in lastWeekEvents"
:key="participation.id" :key="participation.id"
:participation="participation" :participation="participation"
@eventDeleted="eventDeleted" @event-deleted="eventDeleted"
:options="{ hideDate: false }" :options="{ hideDate: false }"
/> />
</div> </div>
@ -174,6 +174,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import { IParticipant, Participant, ParticipantRole } from "../types/participant.model";
import { FETCH_EVENTS } from "../graphql/event"; import { FETCH_EVENTS } from "../graphql/event";
import EventListCard from "../components/Event/EventListCard.vue"; import EventListCard from "../components/Event/EventListCard.vue";
import EventCard from "../components/Event/EventCard.vue"; import EventCard from "../components/Event/EventCard.vue";
@ -182,7 +183,7 @@ import { IPerson, Person } from "../types/actor";
import { ICurrentUser } from "../types/current-user.model"; import { ICurrentUser } from "../types/current-user.model";
import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user"; import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user";
import RouteName from "../router/name"; import RouteName from "../router/name";
import { IEvent, IParticipant, Participant, ParticipantRole } from "../types/event.model"; import { IEvent } from "../types/event.model";
import DateComponent from "../components/Event/DateCalendarIcon.vue"; import DateComponent from "../components/Event/DateCalendarIcon.vue";
import { CONFIG } from "../graphql/config"; import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model"; import { IConfig } from "../types/config.model";

View File

@ -1,9 +1,9 @@
<template> <template>
<div class="container section"> <div class="container section">
<b-notification v-if="$apollo.queries.searchEvents.loading"> <b-notification v-if="$apollo.queries.interact.loading">
{{ $t("Redirecting to event…") }} {{ $t("Redirecting to content…") }}
</b-notification> </b-notification>
<b-notification v-if="$apollo.queries.searchEvents.skip" type="is-danger"> <b-notification v-if="$apollo.queries.interact.skip" type="is-danger">
{{ $t("Resource provided is not an URL") }} {{ $t("Resource provided is not an URL") }}
</b-notification> </b-notification>
</div> </div>
@ -11,48 +11,58 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import { SEARCH_EVENTS } from "@/graphql/search"; import { INTERACT } from "@/graphql/search";
import { IEvent } from "@/types/event.model"; import { IEvent } from "@/types/event.model";
import { IGroup, usernameWithDomain } from "@/types/actor";
import RouteName from "../router/name"; import RouteName from "../router/name";
@Component({ @Component({
apollo: { apollo: {
searchEvents: { interact: {
query: SEARCH_EVENTS, query: INTERACT,
variables() { variables() {
return { return {
searchText: this.$route.query.url, uri: this.$route.query.uri,
}; };
}, },
skip() { skip() {
try { try {
const url = this.$route.query.url as string; const url = this.$route.query.uri as string;
const uri = new URL(url); const uri = new URL(url);
return !(uri instanceof URL); return !(uri instanceof URL);
} catch (e) { } catch (e) {
return true; return true;
} }
}, },
async result({ data }) { async result({ data: { interact } }) {
if ( switch (interact.__typename) {
data.searchEvents && case "Group":
data.searchEvents.total > 0 && await this.$router.replace({
data.searchEvents.elements.length > 0 name: RouteName.GROUP,
) { params: { preferredUsername: usernameWithDomain(interact) },
const event = data.searchEvents.elements[0]; });
break;
case "Event":
await this.$router.replace({ await this.$router.replace({
name: RouteName.EVENT, name: RouteName.EVENT,
params: { uuid: event.uuid }, params: { uuid: interact.uuid },
}); });
break;
default:
this.error = this.$t("This URL is not supported");
} }
// await this.$router.replace({
// name: RouteName.EVENT,
// params: { uuid: event.uuid },
// });
}, },
}, },
}, },
}) })
export default class Interact extends Vue { export default class Interact extends Vue {
searchEvents!: IEvent[]; interact!: IEvent | IGroup;
RouteName = RouteName; error!: string;
} }
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@ -103,7 +103,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator"; import { Component, Vue, Watch } from "vue-property-decorator";
import { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user"; import { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user";
import { IUser, INotificationPendingParticipationEnum } from "../../types/current-user.model"; import { IUser, INotificationPendingEnum } from "../../types/current-user.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({
@ -120,7 +120,7 @@ export default class Notifications extends Vue {
notificationBeforeEvent = false; notificationBeforeEvent = false;
notificationPendingParticipation = INotificationPendingParticipationEnum.NONE; notificationPendingParticipation = INotificationPendingEnum.NONE;
notificationPendingParticipationValues: Record<string, unknown> = {}; notificationPendingParticipationValues: Record<string, unknown> = {};
@ -128,10 +128,10 @@ export default class Notifications extends Vue {
mounted(): void { mounted(): void {
this.notificationPendingParticipationValues = { this.notificationPendingParticipationValues = {
[INotificationPendingParticipationEnum.NONE]: this.$t("Do not receive any mail"), [INotificationPendingEnum.NONE]: this.$t("Do not receive any mail"),
[INotificationPendingParticipationEnum.DIRECT]: this.$t("Receive one email per request"), [INotificationPendingEnum.DIRECT]: this.$t("Receive one email per request"),
[INotificationPendingParticipationEnum.ONE_HOUR]: this.$t("Hourly email summary"), [INotificationPendingEnum.ONE_HOUR]: this.$t("Hourly email summary"),
[INotificationPendingParticipationEnum.ONE_DAY]: this.$t("Daily email summary"), [INotificationPendingEnum.ONE_DAY]: this.$t("Daily email summary"),
}; };
} }

View File

@ -160,8 +160,6 @@ export default class Login extends Vue {
email: validateEmailField, email: validateEmailField,
}; };
private redirect: string | null = null;
submitted = false; submitted = false;
mounted(): void { mounted(): void {
@ -170,7 +168,6 @@ export default class Login extends Vue {
const { query } = this.$route; const { query } = this.$route;
this.errorCode = query.code as LoginErrorCode; this.errorCode = query.code as LoginErrorCode;
this.redirect = query.redirect as string;
} }
async loginAction(e: Event): Promise<Route | void> { async loginAction(e: Event): Promise<Route | void> {
@ -219,11 +216,14 @@ export default class Login extends Vue {
} }
} }
if (this.redirect) { if (this.$route.query.redirect) {
this.$router.push(this.redirect); console.log("redirect", this.$route.query.redirect);
this.$router.push(this.$route.query.redirect as string);
return;
} }
window.localStorage.setItem("welcome-back", "yes"); window.localStorage.setItem("welcome-back", "yes");
this.$router.push({ name: RouteName.HOME }); this.$router.push({ name: RouteName.HOME });
return;
} catch (err) { } catch (err) {
this.submitted = false; this.submitted = false;
console.error(err); console.error(err);

View File

@ -32,6 +32,7 @@ defmodule Mobilizon.Federation.ActivityPub do
Federator, Federator,
Fetcher, Fetcher,
Preloader, Preloader,
Refresher,
Relay, Relay,
Transmogrifier, Transmogrifier,
Types, Types,
@ -373,7 +374,9 @@ defmodule Mobilizon.Federation.ActivityPub do
end end
end end
def join(%Event{} = event, %Actor{} = actor, local \\ true, additional \\ %{}) do def join(entity_to_join, actor_joining, local \\ true, additional \\ %{})
def join(%Event{} = event, %Actor{} = actor, local, additional) do
with {:ok, activity_data, participant} <- Types.Events.join(event, actor, local, additional), with {:ok, activity_data, participant} <- Types.Events.join(event, actor, local, additional),
{:ok, activity} <- create_activity(activity_data, local), {:ok, activity} <- create_activity(activity_data, local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
@ -387,25 +390,22 @@ defmodule Mobilizon.Federation.ActivityPub do
end end
end end
def join_group( def join(%Actor{type: :Group} = group, %Actor{} = actor, local, additional) do
%{parent_id: _parent_id, actor_id: _actor_id, role: _role} = args, with {:ok, activity_data, %Member{} = member} <-
local \\ true, Types.Actors.join(group, actor, local, additional),
additional \\ %{} {:ok, activity} <- create_activity(activity_data, local),
) do
with {:ok, %Member{} = member} <-
Mobilizon.Actors.create_member(args),
activity_data when is_map(activity_data) <-
Convertible.model_to_as(member),
{:ok, activity} <- create_activity(Map.merge(activity_data, additional), local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity, member} {:ok, activity, member}
else
{:accept, accept} ->
accept
end end
end end
def leave(object, actor, local \\ true, additional \\ %{}) def leave(object, actor, local \\ true, additional \\ %{})
@doc """ @doc """
Leave an event Leave an event or a group
""" """
def leave( def leave(
%Event{id: event_id, url: event_url} = _event, %Event{id: event_id, url: event_url} = _event,
@ -438,9 +438,6 @@ defmodule Mobilizon.Federation.ActivityPub do
end end
end end
@doc """
Leave a group
"""
def leave( def leave(
%Actor{type: :Group, id: group_id, url: group_url, members_url: group_members_url}, %Actor{type: :Group, id: group_id, url: group_url, members_url: group_members_url},
%Actor{id: actor_id, url: actor_url}, %Actor{id: actor_id, url: actor_url},
@ -899,6 +896,36 @@ defmodule Mobilizon.Federation.ActivityPub do
end end
end end
@spec accept_join(Member.t(), map) :: {:ok, Member.t(), Activity.t()} | any
defp accept_join(%Member{} = member, additional) do
with {:ok, %Member{} = member} <-
Actors.update_member(member, %{role: :member}),
_ <-
unless(is_nil(member.parent.domain),
do: Refresher.fetch_group(member.parent.url, member.actor)
),
Absinthe.Subscription.publish(Endpoint, member.actor,
group_membership_changed: member.actor.id
),
member_as_data <- Convertible.model_to_as(member),
audience <-
Audience.calculate_to_and_cc_from_mentions(member),
update_data <-
make_accept_join_data(
member_as_data,
Map.merge(Map.merge(audience, additional), %{
"id" => "#{Endpoint.url()}/accept/join/#{member.id}"
})
) do
{:ok, member, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@spec accept_invite(Member.t(), map()) :: {:ok, Member.t(), Activity.t()} | any @spec accept_invite(Member.t(), map()) :: {:ok, Member.t(), Activity.t()} | any
defp accept_invite( defp accept_invite(
%Member{invited_by_id: invited_by_id, actor_id: actor_id} = member, %Member{invited_by_id: invited_by_id, actor_id: actor_id} = member,

View File

@ -4,7 +4,7 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
""" """
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Discussions.{Comment, Discussion} alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Share alias Mobilizon.Share
@ -15,11 +15,23 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
@ap_public "https://www.w3.org/ns/activitystreams#Public" @ap_public "https://www.w3.org/ns/activitystreams#Public"
@doc """ @doc """
Determines the full audience based on mentions for a public audience Determines the full audience based on mentions for an audience
Audience is: For a public audience:
* `to` : the mentioned actors, the eventual actor we're replying to and the public * `to` : the mentioned actors, the eventual actor we're replying to and the public
* `cc` : the actor's followers * `cc` : the actor's followers
For an unlisted audience:
* `to` : the mentioned actors, actor's followers and the eventual actor we're replying to
* `cc` : public
For a private audience:
* `to` : the mentioned actors, actor's followers and the eventual actor we're replying to
* `cc` : none
For a direct audience:
* `to` : the mentioned actors and the eventual actor we're replying to
* `cc` : none
""" """
@spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, :public) do def get_to_and_cc(%Actor{} = actor, mentions, :public) do
@ -29,13 +41,6 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
{to, cc} {to, cc}
end end
@doc """
Determines the full audience based on mentions based on a unlisted audience
Audience is:
* `to` : the mentioned actors, actor's followers and the eventual actor we're replying to
* `cc` : public
"""
@spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, :unlisted) do def get_to_and_cc(%Actor{} = actor, mentions, :unlisted) do
to = [actor.followers_url | mentions] to = [actor.followers_url | mentions]
@ -44,26 +49,12 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
{to, cc} {to, cc}
end end
@doc """
Determines the full audience based on mentions based on a private audience
Audience is:
* `to` : the mentioned actors, actor's followers and the eventual actor we're replying to
* `cc` : none
"""
@spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, :private) do def get_to_and_cc(%Actor{} = actor, mentions, :private) do
{to, cc} = get_to_and_cc(actor, mentions, :direct) {to, cc} = get_to_and_cc(actor, mentions, :direct)
{[actor.followers_url | to], cc} {[actor.followers_url | to], cc}
end end
@doc """
Determines the full audience based on mentions based on a direct audience
Audience is:
* `to` : the mentioned actors and the eventual actor we're replying to
* `cc` : none
"""
@spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(_actor, mentions, :direct) do def get_to_and_cc(_actor, mentions, :direct) do
{mentions, []} {mentions, []}
@ -150,6 +141,12 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
%{"to" => [participant.actor.url], "cc" => actor_participants_urls} %{"to" => [participant.actor.url], "cc" => actor_participants_urls}
end end
def calculate_to_and_cc_from_mentions(%Member{} = member) do
member = Repo.preload(member, [:parent])
%{"to" => [member.parent.members_url], "cc" => []}
end
def calculate_to_and_cc_from_mentions(%Actor{} = actor) do def calculate_to_and_cc_from_mentions(%Actor{} = actor) do
%{ %{
"to" => [@ap_public], "to" => [@ap_public],

View File

@ -7,6 +7,7 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Fetcher, Relay, Transmogrifier, Utils} alias Mobilizon.Federation.ActivityPub.{Fetcher, Relay, Transmogrifier, Utils}
alias Mobilizon.Storage.Repo
require Logger require Logger
@doc """ @doc """
@ -58,6 +59,10 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
:ok <- fetch_collection(discussions_url, on_behalf_of), :ok <- fetch_collection(discussions_url, on_behalf_of),
:ok <- fetch_collection(events_url, on_behalf_of) do :ok <- fetch_collection(events_url, on_behalf_of) do
:ok :ok
else
err ->
Logger.error("Error while refreshing a group")
Logger.error(inspect(err))
end end
end end
@ -70,6 +75,7 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
with {:ok, data} <- Fetcher.fetch(collection_url, on_behalf_of: on_behalf_of), with {:ok, data} <- Fetcher.fetch(collection_url, on_behalf_of: on_behalf_of),
:ok <- Logger.debug("Fetch ok, passing to process_collection"), :ok <- Logger.debug("Fetch ok, passing to process_collection"),
:ok <- process_collection(data, on_behalf_of) do :ok <- process_collection(data, on_behalf_of) do
Logger.debug("Finished processing a collection")
:ok :ok
end end
end end
@ -90,6 +96,19 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
end end
end end
@spec refresh_all_external_groups :: any()
def refresh_all_external_groups do
Repo.transaction(fn ->
Actors.list_external_groups_for_stream()
|> Stream.map(fn %Actor{id: group_id, url: group_url} ->
{group_url, Actors.get_single_group_member_actor(group_id)}
end)
|> Stream.filter(fn {_group_url, member_actor} -> not is_nil(member_actor) end)
|> Stream.map(fn {group_url, member_actor} -> fetch_group(group_url, member_actor) end)
|> Stream.run()
end)
end
defp process_collection(%{"type" => type, "orderedItems" => items}, _on_behalf_of) defp process_collection(%{"type" => type, "orderedItems" => items}, _on_behalf_of)
when type in ["OrderedCollection", "OrderedCollectionPage"] do when type in ["OrderedCollection", "OrderedCollectionPage"] do
Logger.debug( Logger.debug(
@ -99,6 +118,7 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
Logger.debug(inspect(items)) Logger.debug(inspect(items))
Enum.each(items, &handling_element/1) Enum.each(items, &handling_element/1)
Logger.debug("Finished processing a collection")
:ok :ok
end end

View File

@ -26,6 +26,9 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
require Logger require Logger
@doc """
Handle incoming activities
"""
def handle_incoming(%{"id" => nil}), do: :error def handle_incoming(%{"id" => nil}), do: :error
def handle_incoming(%{"id" => ""}), do: :error def handle_incoming(%{"id" => ""}), do: :error
@ -47,18 +50,16 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end end
end end
@doc """ # Handles a `Create` activity for `Note` (comments) objects
Handles a `Create` activity for `Note` (comments) objects #
# The following actions are performed
The following actions are performed # * Fetch the author of the activity
* Fetch the author of the activity # * Convert the ActivityStream data to the comment model format (it also finds and inserts tags)
* Convert the ActivityStream data to the comment model format (it also finds and inserts tags) # * Get (by it's URL) or create the comment with this data
* Get (by it's URL) or create the comment with this data # * Insert eventual mentions in the database
* Insert eventual mentions in the database # * Convert the comment back in ActivityStreams data
* Convert the comment back in ActivityStreams data # * Wrap this data back into a `Create` activity
* Wrap this data back into a `Create` activity # * Return the activity and the comment object
* Return the activity and the comment object
"""
def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object}) do def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object}) do
Logger.info("Handle incoming to create notes") Logger.info("Handle incoming to create notes")
@ -88,18 +89,16 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end end
end end
@doc """ # Handles a `Create` activity for `Event` objects
Handles a `Create` activity for `Event` objects #
# The following actions are performed
The following actions are performed # * Fetch the author of the activity
* Fetch the author of the activity # * Convert the ActivityStream data to the event model format (it also finds and inserts tags)
* Convert the ActivityStream data to the event model format (it also finds and inserts tags) # * Get (by it's URL) or create the event with this data
* Get (by it's URL) or create the event with this data # * Insert eventual mentions in the database
* Insert eventual mentions in the database # * Convert the event back in ActivityStreams data
* Convert the event back in ActivityStreams data # * Wrap this data back into a `Create` activity
* Wrap this data back into a `Create` activity # * Return the activity and the event object
* Return the activity and the event object
"""
def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Event"} = object}) do def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Event"} = object}) do
Logger.info("Handle incoming to create event") Logger.info("Handle incoming to create event")
@ -135,13 +134,22 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
with object_data when is_map(object_data) <- with object_data when is_map(object_data) <-
object |> Converter.Member.as_to_model_data() do object |> Converter.Member.as_to_model_data() do
Logger.debug("Produced the following model data for member")
Logger.debug(inspect(object_data))
with {:existing_member, nil} <- with {:existing_member, nil} <-
{:existing_member, Actors.get_member_by_url(object_data.url)}, {:existing_member, Actors.get_member_by_url(object_data.url)},
%Actor{type: :Group} = group <- Actors.get_actor(object_data.parent_id),
%Actor{} = actor <- Actors.get_actor(object_data.actor_id),
{:ok, %Activity{} = activity, %Member{} = member} <- {:ok, %Activity{} = activity, %Member{} = member} <-
ActivityPub.join_group(object_data, false) do ActivityPub.join(group, actor, false, %{
url: object_data.url,
metadata: %{role: object_data.role}
}) do
{:ok, activity, member} {:ok, activity, member}
else else
{:existing_member, %Member{} = member} -> {:existing_member, %Member{} = member} ->
Logger.debug("Member already exists, updating member")
{:ok, %Member{} = member} = Actors.update_member(member, object_data) {:ok, %Member{} = member} = Actors.update_member(member, object_data)
{:ok, nil, member} {:ok, nil, member}
@ -608,8 +616,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
"type" => "Join", "type" => "Join",
"object" => object, "object" => object,
"actor" => _actor, "actor" => _actor,
"id" => id, "id" => id
"participationMessage" => note
} = data } = data
) do ) do
with actor <- Utils.get_actor(data), with actor <- Utils.get_actor(data),
@ -618,7 +625,10 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
object <- Utils.get_url(object), object <- Utils.get_url(object),
{:ok, object} <- ActivityPub.fetch_object_from_url(object), {:ok, object} <- ActivityPub.fetch_object_from_url(object),
{:ok, activity, object} <- {:ok, activity, object} <-
ActivityPub.join(object, actor, false, %{url: id, metadata: %{message: note}}) do ActivityPub.join(object, actor, false, %{
url: id,
metadata: %{message: Map.get(data, "participationMessage")}
}) do
{:ok, activity, object} {:ok, activity, object}
else else
e -> e ->
@ -732,10 +742,8 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
{:error, :not_supported} {:error, :not_supported}
end end
@doc """ # Handle incoming `Accept` activities wrapping a `Follow` activity
Handle incoming `Accept` activities wrapping a `Follow` activity defp do_handle_incoming_accept_following(follow_object, %Actor{} = actor) do
"""
def do_handle_incoming_accept_following(follow_object, %Actor{} = actor) do
with {:follow, with {:follow,
{:ok, %Follower{approved: false, target_actor: followed, actor: follower} = follow}} <- {:ok, %Follower{approved: false, target_actor: followed, actor: follower} = follow}} <-
{:follow, get_follow(follow_object)}, {:follow, get_follow(follow_object)},
@ -770,10 +778,8 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end end
end end
@doc """ # Handle incoming `Reject` activities wrapping a `Follow` activity
Handle incoming `Reject` activities wrapping a `Follow` activity defp do_handle_incoming_reject_following(follow_object, %Actor{} = actor) do
"""
def do_handle_incoming_reject_following(follow_object, %Actor{} = actor) do
with {:follow, {:ok, %Follower{target_actor: followed} = follow}} <- with {:follow, {:ok, %Follower{target_actor: followed} = follow}} <-
{:follow, get_follow(follow_object)}, {:follow, get_follow(follow_object)},
{:same_actor, true} <- {:same_actor, actor.id == followed.id}, {:same_actor, true} <- {:same_actor, actor.id == followed.id},
@ -804,8 +810,11 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
{:error, _err} -> {:error, _err} ->
case get_member(join_object) do case get_member(join_object) do
{:ok, member} -> {:ok, %Member{invited_by: nil} = member} ->
do_handle_incoming_accept_join_group(member, actor_accepting) do_handle_incoming_accept_join_group(member, :join)
{:ok, %Member{} = member} ->
do_handle_incoming_accept_join_group(member, :invite)
{:error, _err} -> {:error, _err} ->
nil nil
@ -847,7 +856,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end end
end end
defp do_handle_incoming_accept_join_group(%Member{role: :member}, _actor) do defp do_handle_incoming_accept_join_group(%Member{role: :member}, _type) do
Logger.debug( Logger.debug(
"Tried to handle an Accept activity on a Join activity with a group object but the member is already validated" "Tried to handle an Accept activity on a Join activity with a group object but the member is already validated"
) )
@ -857,14 +866,14 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
defp do_handle_incoming_accept_join_group( defp do_handle_incoming_accept_join_group(
%Member{role: role, parent: _group} = member, %Member{role: role, parent: _group} = member,
%Actor{} = _actor_accepting type
) )
when role in [:not_approved, :rejected, :invited] do when role in [:not_approved, :rejected, :invited] and type in [:join, :invite] do
# TODO: The actor that accepts the Join activity may another one that the event organizer ? # TODO: The actor that accepts the Join activity may another one that the event organizer ?
# Or maybe for groups it's the group that sends the Accept activity # Or maybe for groups it's the group that sends the Accept activity
with {:ok, %Activity{} = activity, %Member{role: :member} = member} <- with {:ok, %Activity{} = activity, %Member{role: :member} = member} <-
ActivityPub.accept( ActivityPub.accept(
:invite, type,
member, member,
false false
) do ) do

View File

@ -1,12 +1,15 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Actors do defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
@moduledoc false @moduledoc false
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Audience alias Mobilizon.Federation.ActivityPub.Audience
alias Mobilizon.Federation.ActivityPub.Types.Entity alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Convertible alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.GraphQL.API.Utils, as: APIUtils alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Formatter.HTML alias Mobilizon.Service.Formatter.HTML
alias Mobilizon.Service.Notifications.Scheduler
alias Mobilizon.Web.Endpoint
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2] import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
@behaviour Entity @behaviour Entity
@ -91,6 +94,42 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
def role_needed_to_update(%Actor{} = _group), do: :administrator def role_needed_to_update(%Actor{} = _group), do: :administrator
def role_needed_to_delete(%Actor{} = _group), do: :administrator def role_needed_to_delete(%Actor{} = _group), do: :administrator
@spec join(Actor.t(), Actor.t(), boolean(), map()) :: {:ok, map(), Member.t()}
def join(%Actor{type: :Group} = group, %Actor{} = actor, _local, additional) do
with role <-
additional
|> Map.get(:metadata, %{})
|> Map.get(:role, Mobilizon.Actors.get_default_member_role(group)),
{:ok, %Member{} = member} <-
Mobilizon.Actors.create_member(%{
role: role,
parent_id: group.id,
actor_id: actor.id,
url: Map.get(additional, :url),
metadata:
additional
|> Map.get(:metadata, %{})
|> Map.update(:message, nil, &String.trim(HTML.strip_tags(&1)))
}),
Absinthe.Subscription.publish(Endpoint, actor, group_membership_changed: actor.id),
join_data <- %{
"type" => "Join",
"id" => member.url,
"actor" => actor.url,
"object" => group.url
},
audience <-
Audience.calculate_to_and_cc_from_mentions(member) do
approve_if_default_role_is_member(
group,
actor,
Map.merge(join_data, audience),
member,
role
)
end
end
defp prepare_args_for_actor(args) do defp prepare_args_for_actor(args) do
args args
|> maybe_sanitize_username() |> maybe_sanitize_username()
@ -115,4 +154,39 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
end end
defp maybe_sanitize_summary(args), do: args defp maybe_sanitize_summary(args), do: args
# Set the participant to approved if the default role for new participants is :participant
@spec approve_if_default_role_is_member(Actor.t(), Actor.t(), map(), Member.t(), atom()) ::
{:ok, map(), Member.t()}
defp approve_if_default_role_is_member(
%Actor{type: :Group} = group,
%Actor{} = actor,
activity_data,
%Member{} = member,
role
) do
if is_nil(group.domain) && !is_nil(actor.domain) do
cond do
Mobilizon.Actors.get_default_member_role(group) === :member &&
role == :member ->
{:accept,
ActivityPub.accept(
:join,
member,
true,
%{"actor" => group.url}
)}
Mobilizon.Actors.get_default_member_role(group) === :not_approved &&
role == :not_approved ->
Scheduler.pending_membership_notification(group)
{:ok, activity_data, member}
true ->
{:ok, activity_data, member}
end
else
{:ok, activity_data, member}
end
end
end end

View File

@ -330,6 +330,12 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
end end
@doc """ @doc """
Return AS Link data from
* a `Plug.Upload` struct, stored an returned
* a `Picture`, directly returned
* a map containing picture information, stored, saved and returned
Save picture data from %Plug.Upload{} and return AS Link data. Save picture data from %Plug.Upload{} and return AS Link data.
""" """
def make_picture_data(%Plug.Upload{} = picture, opts) do def make_picture_data(%Plug.Upload{} = picture, opts) do
@ -342,16 +348,10 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
end end
end end
@doc """
Convert a picture model into an AS Link representation.
"""
def make_picture_data(%Picture{} = picture) do def make_picture_data(%Picture{} = picture) do
Converter.Picture.model_to_as(picture) Converter.Picture.model_to_as(picture)
end end
@doc """
Save picture data from raw data and return AS Link data.
"""
def make_picture_data(picture) when is_map(picture) do def make_picture_data(picture) when is_map(picture) do
with {:ok, %{"url" => [%{"href" => url, "mediaType" => content_type}], "size" => size}} <- with {:ok, %{"url" => [%{"href" => url, "mediaType" => content_type}], "size" => size}} <-
Mobilizon.Web.Upload.store(picture.file), Mobilizon.Web.Upload.store(picture.file),

View File

@ -65,7 +65,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
domain: URI.parse(data["id"]).host, domain: URI.parse(data["id"]).host,
manually_approves_followers: data["manuallyApprovesFollowers"], manually_approves_followers: data["manuallyApprovesFollowers"],
type: data["type"], type: data["type"],
visibility: if(Map.get(data, "discoverable", false) == true, do: :public, else: :unlisted) visibility: if(Map.get(data, "discoverable", false) == true, do: :public, else: :unlisted),
openness: data["openness"]
} }
end end
@ -98,6 +99,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
"sharedInbox" => actor.shared_inbox_url "sharedInbox" => actor.shared_inbox_url
}, },
"discoverable" => actor.visibility == :public, "discoverable" => actor.visibility == :public,
"openness" => actor.openness,
"manuallyApprovesFollowers" => actor.manually_approves_followers, "manuallyApprovesFollowers" => actor.manually_approves_followers,
"publicKey" => %{ "publicKey" => %{
"id" => "#{actor.url}#main-key", "id" => "#{actor.url}#main-key",

View File

@ -88,6 +88,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
@doc """ @doc """
Make an AS comment object from an existing `Comment` structure. Make an AS comment object from an existing `Comment` structure.
A "soft-deleted" comment is a tombstone
""" """
@impl Converter @impl Converter
@spec model_to_as(CommentModel.t()) :: map @spec model_to_as(CommentModel.t()) :: map
@ -127,9 +129,6 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
end end
end end
@doc """
A "soft-deleted" comment is a tombstone
"""
@impl Converter @impl Converter
@spec model_to_as(CommentModel.t()) :: map @spec model_to_as(CommentModel.t()) :: map
def model_to_as(%CommentModel{} = comment) do def model_to_as(%CommentModel{} = comment) do

View File

@ -20,7 +20,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Member do
end end
@doc """ @doc """
Convert an event struct to an ActivityStream representation. Convert an member struct to an ActivityStream representation.
""" """
@spec model_to_as(MemberModel.t()) :: map @spec model_to_as(MemberModel.t()) :: map
def model_to_as(%MemberModel{} = member) do def model_to_as(%MemberModel{} = member) do

View File

@ -72,6 +72,18 @@ defmodule Mobilizon.GraphQL.API.Search do
end end
end end
def interact(uri) do
case ActivityPub.fetch_object_from_url(uri) do
{:ok, object} ->
{:ok, object}
{:error, _err} ->
Logger.debug(fn -> "Unable to find or make object from URI '#{uri}'" end)
{:error, :not_found}
end
end
# If the search string is an username # If the search string is an username
@spec process_from_username(String.t()) :: Page.t() @spec process_from_username(String.t()) :: Page.t()
defp process_from_username(search) do defp process_from_username(search) do

View File

@ -75,7 +75,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Discussion do
def create_discussion( def create_discussion(
_parent, _parent,
%{title: title, text: text, actor_id: actor_id}, %{title: title, text: text, actor_id: group_id},
%{ %{
context: %{ context: %{
current_user: %User{} = user current_user: %User{} = user
@ -83,20 +83,23 @@ defmodule Mobilizon.GraphQL.Resolvers.Discussion do
} }
) do ) do
with {:actor, %Actor{id: creator_id} = _actor} <- {:actor, Users.get_actor_for_user(user)}, with {:actor, %Actor{id: creator_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(creator_id, actor_id)}, {:member, true} <- {:member, Actors.is_member?(creator_id, group_id)},
{:ok, _activity, %Discussion{} = discussion} <- {:ok, _activity, %Discussion{} = discussion} <-
ActivityPub.create( ActivityPub.create(
:discussion, :discussion,
%{ %{
title: title, title: title,
text: text, text: text,
actor_id: actor_id, actor_id: group_id,
creator_id: creator_id, creator_id: creator_id,
attributed_to_id: actor_id attributed_to_id: group_id
}, },
true true
) do ) do
{:ok, discussion} {:ok, discussion}
else
{:member, false} ->
{:error, :unauthorized}
end end
end end

View File

@ -12,7 +12,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
require Logger require Logger
@doc """ @doc """
Create an feed token for an user and a defined actor Create an feed token for an user and optionally a defined actor
""" """
@spec create_feed_token(any, map, map) :: {:ok, FeedToken.t()} | {:error, String.t()} @spec create_feed_token(any, map, map) :: {:ok, FeedToken.t()} | {:error, String.t()}
def create_feed_token( def create_feed_token(
@ -29,9 +29,6 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
end end
end end
@doc """
Create an feed token for an user
"""
@spec create_feed_token(any, map, map) :: {:ok, FeedToken.t()} @spec create_feed_token(any, map, map) :: {:ok, FeedToken.t()}
def create_feed_token(_parent, %{}, %{context: %{current_user: %User{id: id}}}) do def create_feed_token(_parent, %{}, %{context: %{current_user: %User{id: id}}}) do
with {:ok, feed_token} <- Events.create_feed_token(%{user_id: id}) do with {:ok, feed_token} <- Events.create_feed_token(%{user_id: id}) do

View File

@ -42,9 +42,6 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
end end
end end
@doc """
Find a group
"""
def find_group(_parent, %{preferred_username: name}, _resolution) do def find_group(_parent, %{preferred_username: name}, _resolution) do
with {:ok, actor} <- ActivityPub.find_or_make_group_from_nickname(name), with {:ok, actor} <- ActivityPub.find_or_make_group_from_nickname(name),
%Actor{} = actor <- Person.proxify_pictures(actor), %Actor{} = actor <- Person.proxify_pictures(actor),
@ -215,35 +212,18 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
@doc """ @doc """
Join an existing group Join an existing group
""" """
def join_group( def join_group(_parent, %{group_id: group_id} = args, %{
_parent, context: %{current_user: %User{} = user}
%{group_id: group_id, actor_id: actor_id}, }) do
%{ with %Actor{} = actor <- Users.get_actor_for_user(user),
context: %{ {:ok, %Actor{type: :Group} = group} <-
current_user: user Actors.get_group_by_actor_id(group_id),
}
}
) do
with {actor_id, ""} <- Integer.parse(actor_id),
{group_id, ""} <- Integer.parse(group_id),
{:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
{:error, :member_not_found} <- Actors.get_member(actor.id, group.id), {:error, :member_not_found} <- Actors.get_member(actor.id, group.id),
{:is_able_to_join, true} <- {:is_able_to_join, Member.can_be_joined(group)}, {:is_able_to_join, true} <- {:is_able_to_join, Member.can_be_joined(group)},
role <- Member.get_default_member_role(group), {:ok, _activity, %Member{} = member} <-
{:ok, _} <- Actors.create_member(%{parent_id: group.id, actor_id: actor.id, role: role}) do ActivityPub.join(group, actor, true, args) do
{ {:ok, member}
:ok,
%{
parent: Person.proxify_pictures(group),
actor: Person.proxify_pictures(actor),
role: role
}
}
else else
{:is_owned, nil} ->
{:error, dgettext("errors", "Profile is not owned by authenticated user")}
{:error, :group_not_found} -> {:error, :group_not_found} ->
{:error, dgettext("errors", "Group not found")} {:error, dgettext("errors", "Group not found")}

View File

@ -14,7 +14,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
@doc """ @doc """
Join an event for an regular actor Join an event for an regular or anonymous actor
""" """
def actor_join_event( def actor_join_event(
_parent, _parent,
@ -30,9 +30,6 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
end end
end end
@doc """
Join an event for an anonymous actor
"""
def actor_join_event( def actor_join_event(
_parent, _parent,
%{actor_id: actor_id, event_id: event_id} = args, %{actor_id: actor_id, event_id: event_id} = args,

View File

@ -126,9 +126,6 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
end end
end end
@doc """
This function is used to create more identities from an existing user
"""
def create_person(_parent, _args, _resolution) do def create_person(_parent, _args, _resolution) do
{:error, :unauthenticated} {:error, :unauthenticated}
end end
@ -240,7 +237,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
end end
@doc """ @doc """
Returns the participation for a specific event Returns the participations, optionally restricted to an event
""" """
def person_participations( def person_participations(
%Actor{id: actor_id}, %Actor{id: actor_id},
@ -260,9 +257,6 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
end end
end end
@doc """
Returns the list of events this person is going to
"""
def person_participations(%Actor{id: actor_id} = actor, %{page: page, limit: limit}, %{ def person_participations(%Actor{id: actor_id} = actor, %{page: page, limit: limit}, %{
context: %{current_user: %User{role: role} = user} context: %{current_user: %User{role: role} = user}
}) do }) do

View File

@ -10,17 +10,14 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
@doc """ @doc """
Get picture for an event's pic Get picture for an event
See Mobilizon.Web.Resolvers.Event.create_event/3
""" """
def picture(%{picture_id: picture_id} = _parent, _args, _resolution) do def picture(%{picture_id: picture_id} = _parent, _args, _resolution) do
with {:ok, picture} <- do_fetch_picture(picture_id), do: {:ok, picture} with {:ok, picture} <- do_fetch_picture(picture_id), do: {:ok, picture}
end end
@doc """
Get picture for an event that has an attached
See Mobilizon.Web.Resolvers.Event.create_event/3
"""
def picture(%{picture: picture} = _parent, _args, _resolution), do: {:ok, picture} def picture(%{picture: picture} = _parent, _args, _resolution), do: {:ok, picture}
def picture(_parent, %{id: picture_id}, _resolution), do: do_fetch_picture(picture_id) def picture(_parent, %{id: picture_id}, _resolution), do: do_fetch_picture(picture_id)
def picture(_parent, _args, _resolution), do: {:ok, nil} def picture(_parent, _args, _resolution), do: {:ok, nil}

View File

@ -44,7 +44,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Report do
end end
@doc """ @doc """
Create a report Create a report, either logged-in or anonymously
""" """
def create_report( def create_report(
_parent, _parent,
@ -63,9 +63,6 @@ defmodule Mobilizon.GraphQL.Resolvers.Report do
end end
end end
@doc """
Create a report anonymously if allowed
"""
def create_report( def create_report(
_parent, _parent,
%{reporter_id: reporter_id} = args, %{reporter_id: reporter_id} = args,

View File

@ -25,4 +25,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Search do
def search_events(_parent, %{page: page, limit: limit} = args, _resolution) do def search_events(_parent, %{page: page, limit: limit} = args, _resolution) do
Search.search_events(args, page, limit) Search.search_events(args, page, limit)
end end
def interact(_parent, %{uri: uri}, _resolution) do
Search.interact(uri)
end
end end

View File

@ -15,14 +15,14 @@ defmodule Mobilizon.GraphQL.Resolvers.Tag do
@doc """ @doc """
Retrieve the list of tags for an event Retrieve the list of tags for an event
From an event or a struct with an url
""" """
def list_tags_for_event(%Event{id: id}, _args, _resolution) do def list_tags_for_event(%Event{id: id}, _args, _resolution) do
{:ok, Events.list_tags_for_event(id)} {:ok, Events.list_tags_for_event(id)}
end end
@doc """ # TODO: Check that I'm actually used
Retrieve the list of tags for an event
"""
def list_tags_for_event(%{url: url}, _args, _resolution) do def list_tags_for_event(%{url: url}, _args, _resolution) do
with %Event{id: event_id} <- Events.get_event_by_url(url) do with %Event{id: event_id} <- Events.get_event_by_url(url) do
{:ok, Events.list_tags_for_event(event_id)} {:ok, Events.list_tags_for_event(event_id)}

View File

@ -17,7 +17,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
Represents a group of actors Represents a group of actors
""" """
object :group do object :group do
interfaces([:actor]) interfaces([:actor, :interactable])
field(:id, :id, description: "Internal ID for this group") field(:id, :id, description: "Internal ID for this group")
field(:url, :string, description: "The ActivityPub actor's URL") field(:url, :string, description: "The ActivityPub actor's URL")
@ -196,6 +196,10 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
arg(:visibility, :group_visibility, description: "The visibility for the group") arg(:visibility, :group_visibility, description: "The visibility for the group")
arg(:openness, :openness,
description: "Whether the group can be join freely, with approval or is invite-only."
)
arg(:avatar, :picture_input, arg(:avatar, :picture_input,
description: description:
"The avatar for the group, either as an object or directly the ID of an existing Picture" "The avatar for the group, either as an object or directly the ID of an existing Picture"

View File

@ -38,7 +38,6 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
@desc "Join a group" @desc "Join a group"
field :join_group, :member do field :join_group, :member do
arg(:group_id, non_null(:id)) arg(:group_id, non_null(:id))
arg(:actor_id, non_null(:id))
resolve(&Group.join_group/3) resolve(&Group.join_group/3)
end end

View File

@ -197,5 +197,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
{:ok, topic: args.person_id} {:ok, topic: args.person_id}
end) end)
end end
field :group_membership_changed, :person do
arg(:person_id, non_null(:id))
config(fn args, _ ->
{:ok, topic: args.person_id}
end)
end
end end
end end

View File

@ -17,7 +17,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
@desc "An event" @desc "An event"
object :event do object :event do
interfaces([:action_log_object]) interfaces([:action_log_object, :interactable])
field(:id, :id, description: "Internal ID for this event") field(:id, :id, description: "Internal ID for this event")
field(:uuid, :uuid, description: "The Event UUID") field(:uuid, :uuid, description: "The Event UUID")
field(:url, :string, description: "The ActivityPub Event URL") field(:url, :string, description: "The ActivityPub Event URL")

View File

@ -4,6 +4,8 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
""" """
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Event
alias Mobilizon.GraphQL.Resolvers.Search alias Mobilizon.GraphQL.Resolvers.Search
@desc "Search persons result" @desc "Search persons result"
@ -24,6 +26,21 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
field(:elements, non_null(list_of(:event)), description: "Event elements") field(:elements, non_null(list_of(:event)), description: "Event elements")
end end
interface :interactable do
field(:url, :string, description: "A public URL for the entity")
resolve_type(fn
%Actor{type: :Group}, _ ->
:group
%Event{}, _ ->
:event
_, _ ->
nil
end)
end
object :search_queries do object :search_queries do
@desc "Search persons" @desc "Search persons"
field :search_persons, :persons do field :search_persons, :persons do
@ -58,5 +75,12 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
resolve(&Search.search_events/3) resolve(&Search.search_events/3)
end end
@desc "Interact with an URI"
field :interact, :interactable do
arg(:uri, non_null(:string), description: "The URI for to interact with")
resolve(&Search.interact/3)
end
end end
end end

View File

@ -132,12 +132,17 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
description: "Whether this user will receive a notification right before event" description: "Whether this user will receive a notification right before event"
) )
field(:notification_pending_participation, :notification_pending_participation_enum, field(:notification_pending_participation, :notification_pending_enum,
description: "When does the user receives a notification about new pending participations" description: "When does the user receives a notification about new pending participations"
) )
field(:notification_pending_membership, :notification_pending_enum,
description:
"When does the user receives a notification about a new pending membership in one of the group they're admin for"
)
end end
enum :notification_pending_participation_enum do enum :notification_pending_enum do
value(:none, as: :none) value(:none, as: :none)
value(:direct, as: :direct) value(:direct, as: :direct)
value(:one_hour, as: :one_hour) value(:one_hour, as: :one_hour)
@ -258,7 +263,8 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
arg(:notification_on_day, :boolean) arg(:notification_on_day, :boolean)
arg(:notification_each_week, :boolean) arg(:notification_each_week, :boolean)
arg(:notification_before_event, :boolean) arg(:notification_before_event, :boolean)
arg(:notification_pending_participation, :notification_pending_participation_enum) arg(:notification_pending_participation, :notification_pending_enum)
arg(:notification_pending_membership, :notification_pending_enum)
resolve(&User.set_user_setting/3) resolve(&User.set_user_setting/3)
end end

View File

@ -84,7 +84,14 @@ defmodule Mobilizon.Actors.Actor do
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@update_required_attrs @required_attrs -- [:url] @update_required_attrs @required_attrs -- [:url]
@update_optional_attrs [:name, :summary, :manually_approves_followers, :user_id, :visibility] @update_optional_attrs [
:name,
:summary,
:manually_approves_followers,
:user_id,
:visibility,
:openness
]
@update_attrs @update_required_attrs ++ @update_optional_attrs @update_attrs @update_required_attrs ++ @update_optional_attrs
@registration_required_attrs [:preferred_username, :keys, :suspended, :url, :type] @registration_required_attrs [:preferred_username, :keys, :suspended, :url, :type]
@ -113,7 +120,8 @@ defmodule Mobilizon.Actors.Actor do
:name, :name,
:summary, :summary,
:manually_approves_followers, :manually_approves_followers,
:visibility :visibility,
:openness
] ]
@remote_actor_creation_attrs @remote_actor_creation_required_attrs ++ @remote_actor_creation_attrs @remote_actor_creation_required_attrs ++
@remote_actor_creation_optional_attrs @remote_actor_creation_optional_attrs
@ -349,6 +357,17 @@ defmodule Mobilizon.Actors.Actor do
|> put_change(:inbox_url, build_url(username, :inbox)) |> put_change(:inbox_url, build_url(username, :inbox))
|> put_change(:shared_inbox_url, "#{Endpoint.url()}/inbox") |> put_change(:shared_inbox_url, "#{Endpoint.url()}/inbox")
|> put_change(:members_url, if(type == :Group, do: build_url(username, :members), else: nil)) |> put_change(:members_url, if(type == :Group, do: build_url(username, :members), else: nil))
|> put_change(
:resources_url,
if(type == :Group, do: build_url(username, :resources), else: nil)
)
|> put_change(:todos_url, if(type == :Group, do: build_url(username, :todos), else: nil))
|> put_change(:posts_url, if(type == :Group, do: build_url(username, :posts), else: nil))
|> put_change(:events_url, if(type == :Group, do: build_url(username, :events), else: nil))
|> put_change(
:discussions_url,
if(type == :Group, do: build_url(username, :discussions), else: nil)
)
|> put_change(:url, build_url(username, :page)) |> put_change(:url, build_url(username, :page))
end end

View File

@ -640,6 +640,15 @@ defmodule Mobilizon.Actors do
|> Repo.stream() |> Repo.stream()
end end
@doc """
Lists the groups.
"""
@spec list_groups_for_stream :: Enum.t()
def list_external_groups_for_stream do
external_groups_query()
|> Repo.stream()
end
@doc """ @doc """
Returns the list of groups an actor is member of. Returns the list of groups an actor is member of.
""" """
@ -720,6 +729,13 @@ defmodule Mobilizon.Actors do
) )
end end
@doc """
Gets the default member role depending on the event join options.
"""
@spec get_default_member_role(Actor.t()) :: :member | :not_approved
def get_default_member_role(%Actor{openness: :open}), do: :member
def get_default_member_role(%Actor{openness: _}), do: :not_approved
@doc """ @doc """
Gets a single member of an actor (for example a group). Gets a single member of an actor (for example a group).
""" """
@ -859,7 +875,7 @@ defmodule Mobilizon.Actors do
end end
@doc """ @doc """
Returns the list of administrator members for a group. Returns a paginated list of administrator members for a group.
""" """
@spec list_administrator_members_for_group(integer | String.t(), integer | nil, integer | nil) :: @spec list_administrator_members_for_group(integer | String.t(), integer | nil, integer | nil) ::
Page.t() Page.t()
@ -869,6 +885,16 @@ defmodule Mobilizon.Actors do
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@doc """
Returns the complete list of administrator members for a group.
"""
@spec list_all_administrator_members_for_group(integer | String.t()) :: [Member.t()]
def list_all_administrator_members_for_group(id) do
id
|> administrator_members_for_group_query()
|> Repo.all()
end
@doc """ @doc """
Returns the list of all group ids where the actor_id is the last administrator. Returns the list of all group ids where the actor_id is the last administrator.
""" """
@ -1400,6 +1426,11 @@ defmodule Mobilizon.Actors do
) )
end end
@spec external_groups_query :: Ecto.Query.t()
defp external_groups_query do
where(Actor, [a], a.type == ^:Group and not is_nil(a.domain))
end
@spec list_members_for_user_query(integer()) :: Ecto.Query.t() @spec list_members_for_user_query(integer()) :: Ecto.Query.t()
defp list_members_for_user_query(user_id) do defp list_members_for_user_query(user_id) do
from( from(
@ -1422,11 +1453,10 @@ defmodule Mobilizon.Actors do
@spec members_for_group_query(integer | String.t()) :: Ecto.Query.t() @spec members_for_group_query(integer | String.t()) :: Ecto.Query.t()
defp members_for_group_query(group_id) do defp members_for_group_query(group_id) do
from( Member
m in Member, |> where(parent_id: ^group_id)
where: m.parent_id == ^group_id, |> order_by(desc: :updated_at)
preload: [:parent, :actor] |> preload([:parent, :actor])
)
end end
@spec group_external_member_actor_query(integer()) :: Ecto.Query.t() @spec group_external_member_actor_query(integer()) :: Ecto.Query.t()

View File

@ -4,26 +4,7 @@ defmodule Mobilizon.Cldr do
""" """
use Cldr, use Cldr,
locales: [ locales: Application.get_env(:mobilizon, :cldr)[:locales],
"ar",
"be",
"ca",
"cs",
"de",
"en",
"es",
"fi",
"fr",
"gl",
"it",
"ja",
"nl",
"oc",
"pl",
"pt",
"ru",
"sv"
],
gettext: Mobilizon.Web.Gettext, gettext: Mobilizon.Web.Gettext,
providers: [Cldr.Number, Cldr.Calendar, Cldr.DateTime, Cldr.Language] providers: [Cldr.Number, Cldr.Calendar, Cldr.DateTime, Cldr.Language]
end end

View File

@ -558,6 +558,8 @@ defmodule Mobilizon.Events do
@doc """ @doc """
Gets an existing tag or creates the new one. Gets an existing tag or creates the new one.
From a map containing a %{"name" => "#mytag"} or a direct binary
""" """
@spec get_or_create_tag(map) :: {:ok, Tag.t()} | {:error, Changeset.t()} @spec get_or_create_tag(map) :: {:ok, Tag.t()} | {:error, Changeset.t()}
def get_or_create_tag(%{"name" => "#" <> title}) do def get_or_create_tag(%{"name" => "#" <> title}) do
@ -570,9 +572,6 @@ defmodule Mobilizon.Events do
end end
end end
@doc """
Gets an existing tag or creates the new one.
"""
@spec get_or_create_tag(String.t()) :: {:ok, Tag.t()} | {:error, Changeset.t()} @spec get_or_create_tag(String.t()) :: {:ok, Tag.t()} | {:error, Changeset.t()}
def get_or_create_tag(title) do def get_or_create_tag(title) do
case Repo.get_by(Tag, title: title) do case Repo.get_by(Tag, title: title) do

View File

@ -13,6 +13,7 @@ defmodule Mobilizon.Users.Setting do
notification_each_week: boolean, notification_each_week: boolean,
notification_before_event: boolean, notification_before_event: boolean,
notification_pending_participation: NotificationPendingNotificationDelay.t(), notification_pending_participation: NotificationPendingNotificationDelay.t(),
notification_pending_membership: NotificationPendingNotificationDelay.t(),
user: User.t() user: User.t()
} }
@ -23,7 +24,8 @@ defmodule Mobilizon.Users.Setting do
:notification_on_day, :notification_on_day,
:notification_each_week, :notification_each_week,
:notification_before_event, :notification_before_event,
:notification_pending_participation :notification_pending_participation,
:notification_pending_membership
] ]
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@ -39,6 +41,10 @@ defmodule Mobilizon.Users.Setting do
default: :one_day default: :one_day
) )
field(:notification_pending_membership, NotificationPendingNotificationDelay,
default: :one_day
)
belongs_to(:user, User, primary_key: true, type: :id, foreign_key: :id, define_field: false) belongs_to(:user, User, primary_key: true, type: :id, foreign_key: :id, define_field: false)
timestamps() timestamps()

View File

@ -64,7 +64,7 @@ defmodule Mobilizon.Service.Export.ICalendar do
end end
@doc """ @doc """
Create cache for an actor Create cache for an actor, an event or an user token
""" """
def create_cache("actor_" <> name) do def create_cache("actor_" <> name) do
with %Actor{} = actor <- Actors.get_local_actor_by_name(name), with %Actor{} = actor <- Actors.get_local_actor_by_name(name),
@ -76,9 +76,6 @@ defmodule Mobilizon.Service.Export.ICalendar do
end end
end end
@doc """
Create cache for an actor
"""
def create_cache("event_" <> uuid) do def create_cache("event_" <> uuid) do
with %Event{} = event <- Events.get_public_event_by_uuid_with_preload(uuid), with %Event{} = event <- Events.get_public_event_by_uuid_with_preload(uuid),
{:ok, res} <- export_public_event(event) do {:ok, res} <- export_public_event(event) do
@ -89,9 +86,6 @@ defmodule Mobilizon.Service.Export.ICalendar do
end end
end end
@doc """
Create cache for an actor
"""
def create_cache("token_" <> token) do def create_cache("token_" <> token) do
case fetch_events_from_token(token) do case fetch_events_from_token(token) do
{:ok, res} -> {:ok, res} ->

View File

@ -4,7 +4,7 @@ defmodule Mobilizon.Service.Notifications.Scheduler do
""" """
alias Mobilizon.{Actors, Users} alias Mobilizon.{Actors, Users}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Service.Workers.Notification alias Mobilizon.Service.Workers.Notification
alias Mobilizon.Users.{Setting, User} alias Mobilizon.Users.{Setting, User}
@ -193,6 +193,77 @@ defmodule Mobilizon.Service.Notifications.Scheduler do
def pending_participation_notification(_), do: {:ok, nil} def pending_participation_notification(_), do: {:ok, nil}
def pending_membership_notification(%Actor{type: :Group, id: group_id}) do
group_id
|> Actors.list_all_administrator_members_for_group()
|> Enum.map(fn %Member{actor: %Actor{id: actor_id}} ->
Actors.get_actor(actor_id)
end)
|> Enum.each(fn actor -> pending_membership_admin_notification(actor, group_id) end)
end
def pending_membership_notification(_), do: {:ok, nil}
defp pending_membership_admin_notification(%Actor{user_id: user_id}, group_id)
when not is_nil(user_id) do
case Users.get_user_with_settings!(user_id) do
%User{} = user ->
pending_membership_admin_notification_user(user, group_id)
# No user for actor, probably a remote actor, ignore
_ ->
{:ok, nil}
end
end
defp pending_membership_admin_notification_user(
%User{
id: user_id,
locale: locale,
settings: %Setting{
notification_pending_membership: notification_pending_membership,
timezone: timezone
}
},
group_id
) do
send_at =
case notification_pending_membership do
:none ->
nil
:direct ->
:direct
:one_day ->
calculate_next_day_notification(Date.utc_today(), timezone, locale)
:one_hour ->
DateTime.utc_now()
|> DateTime.shift_zone!(timezone)
|> (&%{&1 | minute: 0, second: 0, microsecond: {0, 0}}).()
end
params = %{
user_id: user_id,
group_id: group_id
}
cond do
# Sending directly
send_at == :direct ->
Notification.enqueue(:pending_membership_notification, params)
# Not sending
is_nil(send_at) ->
{:ok, nil}
# Sending to calculated time
true ->
Notification.enqueue(:pending_membership_notification, params, scheduled_at: send_at)
end
end
defp shift_zone(datetime, timezone) do defp shift_zone(datetime, timezone) do
case DateTime.shift_zone(datetime, timezone) do case DateTime.shift_zone(datetime, timezone) do
{:ok, shift_datetime} -> shift_datetime {:ok, shift_datetime} -> shift_datetime

View File

@ -0,0 +1,12 @@
defmodule Mobilizon.Service.Workers.RefreshGroups do
@moduledoc """
Worker to build sitemap
"""
alias Mobilizon.Federation.ActivityPub.Refresher
use Oban.Worker, queue: "background"
@impl Oban.Worker
def perform(%Job{}), do: Refresher.refresh_all_external_groups()
end

View File

@ -15,7 +15,9 @@ defmodule Mobilizon.Web.Email.Participation do
alias Mobilizon.Web.{Email, Gettext} alias Mobilizon.Web.{Email, Gettext}
@doc """ @doc """
Send emails to local user Send participation emails to local user
If the actor is anonymous, use information in metadata
""" """
def send_emails_to_local_user( def send_emails_to_local_user(
%Participant{actor: %Actor{user_id: nil, id: actor_id} = _actor} = participation %Participant{actor: %Actor{user_id: nil, id: actor_id} = _actor} = participation
@ -32,9 +34,6 @@ defmodule Mobilizon.Web.Email.Participation do
:ok :ok
end end
@doc """
Send emails to local user
"""
def send_emails_to_local_user( def send_emails_to_local_user(
%Participant{actor: %Actor{user_id: user_id} = _actor} = participation %Participant{actor: %Actor{user_id: user_id} = _actor} = participation
) do ) do

View File

@ -10,6 +10,7 @@ defmodule Mobilizon.Mixfile do
elixir: "~> 1.8", elixir: "~> 1.8",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(), compilers: [:phoenix, :gettext] ++ Mix.compilers(),
xref: [exclude: [:eldap]],
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
aliases: aliases(), aliases: aliases(),
deps: deps(), deps: deps(),

View File

@ -0,0 +1,9 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddNotificationPendingMembershipToSettings do
use Ecto.Migration
def change do
alter table(:user_settings) do
add(:notification_pending_membership, :integer, default: 10)
end
end
end

View File

@ -0,0 +1,28 @@
defmodule Mobilizon.Storage.Repo.Migrations.RepairGroupsWithMissingURLs do
use Ecto.Migration
alias Mobilizon.Actors.Actor
alias Mobilizon.Storage.Repo
import Ecto.{Changeset, Query}
def up do
Actor
|> where([a], a.type == :Group and is_nil(a.domain))
|> Repo.all()
|> Enum.each(&fix_group/1)
end
def down do
IO.puts("Nothing to revert here, this is a repair step.")
end
defp fix_group(%Actor{type: :Group, domain: nil, url: url} = group) do
group
|> change(%{})
|> put_change(:resources_url, "#{url}/resources")
|> put_change(:todos_url, "#{url}/todos")
|> put_change(:posts_url, "#{url}/posts")
|> put_change(:events_url, "#{url}/events")
|> put_change(:discussions_url, "#{url}/discussions")
|> Repo.update()
end
end

View File

@ -14,15 +14,10 @@ defmodule Mobilizon.GraphQL.Resolvers.MemberTest do
end end
describe "Member Resolver to join a group" do describe "Member Resolver to join a group" do
test "join_group/3 should create a member", %{conn: conn, user: user, actor: actor} do @join_group_mutation """
group = insert(:group) mutation JoinGroup($groupId: ID!) {
joinGroup(groupId: $groupId) {
mutation = """ id
mutation {
joinGroup(
actor_id: #{actor.id},
group_id: #{group.id}
) {
role, role,
actor { actor {
id id
@ -34,108 +29,63 @@ defmodule Mobilizon.GraphQL.Resolvers.MemberTest do
} }
""" """
res = test "join_group/3 should create a member", %{conn: conn, user: user, actor: actor} do
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinGroup"]["role"] == "NOT_APPROVED"
assert json_response(res, 200)["data"]["joinGroup"]["parent"]["id"] == to_string(group.id)
assert json_response(res, 200)["data"]["joinGroup"]["actor"]["id"] == to_string(actor.id)
mutation = """
mutation {
joinGroup(
actor_id: #{actor.id},
group_id: #{group.id}
) {
role
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] =~ "already a member"
end
test "join_group/3 should check the actor is owned by the user", %{
conn: conn,
user: user
} do
group = insert(:group) group = insert(:group)
mutation = """ res =
mutation { conn
joinGroup( |> auth_conn(user)
actor_id: 1042, |> AbsintheHelpers.graphql_query(
group_id: #{group.id} query: @join_group_mutation,
) { variables: %{groupId: group.id}
role )
}
} assert res["errors"] == nil
""" assert res["data"]["joinGroup"]["role"] == "NOT_APPROVED"
assert res["data"]["joinGroup"]["parent"]["id"] == to_string(group.id)
assert res["data"]["joinGroup"]["actor"]["id"] == to_string(actor.id)
res = res =
conn conn
|> auth_conn(user) |> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> AbsintheHelpers.graphql_query(
query: @join_group_mutation,
variables: %{groupId: group.id}
)
assert hd(json_response(res, 200)["errors"])["message"] =~ "not owned" assert hd(res["errors"])["message"] =~ "already a member"
end end
test "join_group/3 should check the group is not invite only", %{ test "join_group/3 should check the group is not invite only", %{
conn: conn, conn: conn,
actor: actor,
user: user user: user
} do } do
group = insert(:group, %{openness: :invite_only}) group = insert(:group, %{openness: :invite_only})
mutation = """
mutation {
joinGroup(
actor_id: #{actor.id},
group_id: #{group.id}
) {
role
}
}
"""
res = res =
conn conn
|> auth_conn(user) |> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> AbsintheHelpers.graphql_query(
query: @join_group_mutation,
variables: %{groupId: group.id}
)
assert hd(json_response(res, 200)["errors"])["message"] =~ "cannot join this group" assert hd(res["errors"])["message"] =~ "cannot join this group"
end end
test "join_group/3 should check the group exists", %{ test "join_group/3 should check the group exists", %{
conn: conn, conn: conn,
user: user, user: user
actor: actor
} do } do
mutation = """
mutation {
joinGroup(
actor_id: #{actor.id},
group_id: 1042
) {
role
}
}
"""
res = res =
conn conn
|> auth_conn(user) |> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> AbsintheHelpers.graphql_query(
query: @join_group_mutation,
variables: %{groupId: 1042}
)
assert hd(json_response(res, 200)["errors"])["message"] =~ "Group not found" assert hd(res["errors"])["message"] =~ "Group not found"
end end
end end

View File

@ -30,6 +30,7 @@ defmodule Mobilizon.Factory do
notification_each_week: false, notification_each_week: false,
notification_before_event: false, notification_before_event: false,
notification_pending_participation: :one_day, notification_pending_participation: :one_day,
notification_pending_membership: :one_day,
user_id: nil user_id: nil
} }
end end