Merge branch 'group-admin-profiles' into 'master'

Add group admin profiles

See merge request framasoft/mobilizon!551
This commit is contained in:
Thomas Citharel 2020-08-27 12:28:07 +02:00
commit c5937bbccc
107 changed files with 3514 additions and 1146 deletions

View file

@ -1,24 +1,14 @@
@import "variables.scss";
a {
color: $violet-2;
}
a.out,
.content a {
text-decoration: underline;
text-decoration-color: #ed8d07;
text-decoration-thickness: 2px;
&.navbar-item,
&.dropdown-item,
&.card,
&.button,
&[href="#comments"],
&.router-link-active,
&.comment-link,
&.pagination-link,
&.datepicker-cell,
&.list-item {
text-decoration: none;
}
}
nav.breadcrumb ul li a {
text-decoration: none;
}
input.input {

View file

@ -15,8 +15,8 @@
<div class="title-info-wrapper">
<div class="title-and-date">
<p class="discussion-minimalist-title">{{ discussion.title }}</p>
<span :title="discussion.updatedAt | formatDateTimeString">
{{ $timeAgo.format(new Date(discussion.updatedAt), "twitter") || $t("Right now") }}</span
<span :title="actualDate | formatDateTimeString">
{{ $timeAgo.format(new Date(actualDate), "twitter") || $t("Right now") }}</span
>
</div>
<div class="has-text-grey" v-if="!discussion.lastComment.deletedAt">
@ -46,6 +46,13 @@ export default class DiscussionListItem extends Vue {
}
return element.innerText;
}
get actualDate() {
if (this.discussion.updatedAt === this.discussion.insertedAt && this.discussion.lastComment) {
return this.discussion.lastComment.publishedAt;
}
return this.discussion.updatedAt;
}
}
</script>
<style lang="scss" scoped>

View file

@ -37,6 +37,7 @@
<SettingMenuItem :title="$t('Moderation log')" :to="{ name: RouteName.REPORT_LOGS }" />
<SettingMenuItem :title="$t('Users')" :to="{ name: RouteName.USERS }" />
<SettingMenuItem :title="$t('Profiles')" :to="{ name: RouteName.PROFILES }" />
<SettingMenuItem :title="$t('Groups')" :to="{ name: RouteName.ADMIN_GROUPS }" />
</SettingMenuSection>
<SettingMenuSection
v-if="this.currentUser.role == ICurrentUserRole.ADMINISTRATOR"

View file

@ -5,6 +5,7 @@ export const DISCUSSION_BASIC_FIELDS_FRAGMENT = gql`
id
title
slug
insertedAt
updatedAt
lastComment {
id
@ -16,6 +17,7 @@ export const DISCUSSION_BASIC_FIELDS_FRAGMENT = gql`
url
}
}
publishedAt
deletedAt
}
}
@ -114,6 +116,7 @@ export const GET_DISCUSSION = gql`
insertedAt
updatedAt
deletedAt
publishedAt
}
}
...DiscussionFields

View file

@ -4,8 +4,24 @@ import { RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT } from "./resources";
import { POST_BASIC_FIELDS } from "./post";
export const LIST_GROUPS = gql`
query {
groups {
query ListGroups(
$preferredUsername: String
$name: String
$domain: String
$local: Boolean
$suspended: Boolean
$page: Int
$limit: Int
) {
groups(
preferredUsername: $preferredUsername
name: $name
domain: $domain
local: $local
suspended: $suspended
page: $page
limit: $limit
) {
elements {
id
url
@ -34,9 +50,8 @@ export const LIST_GROUPS = gql`
}
`;
export const FETCH_GROUP = gql`
query($name: String!) {
group(preferredUsername: $name) {
export const GROUP_FIELDS_FRAGMENTS = gql`
fragment GroupFullFields on Group {
id
url
name
@ -136,7 +151,27 @@ export const FETCH_GROUP = gql`
total
}
}
`;
export const FETCH_GROUP = gql`
query($name: String!) {
group(preferredUsername: $name) {
...GroupFullFields
}
}
${GROUP_FIELDS_FRAGMENTS}
${DISCUSSION_BASIC_FIELDS_FRAGMENT}
${POST_BASIC_FIELDS}
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}
`;
export const GET_GROUP = gql`
query($id: ID!) {
getGroup(id: $id) {
...GroupFullFields
}
}
${GROUP_FIELDS_FRAGMENTS}
${DISCUSSION_BASIC_FIELDS_FRAGMENT}
${POST_BASIC_FIELDS}
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}
@ -162,6 +197,7 @@ export const CREATE_GROUP = gql`
id
preferredUsername
name
domain
summary
avatar {
url
@ -206,6 +242,14 @@ export const UPDATE_GROUP = gql`
}
`;
export const DELETE_GROUP = gql`
mutation DeleteGroup($groupId: ID!) {
deleteGroup(groupId: $groupId) {
id
}
}
`;
export const LEAVE_GROUP = gql`
mutation LeaveGroup($groupId: ID!) {
leaveGroup(groupId: $groupId) {
@ -213,3 +257,11 @@ export const LEAVE_GROUP = gql`
}
}
`;
export const REFRESH_PROFILE = gql`
mutation RefreshProfile($actorId: ID!) {
refreshProfile(id: $actorId) {
id
}
}
`;

View file

@ -160,7 +160,6 @@
"Go": "Go",
"Going as {name}": "Going as {name}",
"Group List": "Group List",
"Group full name": "Group full name",
"Group name": "Group name",
"Group {displayName} created": "Group {displayName} created",
"Groups": "Groups",
@ -766,5 +765,19 @@
"Edited {ago}": "Edited {ago}",
"[This comment has been deleted by it's author]": "[This comment has been deleted by it's author]",
"Promote": "Promote",
"Demote": "Demote"
"Demote": "Demote",
"{number} members": "{number} members",
"{number} posts": "No posts|One post|{number} posts",
"Publication date": "Publication date",
"Refresh profile": "Refresh profile",
"Suspend group": "Suspend group",
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.": "Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.",
"Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.",
"Delete group": "Delete group",
"Are you sure you want to <b>completely delete</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Are you sure you want to <b>completely delete</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.",
"Group display name": "Group display name",
"Federated Group Name": "Federated Group Name",
"This is like your federated username (<code>{username}</code>) for groups. It will allow you to be found on the federation, and is guaranteed to be unique.": "This is like your federated username (<code>{username}</code>) for groups. It will allow you to be found on the federation, and is guaranteed to be unique.",
"Banner": "Banner",
"A group with this name already exists": "A group with this name already exists"
}

View file

@ -243,11 +243,10 @@
"Group List": "Liste de groupes",
"Group Members": "Membres du groupe",
"Group address": "Adresse du groupe",
"Group full name": "Nom complet du groupe",
"Group name": "Nom du groupe",
"Group settings": "Paramètres du groupe",
"Group short description": "Description courte du groupe",
"Group visibility": "Visibility du groupe",
"Group visibility": "Visibilité du groupe",
"Group {displayName} created": "Groupe {displayName} créé",
"Groups": "Groupes",
"Headline picture": "Image à la une",
@ -301,7 +300,7 @@
"Language": "Langue",
"Last published event": "Dernier évènement publié",
"Last week": "La semaine dernière",
"Latest posts": "Derniers messages publics",
"Latest posts": "Derniers billets",
"Learn more": "En apprendre plus",
"Learn more about Mobilizon": "En apprendre plus à propos de Mobilizon",
"Leave event": "Annuler ma participation à l'évènement",
@ -767,5 +766,19 @@
"Edited {ago}": "Édité {ago}",
"[This comment has been deleted by it's author]": "[Ce commentaire a été supprimé par son auteur]",
"Promote": "Promouvoir",
"Demote": "Rétrograder"
"Demote": "Rétrograder",
"{number} members": "{number} membres",
"{number} posts": "Aucun billet|Un billet|{number} billets",
"Publication date": "Date de publication",
"Refresh profile": "Rafraîchir le profil",
"Suspend group": "Suspendre le groupe",
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.": "Êtes-vous certain·e de vouloir <b>suspendre</b> ce groupe ? Comme ce groupe provient de l'instance {instance}, cela supprimera seulement les membres locaux et supprimera les données locales, et rejettera également toutes les données futures.",
"Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Êtes-vous certain·e de vouloir <b>suspendre</b> ce groupe ? Tous les membres - y compris celleux sur d'autres instances - seront notifié·es et supprimé·es du groupe, et <b>toutes les données associées au groupe (événements, billets, discussions, todos…) seront irrémédiablement détruites</b>.",
"Delete group": "Supprimer le groupe",
"Are you sure you want to <b>completely delete</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Êtes-vous certain·e de vouloir <b>complètement supprimer</b> ce groupe ? Tous les membres - y compris celleux sur d'autres instances - seront notifié·es et supprimé·es du groupe, et <b>toutes les données associées au groupe (événements, billets, discussions, todos…) seront irrémédiablement détruites</b>.",
"Group display name": "Nom d'affichage du groupe",
"Federated Group Name": "Nom fédéré du groupe",
"This is like your federated username (<code>{username}</code>) for groups. It will allow you to be found on the federation, and is guaranteed to be unique.": "C'est comme votre addresse fédérée (<code>{username}</code>) pour les groupes. Cela vous permettra d'être trouvable sur la fédération, et est garanti d'être unique.",
"Banner": "Bannière",
"A group with this name already exists": "Un groupe avec ce nom existe déjà"
}

View file

@ -1,7 +1,8 @@
import { Component, Mixins, Vue } from "vue-property-decorator";
import { Person } from "@/types/actor";
@Component({})
// TODO: Refactor into js/src/utils/username.ts
@Component
export default class IdentityEditionMixin extends Mixins(Vue) {
identity: Person = new Person();

View file

@ -16,6 +16,8 @@ import Users from "../views/Admin/Users.vue";
import Profiles from "../views/Admin/Profiles.vue";
import AdminProfile from "../views/Admin/AdminProfile.vue";
import AdminUserProfile from "../views/Admin/AdminUserProfile.vue";
import GroupProfiles from "../views/Admin/GroupProfiles.vue";
import AdminGroupProfile from "../views/Admin/AdminGroupProfile.vue";
export enum SettingsRouteName {
SETTINGS = "SETTINGS",
@ -33,6 +35,8 @@ export enum SettingsRouteName {
PROFILES = "PROFILES",
ADMIN_PROFILE = "ADMIN_PROFILE",
ADMIN_USER_PROFILE = "ADMIN_USER_PROFILE",
ADMIN_GROUPS = "ADMIN_GROUPS",
ADMIN_GROUP_PROFILE = "ADMIN_GROUP_PROFILE",
MODERATION = "MODERATION",
REPORTS = "Reports",
REPORT = "Report",
@ -123,6 +127,20 @@ export const settingsRoutes: RouteConfig[] = [
props: true,
meta: { requiredAuth: true },
},
{
path: "admin/groups",
name: SettingsRouteName.ADMIN_GROUPS,
component: GroupProfiles,
props: true,
meta: { requiredAuth: true },
},
{
path: "admin/groups/:id",
name: SettingsRouteName.ADMIN_GROUP_PROFILE,
component: AdminGroupProfile,
props: true,
meta: { requiredAuth: true },
},
{
path: "admin/relays",
name: SettingsRouteName.RELAYS,

View file

@ -60,9 +60,11 @@ export class Actor implements IActor {
}
}
export function usernameWithDomain(actor: IActor): string {
export function usernameWithDomain(actor: IActor, force = false): string {
if (actor.domain) {
return `${actor.preferredUsername}@${actor.domain}`;
} else if (force) {
return `${actor.preferredUsername}@${window.location.hostname}`;
}
return actor.preferredUsername;
}

View file

@ -16,6 +16,7 @@ export interface IComment {
deletedAt?: Date | string;
totalReplies: number;
insertedAt?: Date | string;
publishedAt?: Date | string;
}
export class CommentModel implements IComment {

View file

@ -10,6 +10,8 @@ export interface IDiscussion {
actor?: IActor;
lastComment?: IComment;
comments: Paginate<IComment>;
updatedAt: string;
insertedAt: string;
}
export class Discussion implements IDiscussion {
@ -27,6 +29,10 @@ export class Discussion implements IDiscussion {
lastComment?: IComment = undefined;
insertedAt: string = "";
updatedAt: string = "";
constructor(hash?: IDiscussion) {
if (!hash) return;
@ -40,5 +46,7 @@ export class Discussion implements IDiscussion {
this.creator = hash.creator;
this.actor = hash.actor;
this.lastComment = hash.lastComment;
this.insertedAt = hash.insertedAt;
this.updatedAt = hash.updatedAt;
}
}

29
js/src/utils/username.ts Normal file
View file

@ -0,0 +1,29 @@
import { IActor } from "@/types/actor";
function autoUpdateUsername(actor: IActor, newDisplayName: string | null): IActor {
const oldUsername = convertToUsername(actor.name);
if (actor.preferredUsername === oldUsername) {
actor.preferredUsername = convertToUsername(newDisplayName);
}
return actor;
}
function convertToUsername(value: string | null): string {
if (!value) return "";
// https://stackoverflow.com/a/37511463
return value
.toLocaleLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ /g, "_")
.replace(/[^a-z0-9_]/g, "");
}
function validateUsername(actor: IActor): boolean {
return actor.preferredUsername === convertToUsername(actor.preferredUsername);
}
export { autoUpdateUsername, convertToUsername, validateUsername };

View file

@ -50,11 +50,11 @@
</form>
<div v-if="validationSent && !userAlreadyActivated">
<b-message title="Success" type="is-success" closable="false">
<b-message type="is-success" closable="false">
<h2 class="title">
{{
$t("Your account is nearly ready, {username}", {
username: identity.preferredUsername,
username: identity.name || identity.preferredUsername,
})
}}
</h2>

View file

@ -150,6 +150,7 @@ import identityEditionMixin from "../../../mixins/identityEdition";
},
identity: {
query: FETCH_PERSON,
fetchPolicy: "cache-and-network",
variables() {
return {
username: this.identityName,

View file

@ -0,0 +1,436 @@
<template>
<div v-if="group" class="section">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.ADMIN }">{{ $t("Admin") }}</router-link>
</li>
<li>
<router-link
:to="{
name: RouteName.ADMIN_GROUPS,
}"
>{{ $t("Groups") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
name: RouteName.PROFILES,
params: { id: group.id },
}"
>{{ group.name || group.preferredUsername }}</router-link
>
</li>
</ul>
</nav>
<div class="actor-card">
<router-link
:to="{ name: RouteName.GROUP, params: { preferredUsername: usernameWithDomain(group) } }"
>
<actor-card :actor="group" :full="true" :popover="false" :limit="false" />
</router-link>
</div>
<table v-if="metadata.length > 0" class="table is-fullwidth">
<tbody>
<tr v-for="{ key, value, link } in metadata" :key="key">
<td>{{ key }}</td>
<td v-if="link">
<router-link :to="link">
{{ value }}
</router-link>
</td>
<td v-else>{{ value }}</td>
</tr>
</tbody>
</table>
<div class="buttons">
<b-button @click="confirmSuspendProfile" v-if="!group.suspended" type="is-primary">{{
$t("Suspend")
}}</b-button>
<b-button @click="unsuspendProfile" v-if="group.suspended" type="is-primary">{{
$t("Unsuspend")
}}</b-button>
<b-button @click="refreshProfile" v-if="group.domain" type="is-primary" outlined>{{
$t("Refresh profile")
}}</b-button>
</div>
<section>
<h2 class="subtitle">
{{
$tc("{number} members", group.members.total, {
number: group.members.total,
})
}}
</h2>
<b-table
:data="group.members.elements"
:loading="$apollo.queries.group.loading"
paginated
backend-pagination
:total="group.members.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onMembersPageChange"
>
<b-table-column field="actor.preferredUsername" :label="$t('Member')" v-slot="props">
<article class="media">
<figure class="media-left image is-48x48" v-if="props.row.actor.avatar">
<img class="is-rounded" :src="props.row.actor.avatar.url" alt="" />
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<div class="content">
<span v-if="props.row.actor.name">{{ props.row.actor.name }}</span
><br />
<span class="is-size-7 has-text-grey"
>@{{ usernameWithDomain(props.row.actor) }}</span
>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="role" :label="$t('Role')" v-slot="props">
<b-tag type="is-primary" v-if="props.row.role === MemberRole.ADMINISTRATOR">
{{ $t("Administrator") }}
</b-tag>
<b-tag type="is-primary" v-else-if="props.row.role === MemberRole.MODERATOR">
{{ $t("Moderator") }}
</b-tag>
<b-tag v-else-if="props.row.role === MemberRole.MEMBER">
{{ $t("Member") }}
</b-tag>
<b-tag type="is-warning" v-else-if="props.row.role === MemberRole.NOT_APPROVED">
{{ $t("Not approved") }}
</b-tag>
<b-tag type="is-danger" v-else-if="props.row.role === MemberRole.REJECTED">
{{ $t("Rejected") }}
</b-tag>
<b-tag type="is-danger" v-else-if="props.row.role === MemberRole.INVITED">
{{ $t("Invited") }}
</b-tag>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
}}
</span>
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>{{ $t("Nothing to see here") }}</p>
</div>
</section>
</template>
</b-table>
</section>
<section>
<h2 class="subtitle">
{{
$tc("{number} organized events", group.organizedEvents.total, {
number: group.organizedEvents.total,
})
}}
</h2>
<b-table
:data="group.organizedEvents.elements"
:loading="$apollo.queries.group.loading"
paginated
backend-pagination
:total="group.organizedEvents.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onOrganizedEventsPageChange"
>
<b-table-column field="title" :label="$t('Title')" v-slot="props">
<router-link :to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }">
{{ props.row.title }}
</router-link>
</b-table-column>
<b-table-column field="beginsOn" :label="$t('Begins on')" v-slot="props">
{{ props.row.beginsOn | formatDateTimeString }}
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>{{ $t("Nothing to see here") }}</p>
</div>
</section>
</template>
</b-table>
</section>
<section>
<h2 class="subtitle">
{{
$tc("{number} posts", group.posts.total, {
number: group.posts.total,
})
}}
</h2>
<b-table
:data="group.posts.elements"
:loading="$apollo.queries.group.loading"
paginated
backend-pagination
:total="group.posts.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onPostsPageChange"
>
<b-table-column field="title" :label="$t('Title')" v-slot="props">
<router-link :to="{ name: RouteName.POST, params: { slug: props.row.slug } }">
{{ props.row.title }}
</router-link>
</b-table-column>
<b-table-column field="publishAt" :label="$t('Publication date')" v-slot="props">
{{ props.row.publishAt | formatDateTimeString }}
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>{{ $t("Nothing to see here") }}</p>
</div>
</section>
</template>
</b-table>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
import { IGroup, MemberRole } from "../../types/actor";
import { usernameWithDomain, IActor } from "../../types/actor/actor.model";
import RouteName from "../../router/name";
import { IEvent } from "../../types/event.model";
import ActorCard from "../../components/Account/ActorCard.vue";
import { GET_GROUP, REFRESH_PROFILE } from "@/graphql/group";
const EVENTS_PER_PAGE = 10;
@Component({
apollo: {
group: {
query: GET_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.id,
organizedEventsPage: 1,
organizedEventsLimit: EVENTS_PER_PAGE,
};
},
skip() {
return !this.id;
},
update: (data) => data.getGroup,
},
},
components: {
ActorCard,
},
})
export default class AdminGroupProfile extends Vue {
@Prop({ required: true }) id!: string;
group!: IGroup;
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
EVENTS_PER_PAGE = EVENTS_PER_PAGE;
organizedEventsPage = 1;
membersPage = 1;
postsPage = 1;
MemberRole = MemberRole;
get metadata(): Array<object> {
if (!this.group) return [];
const res: object[] = [
{
key: this.$t("Status") as string,
value: this.group.suspended ? this.$t("Suspended") : this.$t("Active"),
},
{
key: this.$t("Domain") as string,
value: this.group.domain ? this.group.domain : this.$t("Local"),
},
];
return res;
}
confirmSuspendProfile() {
const message = (this.group.domain
? this.$t(
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.",
{ instance: this.group.domain }
)
: this.$t(
"Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>."
)) as string;
this.$buefy.dialog.confirm({
title: this.$t("Suspend group") as string,
message,
confirmText: this.$t("Suspend group") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
hasIcon: true,
onConfirm: () => this.suspendProfile(),
});
}
async suspendProfile() {
this.$apollo.mutate<{ suspendProfile: { id: string } }>({
mutation: SUSPEND_PROFILE,
variables: {
id: this.id,
},
update: (store, { data }) => {
if (data == null) return;
const profileId = this.id;
const profileData = store.readQuery<{ getGroup: IGroup }>({
query: GET_GROUP,
variables: {
id: profileId,
},
});
if (!profileData) return;
const { getGroup: group } = profileData;
group.suspended = true;
group.avatar = null;
group.name = "";
group.summary = "";
store.writeQuery({
query: GET_GROUP,
variables: {
id: profileId,
},
data: { getGroup: group },
});
},
});
}
async unsuspendProfile() {
const profileID = this.id;
this.$apollo.mutate<{ unsuspendProfile: { id: string } }>({
mutation: UNSUSPEND_PROFILE,
variables: {
id: this.id,
},
refetchQueries: [
{
query: GET_GROUP,
variables: {
id: profileID,
},
},
],
});
}
async refreshProfile() {
this.$apollo.mutate<{ refreshProfile: IActor }>({
mutation: REFRESH_PROFILE,
variables: {
actorId: this.id,
},
});
}
async onOrganizedEventsPageChange(page: number) {
this.organizedEventsPage = page;
await this.$apollo.queries.group.fetchMore({
variables: {
actorId: this.id,
organizedEventsPage: this.organizedEventsPage,
organizedEventsLimit: EVENTS_PER_PAGE,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newOrganizedEvents = fetchMoreResult.group.organizedEvents.elements;
return {
group: {
...previousResult.group,
organizedEvents: {
__typename: previousResult.group.organizedEvents.__typename,
total: previousResult.group.organizedEvents.total,
elements: [...previousResult.group.organizedEvents.elements, ...newOrganizedEvents],
},
},
};
},
});
}
async onMembersPageChange(page: number) {
this.membersPage = page;
await this.$apollo.queries.group.fetchMore({
variables: {
actorId: this.id,
memberPage: this.membersPage,
memberLimit: EVENTS_PER_PAGE,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newMembers = fetchMoreResult.group.members.elements;
return {
group: {
...previousResult.group,
members: {
__typename: previousResult.group.members.__typename,
total: previousResult.group.members.total,
elements: [...previousResult.group.members.elements, ...newMembers],
},
},
};
},
});
}
async onPostsPageChange(page: number) {
this.postsPage = page;
await this.$apollo.queries.group.fetchMore({
variables: {
actorId: this.id,
postsPage: this.postsPage,
postLimit: EVENTS_PER_PAGE,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newPosts = fetchMoreResult.group.posts.elements;
return {
group: {
...previousResult.group,
posts: {
__typename: previousResult.group.posts.__typename,
total: previousResult.group.posts.total,
elements: [...previousResult.group.posts.elements, ...newPosts],
},
},
};
},
});
}
}
</script>
<style lang="scss" scoped>
table,
section {
margin: 2rem 0;
}
.actor-card {
background: #fff;
padding: 1.5rem;
border-radius: 10px;
}
</style>

View file

@ -139,6 +139,7 @@ const EVENTS_PER_PAGE = 10;
apollo: {
person: {
query: GET_PERSON,
fetchPolicy: "cache-and-network",
variables() {
return {
actorId: this.id,

View file

@ -69,6 +69,7 @@ import { IPerson } from "../../types/actor";
apollo: {
user: {
query: GET_USER,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.id,

View file

@ -71,6 +71,7 @@ import RouteName from "../../router/name";
apollo: {
dashboard: {
query: DASHBOARD,
fetchPolicy: "cache-and-network",
},
},
metaInfo() {

View file

@ -0,0 +1,168 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MODERATION }">{{ $t("Moderation") }}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.PROFILES }">{{ $t("Groups") }}</router-link>
</li>
</ul>
</nav>
<div v-if="groups">
<b-switch v-model="local">{{ $t("Local") }}</b-switch>
<b-switch v-model="suspended">{{ $t("Suspended") }}</b-switch>
<b-table
:data="groups.elements"
:loading="$apollo.queries.groups.loading"
paginated
backend-pagination
backend-filtering
:total="groups.total"
:per-page="PROFILES_PER_PAGE"
@page-change="onPageChange"
@filters-change="onFiltersChange"
>
<b-table-column field="preferredUsername" :label="$t('Username')" searchable>
<template slot="searchable" slot-scope="props">
<b-input
v-model="props.filters.preferredUsername"
placeholder="Search..."
icon="magnify"
size="is-small"
/>
</template>
<template v-slot:default="props">
<router-link
class="profile"
:to="{ name: RouteName.ADMIN_GROUP_PROFILE, params: { id: props.row.id } }"
>
<article class="media">
<figure class="media-left" v-if="props.row.avatar">
<p class="image is-48x48">
<img :src="props.row.avatar.url" />
</p>
</figure>
<div class="media-content">
<div class="content">
<strong v-if="props.row.name">{{ props.row.name }}</strong
><br v-if="props.row.name" />
<small>@{{ props.row.preferredUsername }}</small>
</div>
</div>
</article>
</router-link>
</template>
</b-table-column>
<b-table-column field="domain" :label="$t('Domain')" searchable>
<template slot="searchable" slot-scope="props">
<b-input
v-model="props.filters.domain"
placeholder="Search..."
icon="magnify"
size="is-small"
/>
</template>
<template v-slot:default="props">
{{ props.row.domain }}
</template>
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>{{ $t("No profile matches the filters") }}</p>
</div>
</section>
</template>
</b-table>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { LIST_PROFILES } from "../../graphql/actor";
import RouteName from "../../router/name";
import { LIST_GROUPS } from "@/graphql/group";
const PROFILES_PER_PAGE = 10;
@Component({
apollo: {
groups: {
query: LIST_GROUPS,
fetchPolicy: "cache-and-network",
variables() {
return {
preferredUsername: this.preferredUsername,
name: this.name,
domain: this.domain,
local: this.local,
suspended: this.suspended,
page: 1,
limit: PROFILES_PER_PAGE,
};
},
},
},
})
export default class GroupProfiles extends Vue {
page = 1;
preferredUsername = "";
name = "";
domain = "";
local = true;
suspended = false;
PROFILES_PER_PAGE = PROFILES_PER_PAGE;
RouteName = RouteName;
async onPageChange(page: number) {
this.page = page;
await this.$apollo.queries.groups.fetchMore({
variables: {
preferredUsername: this.preferredUsername,
name: this.name,
domain: this.domain,
local: this.local,
suspended: this.suspended,
page: this.page,
limit: PROFILES_PER_PAGE,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newProfiles = fetchMoreResult.groups.elements;
return {
groups: {
__typename: previousResult.groups.__typename,
total: previousResult.groups.total,
elements: [...previousResult.groups.elements, ...newProfiles],
},
};
},
});
}
onFiltersChange({ preferredUsername, domain }: { preferredUsername: string; domain: string }) {
this.preferredUsername = preferredUsername;
this.domain = domain;
}
@Watch("domain")
domainNotLocal() {
this.local = this.domain === "";
}
}
</script>
<style lang="scss" scoped>
a.profile {
text-decoration: none;
}
</style>

View file

@ -91,6 +91,7 @@ const PROFILES_PER_PAGE = 10;
apollo: {
persons: {
query: LIST_PROFILES,
fetchPolicy: "cache-and-network",
variables() {
return {
preferredUsername: this.preferredUsername,

View file

@ -103,6 +103,7 @@ const USERS_PER_PAGE = 10;
apollo: {
users: {
query: LIST_USERS,
fetchPolicy: "cache-and-network",
variables() {
return {
email: this.email,

View file

@ -120,6 +120,7 @@ import { DELETE_COMMENT, UPDATE_COMMENT } from "@/graphql/comment";
apollo: {
discussion: {
query: GET_DISCUSSION,
fetchPolicy: "cache-and-network",
variables() {
return {
slug: this.slug,

View file

@ -578,6 +578,7 @@ import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
apollo: {
event: {
query: FETCH_EVENT,
fetchPolicy: "cache-and-network",
variables() {
return {
uuid: this.uuid,
@ -592,6 +593,7 @@ import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
},
participations: {
query: EVENT_PERSON_PARTICIPATION,
fetchPolicy: "cache-and-network",
variables() {
return {
eventId: this.event.id,

View file

@ -2,41 +2,64 @@
<section class="section container">
<h1>{{ $t("Create a new group") }}</h1>
<div>
<b-field :label="$t('Group name')">
<b-input aria-required="true" required v-model="group.preferredUsername" />
</b-field>
<b-message type="is-danger" v-for="(value, index) in errors" :key="index">
{{ value }}
</b-message>
<b-field :label="$t('Group full name')">
<form @submit.prevent="createGroup">
<b-field :label="$t('Group display name')">
<b-input aria-required="true" required v-model="group.name" />
</b-field>
<div class="field">
<label class="label">{{ $t("Federated Group Name") }}</label>
<div class="field-body">
<b-field>
<b-input aria-required="true" required expanded v-model="group.preferredUsername" />
<p class="control">
<span class="button is-static">@{{ host }}</span>
</p>
</b-field>
</div>
<p
v-html="
$t(
'This is like your federated username (<code>{username}</code>) for groups. It will allow you to be found on the federation, and is guaranteed to be unique.',
{ username: usernameWithDomain(currentActor, true) }
)
"
/>
</div>
<b-field :label="$t('Description')">
<b-input aria-required="true" required v-model="group.summary" type="textarea" />
<b-input v-model="group.summary" type="textarea" />
</b-field>
<div>
Avatar
<picture-upload v-model="avatarFile" />
{{ $t("Avatar") }}
<picture-upload :textFallback="$t('Avatar')" v-model="avatarFile" />
</div>
<div>
Banner
<picture-upload v-model="avatarFile" />
{{ $t("Banner") }}
<picture-upload :textFallback="$t('Banner')" v-model="bannerFile" />
</div>
<button class="button is-primary" @click="createGroup()">{{ $t("Create my group") }}</button>
</div>
<button class="button is-primary" native-type="submit">{{ $t("Create my group") }}</button>
</form>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { Group, IPerson } from "@/types/actor";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { Component, Vue, Watch } from "vue-property-decorator";
import { Group, IPerson, usernameWithDomain, MemberRole } from "@/types/actor";
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { CREATE_GROUP } from "@/graphql/group";
import PictureUpload from "@/components/PictureUpload.vue";
import RouteName from "../../router/name";
import { mixins } from "vue-class-component";
import IdentityEditionMixin from "@/mixins/identityEdition";
import { convertToUsername } from "../../utils/username";
@Component({
components: {
@ -48,7 +71,7 @@ import RouteName from "../../router/name";
},
},
})
export default class CreateGroup extends Vue {
export default class CreateGroup extends mixins(IdentityEditionMixin) {
currentActor!: IPerson;
group = new Group();
@ -57,19 +80,39 @@ export default class CreateGroup extends Vue {
bannerFile: File | null = null;
errors: string[] = [];
usernameWithDomain = usernameWithDomain;
async createGroup() {
try {
await this.$apollo.mutate({
mutation: CREATE_GROUP,
variables: this.buildVariables(),
update: (store, { data: { createGroup } }) => {
// TODO: update group list cache
const query = {
query: PERSON_MEMBERSHIPS,
variables: {
id: this.currentActor.id,
},
};
const membershipData = store.readQuery<{ person: IPerson }>(query);
if (!membershipData) return;
const person: IPerson = membershipData.person;
person.memberships.elements.push({
parent: createGroup,
role: MemberRole.ADMINISTRATOR,
actor: this.currentActor,
insertedAt: new Date().toString(),
updatedAt: new Date().toString(),
});
store.writeQuery({ ...query, data: { person } });
},
});
await this.$router.push({
name: RouteName.GROUP,
params: { identityName: this.group.preferredUsername },
params: { preferredUsername: usernameWithDomain(this.group) },
});
this.$notifier.success(
@ -82,6 +125,15 @@ export default class CreateGroup extends Vue {
}
}
get host(): string {
return window.location.hostname;
}
@Watch("group.name")
updateUsername(groupName: string): void {
this.group.preferredUsername = convertToUsername(groupName);
}
private buildVariables() {
let avatarObj = {};
let bannerObj = {};
@ -121,7 +173,18 @@ export default class CreateGroup extends Vue {
}
private handleError(err: any) {
console.error(err);
this.errors.push(
...err.graphQLErrors.map(({ message }: { message: string }) => this.convertMessage(message))
);
}
private convertMessage(message: string): string {
switch (message) {
case "A group with this name already exists":
return this.$t("A group with this name already exists") as string;
default:
return message;
}
}
}
</script>

View file

@ -304,6 +304,7 @@ import addMinutes from "date-fns/addMinutes";
apollo: {
group: {
query: FETCH_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
name: this.preferredUsername,
@ -312,6 +313,7 @@ import addMinutes from "date-fns/addMinutes";
},
person: {
query: PERSON_MEMBERSHIPS,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.currentActor.id,

View file

@ -22,11 +22,15 @@ import { LIST_GROUPS } from "@/graphql/group";
import { Group, IGroup } from "@/types/actor";
import GroupMemberCard from "@/components/Group/GroupMemberCard.vue";
import RouteName from "../../router/name";
import { Paginate } from "@/types/paginate";
@Component({
apollo: {
groups: {
query: {
query: LIST_GROUPS,
fetchPolicy: "network-only",
},
},
},
components: {
@ -34,7 +38,7 @@ import RouteName from "../../router/name";
},
})
export default class GroupList extends Vue {
groups: { elements: IGroup[]; total: number } = { elements: [], total: 0 };
groups!: Paginate<IGroup>;
loading = true;

View file

@ -176,7 +176,7 @@ import { IMember, MemberRole } from "../../types/actor/group.model";
apollo: {
group: {
query: GROUP_MEMBERS,
// fetchPolicy: "network-only",
fetchPolicy: "network-only",
variables() {
return {
name: this.$route.params.preferredUsername,

View file

@ -91,7 +91,10 @@
:value="currentAddress"
/>
<div class="buttons">
<b-button native-type="submit" type="is-primary">{{ $t("Update group") }}</b-button>
<b-button @click="confirmDeleteGroup" type="is-danger">{{ $t("Delete group") }}</b-button>
</div>
</form>
</section>
</div>
@ -100,7 +103,7 @@
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { FETCH_GROUP, UPDATE_GROUP } from "../../graphql/group";
import { FETCH_GROUP, UPDATE_GROUP, DELETE_GROUP } from "../../graphql/group";
import { IGroup, usernameWithDomain } from "../../types/actor";
import { Address, IAddress } from "../../types/address.model";
import { IMember, Group } from "../../types/actor/group.model";
@ -111,6 +114,7 @@ import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.
apollo: {
group: {
query: FETCH_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
name: this.$route.params.preferredUsername,
@ -148,15 +152,41 @@ export default class GroupSettings extends Vue {
// eslint-disable-next-line
// @ts-ignore
delete variables.__typename;
if (variables.physicalAddress) {
// eslint-disable-next-line
// @ts-ignore
delete variables.physicalAddress.__typename;
}
await this.$apollo.mutate<{ updateGroup: IGroup }>({
mutation: UPDATE_GROUP,
variables,
});
}
confirmDeleteGroup() {
this.$buefy.dialog.confirm({
title: this.$t("Delete group") as string,
message: this.$t(
"Are you sure you want to <b>completely delete</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>."
) as string,
confirmText: this.$t("Delete group") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
hasIcon: true,
onConfirm: () => this.deleteGroup(),
});
}
async deleteGroup() {
await this.$apollo.mutate<{ deleteGroup: IGroup }>({
mutation: DELETE_GROUP,
variables: {
groupId: this.group.id,
},
});
return this.$router.push({ name: RouteName.MY_GROUPS });
}
async copyURL() {
await window.navigator.clipboard.writeText(this.group.url);
this.showCopiedTooltip = true;

View file

@ -85,7 +85,7 @@ export default class MyEvents extends Vue {
get memberships() {
if (!this.membershipsPages) return [];
return this.membershipsPages.elements.filter(
(member: IMember) => member.role !== MemberRole.INVITED
(member: IMember) => ![MemberRole.INVITED, MemberRole.REJECTED].includes(member.role)
);
}
}

View file

@ -170,6 +170,7 @@ import { displayNameAndUsername } from "../../types/actor";
},
apollo: {
actionLogs: {
fetchPolicy: "cache-and-network",
query: LOGS,
},
},

View file

@ -232,6 +232,7 @@ import RouteName from "../../router/name";
apollo: {
report: {
query: REPORT,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.reportId,

View file

@ -57,7 +57,7 @@ import RouteName from "../../router/name";
apollo: {
reports: {
query: REPORTS,
fetchPolicy: "no-cache",
fetchPolicy: "cache-and-network",
variables() {
return {
status: this.filterReports,

View file

@ -98,6 +98,7 @@ import RouteName from "../../router/name";
config: CONFIG,
post: {
query: FETCH_POST,
fetchPolicy: "cache-and-network",
variables() {
return {
slug: this.slug,

View file

@ -57,6 +57,7 @@ import RouteName from "../../router/name";
apollo: {
group: {
query: FETCH_GROUP_POSTS,
fetchPolicy: "cache-and-network",
variables() {
return {
preferredUsername: this.preferredUsername,

View file

@ -58,6 +58,7 @@ import Tag from "../../components/Tag.vue";
currentActor: CURRENT_ACTOR_CLIENT,
memberships: {
query: PERSON_MEMBERSHIPS,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.currentActor.id,
@ -70,6 +71,7 @@ import Tag from "../../components/Tag.vue";
},
post: {
query: FETCH_POST,
fetchPolicy: "cache-and-network",
variables() {
return {
slug: this.slug,

View file

@ -218,6 +218,7 @@ import ResourceSelector from "../../components/Resource/ResourceSelector.vue";
apollo: {
resource: {
query: GET_RESOURCE,
fetchPolicy: "cache-and-network",
variables() {
let path = Array.isArray(this.$route.params.path)
? this.$route.params.path.join("/")

View file

@ -1,9 +1,9 @@
<template>
<div class="section container">
<h1 class="title">{{ $t("Explore") }}</h1>
<section v-if="actualTag">
<section v-if="tag">
<i18n path="Events tagged with {tag}">
<b-tag slot="tag" type="is-light">{{ $t("#{tag}", { tag: actualTag }) }}</b-tag>
<b-tag slot="tag" type="is-light">{{ $t("#{tag}", { tag }) }}</b-tag>
</i18n>
</section>
<section class="hero is-light" v-else>
@ -49,7 +49,7 @@
</form>
</div>
</section>
<section class="events-featured" v-if="!actualTag && searchEvents.initial">
<section class="events-featured" v-if="!tag && searchEvents.initial">
<b-loading :active.sync="$apollo.loading"></b-loading>
<h2 class="title">{{ $t("Featured events") }}</h2>
<div v-if="events.length > 0" class="columns is-multiline">
@ -163,10 +163,11 @@ const tabsName: { events: number; groups: number } = {
events: FETCH_EVENTS,
searchEvents: {
query: SEARCH_EVENTS,
fetchPolicy: "cache-and-network",
variables() {
return {
term: this.search,
tags: this.actualTag,
tags: this.tag,
location: this.geohash,
beginsOn: this.start,
endsOn: this.end,
@ -175,11 +176,12 @@ const tabsName: { events: number; groups: number } = {
},
debounce: 300,
skip() {
return !this.search && !this.actualTag && !this.geohash && this.end === null;
return !this.search && !this.tag && !this.geohash && this.end === null;
},
},
searchGroups: {
query: SEARCH_GROUPS,
fetchPolicy: "cache-and-network",
variables() {
return {
term: this.search,
@ -213,7 +215,6 @@ export default class Search extends Vue {
activeTab: SearchTabs = tabsName[this.$route.query.searchType as "events" | "groups"] || 0;
location: IAddress = new Address();
actualTag: string = this.tag;
options: ISearchTimeOption[] = [
{

View file

@ -40,6 +40,7 @@ import RouteName from "../../router/name";
apollo: {
todo: {
query: GET_TODO,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.$route.params.todoId,

View file

@ -56,6 +56,7 @@ import RouteName from "../../router/name";
apollo: {
todoList: {
query: FETCH_TODO_LIST,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.$route.params.id,

View file

@ -55,6 +55,7 @@ import RouteName from "../../router/name";
apollo: {
group: {
query: FETCH_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
name: this.$route.params.preferredUsername,

View file

@ -96,20 +96,26 @@ defmodule Mobilizon.Federation.ActivityPub do
{:existing, entity} ->
Logger.debug("Entity is already existing")
entity =
res =
if force_fetch and not are_same_origin?(url, Endpoint.url()) do
Logger.debug("Entity is external and we want a force fetch")
with {:ok, _activity, entity} <- Fetcher.fetch_and_update(url, options) do
entity
case Fetcher.fetch_and_update(url, options) do
{:ok, _activity, entity} ->
{:ok, entity}
{:error, "Gone"} ->
{:error, "Gone", entity}
end
else
entity
{:ok, entity}
end
with {:ok, entity} <- res do
Logger.debug("Going to preload an existing entity")
Preloader.maybe_preload(entity)
end
e ->
Logger.warn("Something failed while fetching url #{inspect(e)}")
@ -333,9 +339,9 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
def delete(object, actor, local \\ true) do
def delete(object, actor, local \\ true, additional \\ %{}) do
with {:ok, activity_data, actor, object} <-
Managable.delete(object, actor, local),
Managable.delete(object, actor, local, additional),
group <- Ownable.group_actor(object),
:ok <- check_for_actor_key_rotation(actor),
{:ok, activity} <- create_activity(activity_data, local),
@ -417,12 +423,14 @@ defmodule Mobilizon.Federation.ActivityPub do
%Actor{type: :Group, id: group_id, url: group_url, members_url: group_members_url},
%Actor{id: actor_id, url: actor_url},
local,
_additional
additional
) do
with {:member, {:ok, %Member{id: member_id} = member}} <-
{:member, Actors.get_member(actor_id, group_id)},
{:is_only_admin, false} <-
{:is_only_admin, Actors.is_only_administrator?(member_id, group_id)},
{:is_not_only_admin, true} <-
{:is_not_only_admin,
Map.get(additional, :force_member_removal, false) ||
!Actors.is_only_administrator?(member_id, group_id)},
{:delete, {:ok, %Member{} = member}} <- {:delete, Actors.delete_member(member)},
leave_data <- %{
"to" => [group_members_url],
@ -639,6 +647,19 @@ defmodule Mobilizon.Federation.ActivityPub do
end)
end
defp convert_followers_in_recipients(recipients) do
Enum.reduce(recipients, {recipients, []}, fn recipient, {recipients, follower_actors} = acc ->
case Actors.get_group_by_followers_url(recipient) do
%Actor{} = group ->
{Enum.filter(recipients, fn recipient -> recipient != group.followers_url end),
follower_actors ++ Actors.list_external_followers_for_actor(group)}
_ ->
acc
end
end)
end
# @spec is_announce_activity?(Activity.t()) :: boolean
# defp is_announce_activity?(%Activity{data: %{"type" => "Announce"}}), do: true
# defp is_announce_activity?(_), do: false
@ -661,13 +682,7 @@ defmodule Mobilizon.Federation.ActivityPub do
Relay.publish(activity)
end
{recipients, followers} =
if actor.followers_url in activity.recipients do
{Enum.filter(recipients, fn recipient -> recipient != actor.followers_url end),
Actors.list_external_followers_for_actor(actor)}
else
{recipients, []}
end
{recipients, followers} = convert_followers_in_recipients(recipients)
{recipients, members} = convert_members_in_recipients(recipients)
@ -858,7 +873,7 @@ defmodule Mobilizon.Federation.ActivityPub do
) do
with %Actor{} = inviter <- Actors.get_actor(invited_by_id),
%Actor{url: actor_url} <- Actors.get_actor(actor_id),
{:ok, %Member{url: member_url, id: member_id} = member} <-
{:ok, %Member{id: member_id} = member} <-
Actors.update_member(member, %{role: :member}),
accept_data <- %{
"type" => "Accept",
@ -866,7 +881,7 @@ defmodule Mobilizon.Federation.ActivityPub do
"to" => [inviter.url, member.parent.members_url],
"cc" => [member.parent.url],
"actor" => actor_url,
"object" => member_url,
"object" => Convertible.model_to_as(member),
"id" => "#{Endpoint.url()}/accept/invite/member/#{member_id}"
} do
{:ok, member, accept_data}

View file

@ -27,6 +27,12 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
{:ok, %Tesla.Env{body: data, status: code}} when code in 200..299 <-
ActivityPubClient.get(client, url) do
{:ok, data}
else
{:ok, %Tesla.Env{status: 410}} ->
{:error, "Gone"}
{:ok, %Tesla.Env{} = res} ->
{:error, res}
end
end
@ -47,6 +53,9 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
{:origin_check, false} ->
Logger.warn("Object origin check failed")
{:error, "Object origin check failed"}
{:error, err} ->
{:error, err}
end
end
@ -67,6 +76,9 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
{:origin_check, false} ->
Logger.warn("Object origin check failed")
{:error, "Object origin check failed"}
{:error, err} ->
{:error, err}
end
end
end

View file

@ -3,11 +3,33 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
Module that provides functions to explore and fetch collections on a group
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Fetcher, Transmogrifier}
alias Mobilizon.Federation.ActivityPub.{Fetcher, Relay, Transmogrifier}
require Logger
def refresh_profile(%Actor{domain: nil}), do: {:error, "Can only refresh remote actors"}
def refresh_profile(%Actor{type: :Group, url: url, id: group_id} = group) do
on_behalf_of =
case Actors.get_single_group_member_actor(group_id) do
%Actor{} = member_actor ->
member_actor
_ ->
Relay.get_actor()
end
with :ok <- fetch_group(url, on_behalf_of) do
{:ok, group}
end
end
def refresh_profile(%Actor{type: :Person, url: url}) do
ActivityPub.make_actor_from_url(url)
end
@spec fetch_group(String.t(), Actor.t()) :: :ok
def fetch_group(group_url, %Actor{} = on_behalf_of) do
with {:ok,
@ -20,14 +42,15 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
discussions_url: discussions_url,
events_url: events_url
}} <-
ActivityPub.get_or_fetch_actor_by_url(group_url) do
fetch_collection(outbox_url, on_behalf_of)
fetch_collection(members_url, on_behalf_of)
fetch_collection(resources_url, on_behalf_of)
fetch_collection(posts_url, on_behalf_of)
fetch_collection(todos_url, on_behalf_of)
fetch_collection(discussions_url, on_behalf_of)
fetch_collection(events_url, on_behalf_of)
ActivityPub.make_actor_from_url(group_url),
:ok <- fetch_collection(outbox_url, on_behalf_of),
:ok <- fetch_collection(members_url, on_behalf_of),
:ok <- fetch_collection(resources_url, on_behalf_of),
:ok <- fetch_collection(posts_url, on_behalf_of),
:ok <- fetch_collection(todos_url, on_behalf_of),
:ok <- fetch_collection(discussions_url, on_behalf_of),
:ok <- fetch_collection(events_url, on_behalf_of) do
:ok
end
end
@ -37,9 +60,10 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
Logger.debug("Fetching and preparing collection from url")
Logger.debug(inspect(collection_url))
with {:ok, data} <- Fetcher.fetch(collection_url, on_behalf_of: on_behalf_of) do
Logger.debug("Fetch ok, passing to process_collection")
process_collection(data, 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 <- process_collection(data, on_behalf_of) do
:ok
end
end
@ -68,6 +92,7 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
Logger.debug(inspect(items))
Enum.each(items, &handling_element/1)
:ok
end
defp process_collection(%{"type" => "OrderedCollection", "first" => first}, on_behalf_of)
@ -84,7 +109,15 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
end
end
defp process_collection(_, _), do: :error
defp handling_element(data) when is_map(data) do
id = Map.get(data, "id")
if id do
Mobilizon.Tombstone.delete_uri_tombstone(id)
end
activity = %{
"type" => "Create",
"to" => data["to"],

View file

@ -131,17 +131,20 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
Logger.info("Handle incoming to create a member")
with object_data when is_map(object_data) <-
object |> Converter.Member.as_to_model_data(),
{:existing_member, nil} <-
object |> Converter.Member.as_to_model_data() do
with {:existing_member, nil} <-
{:existing_member, Actors.get_member_by_url(object_data.url)},
{:ok, %Activity{} = activity, %Member{} = member} <-
ActivityPub.join_group(object_data, false) do
{:ok, activity, member}
else
{:existing_member, %Member{} = member} ->
{:ok, %Member{} = member} = Actors.update_member(member, object_data)
{:ok, nil, member}
end
end
end
def handle_incoming(%{
"type" => "Create",
@ -502,7 +505,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
with actor_url <- Utils.get_actor(data),
{:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_actor_by_url(actor_url),
object_id <- Utils.get_url(object),
{:ok, object} <- ActivityPub.fetch_object_from_url(object_id),
{:error, "Gone", object} <- ActivityPub.fetch_object_from_url(object_id, force: true),
{:origin_check, true} <-
{:origin_check,
Utils.origin_check_from_id?(actor_url, object_id) ||
@ -515,7 +518,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
:error
e ->
Logger.debug(inspect(e))
Logger.error(inspect(e))
:error
end
end

View file

@ -38,29 +38,55 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
end
end
@public_ap "https://www.w3.org/ns/activitystreams#Public"
@impl Entity
def delete(
%Actor{followers_url: followers_url, url: target_actor_url} = target_actor,
%Actor{url: actor_url} = actor,
local
%Actor{
followers_url: followers_url,
members_url: members_url,
url: target_actor_url,
type: type,
domain: domain
} = target_actor,
%Actor{url: actor_url, id: author_id} = actor,
_local,
additionnal
) do
to = [@public_ap, followers_url]
{to, cc} =
if type == :Group do
{to ++ [members_url], [target_actor_url]}
else
{to, []}
end
activity_data = %{
"type" => "Delete",
"actor" => actor_url,
"object" => Convertible.model_to_as(target_actor),
"id" => target_actor_url <> "/delete",
"to" => [followers_url, "https://www.w3.org/ns/activitystreams#Public"]
"to" => to,
"cc" => cc
}
# We completely delete the actor if activity is remote
with {:ok, %Oban.Job{}} <- Actors.delete_actor(target_actor, reserve_username: local) do
suspension = Map.get(additionnal, :suspension, false)
with {:ok, %Oban.Job{}} <-
Actors.delete_actor(target_actor,
# We completely delete the actor if the actor is remote
reserve_username: is_nil(domain),
suspension: suspension,
author_id: author_id
) do
{:ok, activity_data, actor, target_actor}
end
end
def actor(%Actor{} = actor), do: actor
def group_actor(%Actor{} = _actor), do: nil
def group_actor(%Actor{} = actor), do: actor
defp prepare_args_for_actor(args) do
with preferred_username <-

View file

@ -53,8 +53,15 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Comments do
end
@impl Entity
@spec delete(Comment.t(), Actor.t(), boolean) :: {:ok, Comment.t()}
def delete(%Comment{url: url} = comment, %Actor{} = actor, _local) do
@spec delete(Comment.t(), Actor.t(), boolean, map()) :: {:ok, Comment.t()}
def delete(
%Comment{url: url, id: comment_id},
%Actor{} = actor,
_local,
options \\ %{}
) do
comment = Discussions.get_comment_with_preload(comment_id)
activity_data = %{
"type" => "Delete",
"actor" => actor.url,
@ -63,16 +70,17 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Comments do
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
}
force_deletion = Map.get(options, :force, false)
with audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
{:ok, %Comment{} = comment} <- Discussions.delete_comment(comment),
# Preload to be sure
%Comment{} = comment <- Discussions.get_comment_with_preload(comment.id),
{:ok, %Comment{} = updated_comment} <-
Discussions.delete_comment(comment, force: force_deletion),
{:ok, true} <- Cachex.del(:activity_pub, "comment_#{comment.uuid}"),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: comment.url, actor_id: actor.id}) do
Share.delete_all_by_uri(comment.url)
{:ok, Map.merge(activity_data, audience), actor, comment}
{:ok, Map.merge(activity_data, audience), actor, updated_comment}
end
end

View file

@ -5,9 +5,8 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Discussions do
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Federation.ActivityPub.Audience
alias Mobilizon.Federation.ActivityPub.Types.{Comments, Entity}
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Storage.Repo
alias Mobilizon.Web.Endpoint
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
require Logger
@ -65,28 +64,14 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Discussions do
end
@impl Entity
@spec delete(Discussion.t(), Actor.t(), boolean) :: {:ok, Discussion.t()}
def delete(%Discussion{actor: group, url: url} = discussion, %Actor{} = actor, _local) do
stream =
discussion.comments
|> Enum.map(
&Repo.preload(&1, [
:actor,
:attributed_to,
:in_reply_to_comment,
:mentions,
:origin_comment,
:discussion,
:tags,
:replies
])
)
|> Enum.map(&Map.put(&1, :event, nil))
|> Task.async_stream(fn comment -> Comments.delete(comment, actor, nil) end)
Stream.run(stream)
with {:ok, %Discussion{}} <- Discussions.delete_discussion(discussion) do
@spec delete(Discussion.t(), Actor.t(), boolean, map()) :: {:ok, Discussion.t()}
def delete(
%Discussion{actor: group, url: url} = discussion,
%Actor{} = actor,
_local,
_additionnal
) do
with {:ok, _} <- Discussions.delete_discussion(discussion) do
# This is just fake
activity_data = %{
"type" => "Delete",

View file

@ -35,7 +35,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Entity do
@callback update(struct :: t(), attrs :: map(), additionnal :: map()) ::
{:ok, t(), ActivityStream.t()}
@callback delete(struct :: t(), Actor.t(), local :: boolean()) ::
@callback delete(struct :: t(), Actor.t(), local :: boolean(), map()) ::
{:ok, ActivityStream.t(), Actor.t(), t()}
end
@ -50,10 +50,10 @@ defprotocol Mobilizon.Federation.ActivityPub.Types.Managable do
"""
def update(entity, attrs, additionnal)
@spec delete(Entity.t(), Actor.t(), boolean()) ::
@spec delete(Entity.t(), Actor.t(), boolean(), map()) ::
{:ok, ActivityStream.t(), Actor.t(), Entity.t()}
@doc "Deletes an entity and returns the activitystream representation for it"
def delete(entity, actor, local)
def delete(entity, actor, local, additionnal)
end
defprotocol Mobilizon.Federation.ActivityPub.Types.Ownable do
@ -68,7 +68,7 @@ end
defimpl Managable, for: Event do
defdelegate update(entity, attrs, additionnal), to: Events
defdelegate delete(entity, actor, local), to: Events
defdelegate delete(entity, actor, local, additionnal), to: Events
end
defimpl Ownable, for: Event do
@ -78,7 +78,7 @@ end
defimpl Managable, for: Comment do
defdelegate update(entity, attrs, additionnal), to: Comments
defdelegate delete(entity, actor, local), to: Comments
defdelegate delete(entity, actor, local, additionnal), to: Comments
end
defimpl Ownable, for: Comment do
@ -88,7 +88,7 @@ end
defimpl Managable, for: Post do
defdelegate update(entity, attrs, additionnal), to: Posts
defdelegate delete(entity, actor, local), to: Posts
defdelegate delete(entity, actor, local, additionnal), to: Posts
end
defimpl Ownable, for: Post do
@ -98,7 +98,7 @@ end
defimpl Managable, for: Actor do
defdelegate update(entity, attrs, additionnal), to: Actors
defdelegate delete(entity, actor, local), to: Actors
defdelegate delete(entity, actor, local, additionnal), to: Actors
end
defimpl Ownable, for: Actor do
@ -108,7 +108,7 @@ end
defimpl Managable, for: TodoList do
defdelegate update(entity, attrs, additionnal), to: TodoLists
defdelegate delete(entity, actor, local), to: TodoLists
defdelegate delete(entity, actor, local, additionnal), to: TodoLists
end
defimpl Ownable, for: TodoList do
@ -118,7 +118,7 @@ end
defimpl Managable, for: Todo do
defdelegate update(entity, attrs, additionnal), to: Todos
defdelegate delete(entity, actor, local), to: Todos
defdelegate delete(entity, actor, local, additionnal), to: Todos
end
defimpl Ownable, for: Todo do
@ -128,7 +128,7 @@ end
defimpl Managable, for: Resource do
defdelegate update(entity, attrs, additionnal), to: Resources
defdelegate delete(entity, actor, local), to: Resources
defdelegate delete(entity, actor, local, additionnal), to: Resources
end
defimpl Ownable, for: Resource do
@ -138,7 +138,7 @@ end
defimpl Managable, for: Discussion do
defdelegate update(entity, attrs, additionnal), to: Discussions
defdelegate delete(entity, actor, local), to: Discussions
defdelegate delete(entity, actor, local, additionnal), to: Discussions
end
defimpl Ownable, for: Discussion do
@ -153,5 +153,5 @@ end
defimpl Managable, for: Member do
defdelegate update(entity, attrs, additionnal), to: Members
defdelegate delete(entity, actor, local), to: Members
defdelegate delete(entity, actor, local, additionnal), to: Members
end

View file

@ -53,8 +53,8 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Events do
end
@impl Entity
@spec delete(Event.t(), Actor.t(), boolean) :: {:ok, Event.t()}
def delete(%Event{url: url} = event, %Actor{} = actor, _local) do
@spec delete(Event.t(), Actor.t(), boolean, map()) :: {:ok, Event.t()}
def delete(%Event{url: url} = event, %Actor{} = actor, _local, _additionnal) do
activity_data = %{
"type" => "Delete",
"actor" => actor.url,

View file

@ -2,6 +2,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Members do
@moduledoc false
alias Mobilizon.Actors
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.Convertible
require Logger
import Mobilizon.Federation.ActivityPub.Utils, only: [make_update_data: 2]
@ -38,8 +39,16 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Members do
end
end
# Delete member is not used, see ActivityPub.leave/4 and ActivityPub.remove/5 instead
def delete(_, _, _), do: :error
# Used only when a group is suspended
def delete(
%Member{parent: %Actor{} = group, actor: %Actor{} = actor} = _member,
%Actor{},
local,
_additionnal
) do
Logger.debug("Deleting a member")
ActivityPub.leave(group, actor, local, %{force_member_removal: true})
end
def actor(%Member{actor_id: actor_id}),
do: Actors.get_actor(actor_id)

View file

@ -69,7 +69,8 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do
attributed_to: %Actor{url: group_url}
} = post,
%Actor{url: actor_url} = actor,
_local
_local,
_additionnal
) do
activity_data = %{
"actor" => actor_url,

View file

@ -131,7 +131,8 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Resources do
def delete(
%Resource{url: url, actor: %Actor{url: group_url, members_url: members_url}} = resource,
%Actor{url: actor_url} = actor,
_local
_local,
_additionnal
) do
Logger.debug("Building Delete Resource activity")

View file

@ -40,19 +40,20 @@ defmodule Mobilizon.Federation.ActivityPub.Types.TodoLists do
end
@impl Entity
@spec delete(TodoList.t(), Actor.t(), boolean()) ::
@spec delete(TodoList.t(), Actor.t(), boolean(), map()) ::
{:ok, ActivityStream.t(), Actor.t(), TodoList.t()}
def delete(
%TodoList{url: url, actor: %Actor{url: group_url}} = todo_list,
%Actor{url: actor_url} = actor,
_local
_local,
_additionnal
) do
Logger.debug("Building Delete TodoList activity")
activity_data = %{
"actor" => actor_url,
"type" => "Delete",
"object" => Convertible.model_to_as(url),
"object" => Convertible.model_to_as(todo_list),
"id" => url <> "/delete",
"to" => [group_url]
}

View file

@ -44,11 +44,13 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Todos do
end
@impl Entity
@spec delete(Todo.t(), Actor.t(), boolean()) :: {:ok, ActivityStream.t(), Actor.t(), Todo.t()}
@spec delete(Todo.t(), Actor.t(), boolean(), map()) ::
{:ok, ActivityStream.t(), Actor.t(), Todo.t()}
def delete(
%Todo{url: url, creator: %Actor{url: group_url}} = todo,
%Actor{url: actor_url} = actor,
_local
_local,
_additionnal
) do
Logger.debug("Building Delete Todo activity")

View file

@ -143,7 +143,9 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
do_maybe_relay_if_group_activity(object, attributed_to_url)
end
def maybe_relay_if_group_activity(_, _), do: :ok
def maybe_relay_if_group_activity(_activity, _attributedTo) do
:ok
end
defp do_maybe_relay_if_group_activity(object, attributed_to) when is_list(attributed_to),
do: do_maybe_relay_if_group_activity(object, hd(attributed_to))

View file

@ -135,6 +135,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
def model_to_as(%CommentModel{} = comment) do
Convertible.model_to_as(%TombstoneModel{
uri: comment.url,
actor: comment.actor,
inserted_at: comment.deleted_at
})
end

View file

@ -39,6 +39,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Discussion do
"actor" => discussion.creator.url,
"attributedTo" => discussion.actor.url,
"id" => discussion.url,
"publishedAt" => discussion.inserted_at,
"context" => discussion.url
}
end

View file

@ -28,7 +28,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Tombstone do
%{
"type" => "Tombstone",
"id" => tombstone.uri,
"actor" => tombstone.actor.url,
"actor" => if(tombstone.actor, do: tombstone.actor.url, else: nil),
"deleted" => tombstone.inserted_at
}
end

View file

@ -18,7 +18,7 @@ defmodule Mobilizon.GraphQL.API.Groups do
with preferred_username <-
args |> Map.get(:preferred_username) |> HTML.strip_tags() |> String.trim(),
{:existing_group, nil} <-
{:existing_group, Actors.get_local_group_by_title(preferred_username)},
{:existing_group, Actors.get_local_actor_by_name(preferred_username)},
args <- args |> Map.put(:type, :Group),
{:ok, %Activity{} = activity, %Actor{} = group} <-
ActivityPub.create(:actor, args, true, %{"actor" => args.creator_actor.url}) do

View file

@ -0,0 +1,104 @@
defmodule Mobilizon.GraphQL.Resolvers.Actor do
@moduledoc """
Handles the group-related GraphQL calls.
"""
import Mobilizon.Users.Guards
alias Mobilizon.{Actors, Admin, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Refresher
alias Mobilizon.Users.User
require Logger
def refresh_profile(_parent, %{id: id}, %{context: %{current_user: %User{role: role}}})
when is_admin(role) do
case Actors.get_actor(id) do
%Actor{domain: domain} = actor when not is_nil(domain) ->
Refresher.refresh_profile(actor)
{:ok, actor}
%Actor{} ->
{:error, "Only remote actors may be refreshed"}
_ ->
{:error, "No actor found with this ID"}
end
end
def suspend_profile(_parent, %{id: id}, %{
context: %{current_user: %User{role: role} = user}
})
when is_moderator(role) do
with {:moderator_actor, %Actor{} = moderator_actor} <-
{:moderator_actor, Users.get_actor_for_user(user)},
%Actor{suspended: false} = actor <- Actors.get_actor_with_preload(id) do
case actor do
# Suspend a group on this instance
%Actor{type: :Group, domain: nil} ->
Logger.debug("We're suspending a group on this very instance")
ActivityPub.delete(actor, moderator_actor, true, %{suspension: true})
Admin.log_action(moderator_actor, "suspend", actor)
{:ok, actor}
# Delete a remote actor
%Actor{domain: domain} when not is_nil(domain) ->
Logger.debug("We're just deleting a remote instance")
Actors.delete_actor(actor, suspension: true)
Admin.log_action(moderator_actor, "suspend", actor)
{:ok, actor}
%Actor{domain: nil} ->
{:error, "No remote profile found with this ID"}
end
else
{:moderator_actor, nil} ->
{:error, "No actor found for the moderator user"}
%Actor{suspended: true} ->
{:error, "Actor already suspended"}
{:error, _} ->
{:error, "Error while performing background task"}
end
end
def suspend_profile(_parent, _args, _resolution) do
{:error, "Only moderators and administrators can suspend a profile"}
end
def unsuspend_profile(_parent, %{id: id}, %{
context: %{current_user: %User{role: role} = user}
})
when is_moderator(role) do
with {:moderator_actor, %Actor{} = moderator_actor} <-
{:moderator_actor, Users.get_actor_for_user(user)},
%Actor{suspended: true} = actor <-
Actors.get_actor_with_preload(id, true),
{:delete_tombstones, {_, nil}} <-
{:delete_tombstones, Mobilizon.Tombstone.delete_actor_tombstones(id)},
{:ok, %Actor{} = actor} <- Actors.update_actor(actor, %{suspended: false}),
{:ok, %Actor{} = actor} <- refresh_if_remote(actor),
{:ok, _} <- Admin.log_action(moderator_actor, "unsuspend", actor) do
{:ok, actor}
else
{:moderator_actor, nil} ->
{:error, "No actor found for the moderator user"}
nil ->
{:error, "No remote profile found with this ID"}
{:error, _} ->
{:error, "Error while performing background task"}
end
end
def unsuspend_profile(_parent, _args, _resolution) do
{:error, "Only moderators and administrators can unsuspend a profile"}
end
@spec refresh_if_remote(Actor.t()) :: {:ok, Actor.t()}
defp refresh_if_remote(%Actor{domain: nil} = actor), do: {:ok, actor}
defp refresh_if_remote(%Actor{} = actor), do: Refresher.refresh_profile(actor)
end

View file

@ -20,8 +20,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Discussion do
}
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
{:ok, Discussions.find_discussions_for_actor(group_id)}
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, %Actor{type: :Group} = group} <- Actors.get_group_by_actor_id(group_id) do
{:ok, Discussions.find_discussions_for_actor(group)}
else
{:member, false} ->
{:ok, %Page{total: 0, elements: []}}
@ -174,6 +175,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Discussion do
{:ok, _activity, %Discussion{} = discussion} <-
ActivityPub.delete(discussion, actor) do
{:ok, discussion}
else
{:no_discussion, _} ->
{:error, "No discussion with ID #{discussion_id}"}
{:member, _} ->
{:error, "You are not a member of the group the discussion belongs to"}
end
end
end

View file

@ -3,6 +3,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
Handles the group-related GraphQL calls.
"""
import Mobilizon.Users.Guards
alias Mobilizon.{Actors, Events, Users}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Federation.ActivityPub
@ -54,13 +55,46 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
end
end
@doc """
Get a group
"""
def get_group(_parent, %{id: id}, %{context: %{current_user: %User{role: role}}}) do
with %Actor{type: :Group, suspended: suspended} = actor <-
Actors.get_actor_with_preload(id, true),
true <- suspended == false or is_moderator(role) do
{:ok, actor}
else
_ ->
{:error, "Group with ID #{id} not found"}
end
end
@doc """
Lists all groups
"""
def list_groups(_parent, %{page: page, limit: limit}, _resolution) do
{:ok, Actors.list_groups(page, limit)}
def list_groups(
_parent,
%{
preferred_username: preferred_username,
name: name,
domain: domain,
local: local,
suspended: suspended,
page: page,
limit: limit
},
%{
context: %{current_user: %User{role: role}}
}
)
when is_moderator(role) do
{:ok,
Actors.list_actors(:Group, preferred_username, name, domain, local, suspended, page, limit)}
end
def list_groups(_parent, _args, _resolution),
do: {:error, "You may not list groups unless moderator."}
@doc """
Create a new group. The creator is automatically added as admin
"""
@ -127,28 +161,23 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
"""
def delete_group(
_parent,
%{group_id: group_id, actor_id: actor_id},
%{group_id: group_id},
%{
context: %{
current_user: user
}
}
) do
with {actor_id, ""} <- Integer.parse(actor_id),
{group_id, ""} <- Integer.parse(group_id),
with %Actor{id: actor_id} = actor <- Users.get_actor_for_user(user),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
{:is_owned, %Actor{}} <- User.owns_actor(user, actor_id),
{:ok, %Member{} = member} <- Actors.get_member(actor_id, group.id),
{:is_admin, true} <- {:is_admin, Member.is_administrator(member)},
group <- Actors.delete_group!(group) do
{:ok, _activity, group} <- ActivityPub.delete(group, actor, true) do
{:ok, %{id: group.id}}
else
{:error, :group_not_found} ->
{:error, "Group not found"}
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
{:error, :member_not_found} ->
{:error, "Actor id is not a member of this group"}
@ -231,7 +260,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
{:group, nil} ->
{:error, "Group not found"}
{:is_only_admin, true} ->
{:is_not_only_admin, false} ->
{:error, "You can't leave this group because you are the only administrator"}
end
end
@ -245,12 +274,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
_args,
%{
context: %{
current_user: %User{} = user
current_user: %User{role: user_role} = user
}
}
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
{:member, true} <-
{:member, Actors.is_member?(actor_id, group_id) or is_moderator(user_role)} do
# TODO : Handle public / restricted to group members events
{:ok, Events.list_organized_events_for_group(group)}
else

View file

@ -3,6 +3,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
Handles the member-related GraphQL calls
"""
import Mobilizon.Users.Guards
alias Mobilizon.{Actors, Users}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Federation.ActivityPub
@ -19,11 +20,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
%Actor{id: group_id} = group,
%{page: page, limit: limit, roles: roles},
%{
context: %{current_user: %User{} = user}
context: %{current_user: %User{role: user_role} = user}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
{:member, true} <-
{:member, Actors.is_member?(actor_id, group_id) or is_moderator(user_role)} do
roles =
case roles do
"" ->
@ -167,9 +169,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
{:error, :member_not_found} ->
true
err ->
require Logger
Logger.error(inspect(err))
_err ->
false
end
end

View file

@ -7,7 +7,6 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Admin
alias Mobilizon.Events
alias Mobilizon.Events.Participant
alias Mobilizon.Storage.Page
@ -321,64 +320,6 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
end
end
def suspend_profile(_parent, %{id: id}, %{
context: %{current_user: %User{role: role} = user}
})
when is_moderator(role) do
with {:moderator_actor, %Actor{} = moderator_actor} <-
{:moderator_actor, Users.get_actor_for_user(user)},
%Actor{suspended: false} = actor <- Actors.get_remote_actor_with_preload(id),
{:ok, _} <- Actors.delete_actor(actor),
{:ok, _} <- Admin.log_action(moderator_actor, "suspend", actor) do
{:ok, actor}
else
{:moderator_actor, nil} ->
{:error, "No actor found for the moderator user"}
%Actor{suspended: true} ->
{:error, "Actor already suspended"}
nil ->
{:error, "No remote profile found with this ID"}
{:error, _} ->
{:error, "Error while performing background task"}
end
end
def suspend_profile(_parent, _args, _resolution) do
{:error, "Only moderators and administrators can suspend a profile"}
end
def unsuspend_profile(_parent, %{id: id}, %{
context: %{current_user: %User{role: role} = user}
})
when is_moderator(role) do
with {:moderator_actor, %Actor{} = moderator_actor} <-
{:moderator_actor, Users.get_actor_for_user(user)},
%Actor{preferred_username: preferred_username, domain: domain} = actor <-
Actors.get_remote_actor_with_preload(id, true),
{:ok, _} <- Actors.update_actor(actor, %{suspended: false}),
{:ok, %Actor{} = actor} <-
ActivityPub.make_actor_from_nickname("#{preferred_username}@#{domain}"),
{:ok, _} <- Admin.log_action(moderator_actor, "unsuspend", actor) do
{:ok, actor}
else
{:moderator_actor, nil} ->
{:error, "No actor found for the moderator user"}
nil ->
{:error, "No remote profile found with this ID"}
{:error, _} ->
{:error, "Error while performing background task"}
end
end
def unsuspend_profile(_parent, _args, _resolution) do
{:error, "Only moderators and administrators can unsuspend a profile"}
end
# We check that the actor is not the last administrator/creator of a group
@spec last_admin_of_a_group?(integer()) :: boolean()
defp last_admin_of_a_group?(actor_id) do

View file

@ -3,6 +3,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
Handles the posts-related GraphQL calls
"""
import Mobilizon.Users.Guards
alias Mobilizon.{Actors, Posts, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
@ -24,12 +25,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
%{page: page, limit: limit} = args,
%{
context: %{
current_user: %User{} = user
current_user: %User{role: user_role} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:member, true} <-
{:member, Actors.is_member?(actor_id, group_id) or is_moderator(user_role)},
%Page{} = page <- Posts.get_posts_for_group(group, page, limit) do
{:ok, page}
else

View file

@ -175,6 +175,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:discussion_mutations)
import_fields(:resource_mutations)
import_fields(:post_mutations)
import_fields(:actor_mutations)
end
@desc """

View file

@ -5,6 +5,7 @@ defmodule Mobilizon.GraphQL.Schema.ActorInterface do
use Absinthe.Schema.Notation
alias Mobilizon.Actors.Actor
alias Mobilizon.GraphQL.Resolvers.Actor, as: ActorResolver
alias Mobilizon.GraphQL.Schema
import_types(Schema.Actors.FollowerType)
@ -59,4 +60,21 @@ defmodule Mobilizon.GraphQL.Schema.ActorInterface do
value(:Organization, description: "An ActivityPub Organization")
value(:Service, description: "An ActivityPub Service")
end
object :actor_mutations do
field :suspend_profile, :deleted_object do
arg(:id, non_null(:id), description: "The profile ID to suspend")
resolve(&ActorResolver.suspend_profile/3)
end
field :unsuspend_profile, :actor do
arg(:id, non_null(:id), description: "The profile ID to unsuspend")
resolve(&ActorResolver.unsuspend_profile/3)
end
field :refresh_profile, :actor do
arg(:id, non_null(:id))
resolve(&ActorResolver.refresh_profile/3)
end
end
end

View file

@ -130,11 +130,22 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
object :group_queries do
@desc "Get all groups"
field :groups, :paginated_group_list do
arg(:preferred_username, :string, default_value: "")
arg(:name, :string, default_value: "")
arg(:domain, :string, default_value: "")
arg(:local, :boolean, default_value: true)
arg(:suspended, :boolean, default_value: false)
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Group.list_groups/3)
end
@desc "Get a group by its ID"
field :get_group, :group do
arg(:id, non_null(:id))
resolve(&Group.get_group/3)
end
@desc "Get a group by its preferred username"
field :group, :group do
arg(:preferred_username, non_null(:string))
@ -199,7 +210,6 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
@desc "Delete a group"
field :delete_group, :deleted_object do
arg(:group_id, non_null(:id))
arg(:actor_id, non_null(:id))
resolve(&Group.delete_group/3)
end

View file

@ -188,16 +188,6 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
resolve(handle_errors(&Person.register_person/3))
end
field :suspend_profile, :deleted_object do
arg(:id, :id, description: "The profile ID to suspend")
resolve(&Person.suspend_profile/3)
end
field :unsuspend_profile, :person do
arg(:id, :id, description: "The profile ID to unsuspend")
resolve(&Person.unsuspend_profile/3)
end
end
object :person_subscriptions do

View file

@ -33,6 +33,7 @@ defmodule Mobilizon.GraphQL.Schema.Discussions.CommentType do
field(:inserted_at, :datetime)
field(:updated_at, :datetime)
field(:deleted_at, :datetime)
field(:published_at, :datetime)
end
@desc "The list of visibility options for a comment"

View file

@ -13,13 +13,12 @@ defmodule Mobilizon.Actors do
alias Mobilizon.Actors.{Actor, Bot, Follower, Member}
alias Mobilizon.Addresses.Address
alias Mobilizon.{Crypto, Events}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Media.File
alias Mobilizon.Service.Workers
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Web.Email.Group
alias Mobilizon.Web.Upload
require Logger
@ -249,23 +248,19 @@ defmodule Mobilizon.Actors do
"""
@spec upsert_actor(map, boolean) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def upsert_actor(
%{keys: keys, name: name, summary: summary, avatar: avatar, banner: banner} = data,
data,
preload \\ false
) do
# data =
# data
# |> Map.put(:avatar, transform_media_file(data.avatar))
# |> Map.put(:banner, transform_media_file(data.banner))
insert =
data
|> Actor.remote_actor_creation_changeset()
|> Repo.insert(
on_conflict: [
set: [
keys: keys,
name: name,
summary: summary,
avatar: transform_media_file(avatar),
banner: transform_media_file(banner),
last_refreshed_at: DateTime.utc_now()
]
],
on_conflict: {:replace_all_except, [:id, :url, :preferred_username, :domain]},
conflict_target: [:url]
)
@ -282,26 +277,28 @@ defmodule Mobilizon.Actors do
end
end
defp transform_media_file(nil), do: nil
# defp transform_media_file(nil), do: nil
defp transform_media_file(file) do
file = for({key, val} <- file, into: %{}, do: {String.to_atom(key), val})
# defp transform_media_file(file) do
# file = for({key, val} <- file, into: %{}, do: {String.to_atom(key), val})
if is_nil(file) do
nil
else
struct(Mobilizon.Media.File, file)
end
end
# if is_nil(file) do
# nil
# else
# struct(Mobilizon.Media.File, file)
# end
# end
@delete_actor_default_options [reserve_username: true]
@delete_actor_default_options [reserve_username: true, suspension: false]
def delete_actor(%Actor{} = actor, options \\ @delete_actor_default_options) do
delete_actor_options = Keyword.merge(@delete_actor_default_options, options)
Workers.Background.enqueue("delete_actor", %{
"actor_id" => actor.id,
"reserve_username" => Keyword.get(delete_actor_options, :reserve_username, true)
"author_id" => Keyword.get(delete_actor_options, :author_id),
"reserve_username" => Keyword.get(delete_actor_options, :reserve_username, true),
"suspension" => Keyword.get(delete_actor_options, :suspension, false)
})
end
@ -309,11 +306,16 @@ defmodule Mobilizon.Actors do
Deletes an actor.
"""
@spec perform(atom(), Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def perform(:delete_actor, %Actor{} = actor, options \\ @delete_actor_default_options) do
def perform(:delete_actor, %Actor{type: type} = actor, options \\ @delete_actor_default_options) do
Logger.info("Going to delete actor #{actor.url}")
actor = Repo.preload(actor, @actor_preloads)
delete_actor_options = Keyword.merge(@delete_actor_default_options, options)
Logger.debug(inspect(delete_actor_options))
if type == :Group do
delete_eventual_local_members(actor, delete_actor_options)
end
multi =
Multi.new()
@ -322,6 +324,31 @@ defmodule Mobilizon.Actors do
|> Multi.run(:remove_banner, fn _, _ -> remove_banner(actor) end)
|> Multi.run(:remove_avatar, fn _, _ -> remove_avatar(actor) end)
multi =
if type == :Group do
multi
|> Multi.run(:delete_remote_members, fn _, _ ->
delete_group_elements(actor, :remote_members)
end)
|> Multi.run(:delete_group_organized_events, fn _, _ ->
delete_group_elements(actor, :events)
end)
|> Multi.run(:delete_group_posts, fn _, _ ->
delete_group_elements(actor, :posts)
end)
|> Multi.run(:delete_group_resources, fn _, _ ->
delete_group_elements(actor, :resources)
end)
|> Multi.run(:delete_group_todo_lists, fn _, _ ->
delete_group_elements(actor, :todo_lists)
end)
|> Multi.run(:delete_group_discussions, fn _, _ ->
delete_group_elements(actor, :discussions)
end)
else
multi
end
multi =
if Keyword.get(delete_actor_options, :reserve_username, true) do
Multi.update(multi, :actor, Actor.delete_changeset(actor))
@ -329,6 +356,8 @@ defmodule Mobilizon.Actors do
Multi.delete(multi, :actor, actor)
end
Logger.debug("Going to run the transaction")
case Repo.transaction(multi) do
{:ok, %{actor: %Actor{} = actor}} ->
{:ok, true} = Cachex.del(:activity_pub, "actor_#{actor.preferred_username}")
@ -357,7 +386,16 @@ defmodule Mobilizon.Actors do
@doc """
Returns the list of actors.
"""
@spec list_actors(String.t(), String.t(), boolean, boolean, integer, integer) :: Page.t()
@spec list_actors(
atom(),
String.t(),
String.t(),
String.t(),
boolean,
boolean,
integer,
integer
) :: Page.t()
def list_actors(
type \\ :Person,
preferred_username \\ "",
@ -380,12 +418,41 @@ defmodule Mobilizon.Actors do
limit
) do
person_query()
|> filter_actors(preferred_username, name, domain, local, suspended)
|> Page.build_page(page, limit)
end
def list_actors(
:Group,
preferred_username,
name,
domain,
local,
suspended,
page,
limit
) do
group_query()
|> filter_actors(preferred_username, name, domain, local, suspended)
|> Page.build_page(page, limit)
end
@spec filter_actors(Ecto.Query.t(), String.t(), String.t(), String.t(), boolean(), boolean()) ::
Ecto.Query.t()
defp filter_actors(
query,
preferred_username,
name,
domain,
local,
suspended
) do
query
|> filter_suspended(suspended)
|> filter_preferred_username(preferred_username)
|> filter_name(name)
|> filter_domain(domain)
|> filter_remote(local)
|> Page.build_page(page, limit)
end
defp filter_preferred_username(query, ""), do: query
@ -406,6 +473,7 @@ defmodule Mobilizon.Actors do
defp filter_remote(query, true), do: filter_local(query)
defp filter_remote(query, false), do: filter_external(query)
@spec filter_suspended(Ecto.Query.t(), boolean()) :: Ecto.Query.t()
defp filter_suspended(query, true), do: where(query, [a], a.suspended)
defp filter_suspended(query, false), do: where(query, [a], not a.suspended)
@ -440,6 +508,7 @@ defmodule Mobilizon.Actors do
|> actor_by_username_or_name_query(term)
|> actors_for_location(args)
|> filter_by_types(types)
|> filter_suspended(false)
|> Page.build_page(page, limit)
end
@ -492,6 +561,13 @@ defmodule Mobilizon.Actors do
|> Repo.one()
end
@spec get_group_by_followers_url(String.t()) :: Actor.t()
def get_group_by_followers_url(followers_url) do
group_query()
|> where([q], q.followers_url == ^followers_url)
|> Repo.one()
end
@doc """
Creates a group.
@ -702,6 +778,28 @@ defmodule Mobilizon.Actors do
|> Page.build_page(page, limit)
end
@spec list_local_members_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_local_members_for_group(
%Actor{id: group_id, type: :Group} = _group,
page \\ nil,
limit \\ nil
) do
group_id
|> group_internal_member_query()
|> Page.build_page(page, limit)
end
@spec list_remote_members_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_remote_members_for_group(
%Actor{id: group_id, type: :Group} = _group,
page \\ nil,
limit \\ nil
) do
group_id
|> group_external_member_query()
|> Page.build_page(page, limit)
end
@doc """
Returns the list of members for a group.
"""
@ -1283,6 +1381,26 @@ defmodule Mobilizon.Actors do
|> select([_m, a], a)
end
@spec group_external_member_query(integer()) :: Ecto.Query.t()
defp group_external_member_query(group_id) do
Member
|> where([m], m.parent_id == ^group_id)
|> join(:inner, [m], a in Actor, on: m.actor_id == a.id)
|> where([_m, a], not is_nil(a.domain))
|> preload([m], [:parent, :actor])
|> select([m, _a], m)
end
@spec group_internal_member_query(integer()) :: Ecto.Query.t()
defp group_internal_member_query(group_id) do
Member
|> where([m], m.parent_id == ^group_id)
|> join(:inner, [m], a in Actor, on: m.actor_id == a.id)
|> where([_m, a], is_nil(a.domain))
|> preload([m], [:parent, :actor])
|> select([m, _a], m)
end
@spec filter_member_role(Ecto.Query.t(), list(atom()) | atom()) :: Ecto.Query.t()
def filter_member_role(query, []), do: query
@ -1497,4 +1615,72 @@ defmodule Mobilizon.Actors do
{:error, res}
end
end
defp delete_group_elements(%Actor{type: :Group} = actor, type) do
Logger.debug("delete_group_elements #{inspect(type)}")
method =
case type do
:remote_members -> &list_remote_members_for_group/3
:events -> &Events.list_organized_events_for_group/3
:posts -> &Mobilizon.Posts.get_posts_for_group/3
:resources -> &Mobilizon.Resources.get_resources_for_group/3
:todo_lists -> &Mobilizon.Todos.get_todo_lists_for_group/3
:discussions -> &Mobilizon.Discussions.find_discussions_for_actor/3
end
res =
actor
|> accumulate_paginated_elements(method)
|> Enum.map(fn element -> ActivityPub.delete(element, actor, false) end)
if Enum.all?(res, fn {status, _, _} -> status == :ok end) do
Logger.debug("Return OK for all #{to_string(type)}")
{:ok, res}
else
Logger.debug("Something failed #{inspect(res)}")
{:error, res}
end
end
defp accumulate_paginated_elements(
%Actor{} = actor,
method,
elements \\ [],
page \\ 1,
limit \\ 10
) do
Logger.debug("accumulate_paginated_elements")
%Page{total: total, elements: new_elements} = page = method.(actor, page, limit)
elements = elements ++ new_elements
count = length(elements)
if count < total do
accumulate_paginated_elements(actor, method, elements, page + 1, limit)
else
Logger.debug("Found #{count} group elements to delete")
elements
end
end
# This one is not in the Multi transaction because it sends activities
defp delete_eventual_local_members(%Actor{} = group, options) do
suspended? = Keyword.get(options, :suspension, false)
group
|> accumulate_paginated_elements(&list_local_members_for_group/3)
|> Enum.map(fn member ->
if suspended? do
Group.send_group_suspension_notification(member)
else
with author_id when not is_nil(author_id) <- Keyword.get(options, :author_id),
%Actor{} = author <- get_actor(author_id) do
Group.send_group_deletion_notification(member, author)
end
end
member
end)
|> Enum.map(fn member -> ActivityPub.delete(member, group, false) end)
end
end

View file

@ -94,6 +94,7 @@ defmodule Mobilizon.Discussions do
@spec get_comment!(integer | String.t()) :: Comment.t()
def get_comment!(id), do: Repo.get!(Comment, id)
@spec get_comment_with_preload(String.t() | integer() | nil) :: Comment.t() | nil
def get_comment_with_preload(nil), do: nil
def get_comment_with_preload(id) do
@ -214,11 +215,20 @@ defmodule Mobilizon.Discussions do
But actually just empty the fields so that threads are not broken.
"""
@spec delete_comment(Comment.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def delete_comment(%Comment{} = comment) do
@spec delete_comment(Comment.t(), Keyword.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def delete_comment(%Comment{} = comment, options \\ []) do
if Keyword.get(options, :force, false) == false do
with {:ok, %Comment{} = comment} <-
comment
|> Comment.delete_changeset()
|> Repo.update()
|> Repo.update(),
%Comment{} = comment <- get_comment_with_preload(comment.id) do
{:ok, comment}
end
else
comment
|> Repo.delete()
end
end
@doc """
@ -289,8 +299,8 @@ defmodule Mobilizon.Discussions do
|> Repo.preload(@discussion_preloads)
end
@spec find_discussions_for_actor(integer, integer | nil, integer | nil) :: Page.t()
def find_discussions_for_actor(actor_id, page \\ nil, limit \\ nil) do
@spec find_discussions_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def find_discussions_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
Discussion
|> where([c], c.actor_id == ^actor_id)
|> preload(^@discussion_preloads)
@ -372,9 +382,13 @@ defmodule Mobilizon.Discussions do
Delete a discussion.
"""
@spec delete_discussion(Discussion.t()) :: {:ok, Discussion.t()} | {:error, Changeset.t()}
def delete_discussion(%Discussion{} = discussion) do
discussion
|> Repo.delete()
def delete_discussion(%Discussion{id: discussion_id}) do
Multi.new()
|> Multi.delete_all(:comments, fn _ ->
where(Comment, [c], c.discussion_id == ^discussion_id)
end)
# |> Multi.delete(:discussion, discussion)
|> Repo.transaction()
end
defp public_comments_for_actor_query(actor_id) do
@ -402,7 +416,4 @@ defmodule Mobilizon.Discussions do
@spec preload_for_comment(Ecto.Query.t()) :: Ecto.Query.t()
defp preload_for_comment(query), do: preload(query, ^@comment_preloads)
# @spec preload_for_discussion(Ecto.Query.t()) :: Ecto.Query.t()
# defp preload_for_discussion(query), do: preload(query, ^@discussion_preloads)
end

View file

@ -46,13 +46,6 @@ defmodule Mobilizon.Posts do
|> Page.build_page(page, limit)
end
def do_get_posts_for_group(group_id) do
Post
|> where(attributed_to_id: ^group_id)
|> order_by(desc: :inserted_at)
|> preload([p], [:author, :attributed_to, :picture])
end
@doc """
Get a post by it's ID
"""
@ -144,4 +137,11 @@ defmodule Mobilizon.Posts do
defp filter_public(query) do
where(query, [p], p.visibility == ^:public and not p.draft)
end
defp do_get_posts_for_group(group_id) do
Post
|> where(attributed_to_id: ^group_id)
|> order_by(desc: :inserted_at)
|> preload([p], [:author, :attributed_to, :picture])
end
end

View file

@ -7,6 +7,7 @@ defmodule Mobilizon.Tombstone do
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Storage.Repo
require Ecto.Query
@type t :: %__MODULE__{
uri: String.t(),
@ -42,4 +43,17 @@ defmodule Mobilizon.Tombstone do
def find_tombstone(uri) do
Repo.get_by(__MODULE__, uri: uri)
end
@spec delete_actor_tombstones(String.t() | integer()) :: {integer(), nil}
def delete_actor_tombstones(actorId) do
__MODULE__
|> Ecto.Query.where(actor_id: ^actorId)
|> Repo.delete_all()
end
def delete_uri_tombstone(uri) do
__MODULE__
|> Ecto.Query.where(uri: ^uri)
|> Repo.delete_all()
end
end

View file

@ -7,7 +7,7 @@ defmodule Mobilizon.Web.Email.Group do
import Bamboo.Phoenix
import Mobilizon.Web.Gettext
alias Mobilizon.{Actors, Users}
alias Mobilizon.{Actors, Config, Users}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Users.User
alias Mobilizon.Web.{Email, Gettext}
@ -68,7 +68,7 @@ defmodule Mobilizon.Web.Email.Group do
|> assign(:locale, locale)
|> assign(:group, group)
|> assign(:subject, subject)
|> render(:group_removal)
|> render(:group_member_removal)
|> Email.Mailer.deliver_later()
:ok
@ -76,4 +76,83 @@ defmodule Mobilizon.Web.Email.Group do
end
# TODO : def send_confirmation_to_inviter()
@member_roles [:administrator, :moderator, :member]
def send_group_suspension_notification(%Member{actor: %Actor{user_id: nil}}), do: :ok
def send_group_suspension_notification(%Member{role: role}) when role not in @member_roles,
do: :ok
def send_group_suspension_notification(%Member{
actor: %Actor{user_id: user_id},
parent: %Actor{domain: nil} = group,
role: member_role
}) do
with %User{email: email, locale: locale} <- Users.get_user!(user_id) do
Gettext.put_locale(locale)
instance = Config.instance_name()
subject =
gettext(
"The group %{group} has been suspended on %{instance}",
group: group.name,
instance: instance
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:group, group)
|> assign(:role, member_role)
|> assign(:subject, subject)
|> assign(:instance, instance)
|> render(:group_suspension)
|> Email.Mailer.deliver_later()
:ok
end
end
def send_group_deletion_notification(%Member{actor: %Actor{user_id: nil}}, _author), do: :ok
def send_group_deletion_notification(%Member{role: role}, _author)
when role not in @member_roles,
do: :ok
def send_group_deletion_notification(
%Member{
actor: %Actor{user_id: user_id, id: actor_id},
parent: %Actor{domain: nil} = group,
role: member_role
},
%Actor{id: author_id} = author
) do
with %User{email: email, locale: locale} <- Users.get_user!(user_id),
{:member_not_author, true} <- {:member_not_author, author_id !== actor_id} do
Gettext.put_locale(locale)
instance = Config.instance_name()
subject =
gettext(
"The group %{group} has been deleted on %{instance}",
group: group.name,
instance: instance
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:group, group)
|> assign(:role, member_role)
|> assign(:subject, subject)
|> assign(:instance, instance)
|> assign(:author, author)
|> render(:group_deletion)
|> Email.Mailer.deliver_later()
:ok
else
# Skip if it's the author itself
{:member_not_author, _} ->
:ok
end
end
end

View file

@ -0,0 +1,51 @@
<!-- HERO -->
<tr>
<td bgcolor="#474467" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #3A384C; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "The group %{group} was deleted on %{instance}!", group: (@group.name || @group.preferred_username), instance: @instance %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#E6E4F4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted.",
author: Mobilizon.Actors.Actor.display_name_and_username(@author),
group: Mobilizon.Actors.Actor.display_name_and_username(@group) %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View file

@ -0,0 +1,5 @@
<%= gettext "The group %{group} was deleted on %{instance}!", group: (@group.name || @group.preferred_username), instance: @instance %>
==
<%= gettext "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted.",
author: Mobilizon.Actors.Actor.display_name_and_username(@author),
group: Mobilizon.Actors.Actor.display_name_and_username(@group) %>

View file

@ -0,0 +1,66 @@
<!-- HERO -->
<tr>
<td bgcolor="#474467" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #3A384C; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "The group %{group} has been suspended on %{instance}!", group: (@group.name || @group.preferred_username), instance: @instance %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#E6E4F4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group.", group_name: @group.name, group_address: if @group.domain, do: "@#{@group.preferred_username}@#{@group.domain}", else: "@#{@group.preferred_username}" %>
</p>
</td>
</tr>
<%= if is_nil(@group.domain) do %>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0">
<%= gettext "As this group was located on this instance, all of it's data has been irretrievably deleted." %>
</p>
</td>
</tr>
<% else %>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0">
<%= gettext "As this group was located on another instance, it will continue to work for other instances than this one." %>
</p>
</td>
</tr>
<% end %>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View file

@ -0,0 +1,8 @@
<%= gettext "The group %{group} has been suspended on %{instance}!", group: (@group.name || @group.preferred_username), instance: @instance %>
==
<%= gettext "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group.", group_name: @group.name, group_address: if @group.domain, do: "@#{@group.preferred_username}@#{@group.domain}", else: "@#{@group.preferred_username}" %>
<%= if is_nil(@group.domain) do %>
<%= gettext "As this group was located on this instance, all of it's data has been irretrievably deleted." %>
<% else %>
<%= gettext "As this group was located on another instance, it will continue to work for other instances than this one." %>
<% end %>

View file

@ -88,7 +88,7 @@ defmodule Mobilizon.Web.ActivityPub.ActorView do
end
defp fetch_collection(:discussions, actor, page) do
Discussions.find_discussions_for_actor(actor.id, page)
Discussions.find_discussions_for_actor(actor, page)
end
defp fetch_collection(:posts, actor, page) do

View file

@ -407,7 +407,7 @@ msgid "View the event on: %{link}"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1209,33 +1209,79 @@ msgid "Mobilizon is still under development, we will add new features along the
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -398,7 +398,7 @@ msgid "View the event on: %{link}"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1185,33 +1185,79 @@ msgid "Mobilizon is still under development, we will add new features along the
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -406,7 +406,7 @@ msgid "View the event on: %{link}"
msgstr "Vés a l'activitat actualitzada a %{link}"
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1205,33 +1205,79 @@ msgstr ""
"meitat del 2020%{b_end}."
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -398,7 +398,7 @@ msgid "View the event on: %{link}"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1185,33 +1185,79 @@ msgid "Mobilizon is still under development, we will add new features along the
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -411,7 +411,7 @@ msgid "View the event on: %{link}"
msgstr "Zeige die aktualisierte Veranstaltung unter: %{link}"
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1213,33 +1213,79 @@ msgstr ""
"in der ersten Hälfte von 2020 veröffentlicht wird</b>."
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -382,7 +382,7 @@ msgid "View the event on: %{link}"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1164,24 +1164,24 @@ msgid "Mobilizon is still under development, we will add new features along the
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
@ -1191,6 +1191,52 @@ msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:38
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -405,7 +405,7 @@ msgid "View the event on: %{link}"
msgstr "View the updated event on: %{link}"
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1187,33 +1187,79 @@ msgid "Mobilizon is still under development, we will add new features along the
msgstr "Mobilizon is under development, we will add new features to this site during regular updates, until the release of version 1 of the software in the first half of 2020."
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -10,16 +10,15 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: 2020-08-18 12:37+0000\n"
"PO-Revision-Date: 2020-08-27 12:12+0200\n"
"Last-Translator: Thomas Citharel <thomas.citharel@framasoft.org>\n"
"Language-Team: French <https://weblate.framasoft.org/projects/mobilizon/"
"backend/fr/>\n"
"Language-Team: French <https://weblate.framasoft.org/projects/mobilizon/backend/fr/>\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 4.1\n"
"X-Generator: Poedit 2.3\n"
#: lib/web/templates/email/password_reset.html.eex:48
msgid "If you didn't request this, please ignore this email. Your password won't change until you access the link below and create a new one."
@ -334,7 +333,7 @@ msgstr "Pour accepter cette invitation, rendez-vous dans vos groupes."
msgid "View the event on: %{link}"
msgstr "Voir l'événement mis à jour sur : %{link}"
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr "Vous avez été invité par %{inviter} à rejoindre le groupe %{group}"
@ -817,12 +816,8 @@ msgid "In the meantime, please consider this software as not (yet) fully functio
msgstr "D'ici là, veuillez considérer que le logiciel n'est pas (encore) fini. Retrouvez plus d'informations %{a_start}sur le blog de Framasoft%{a_end}."
#: lib/web/templates/email/email.text.eex:10
msgid ""
"In the meantime, please consider this software as not (yet) fully "
"functional. Read more on the Framasoft blog:"
msgstr ""
"D'ici là, veuillez considérer que le logiciel n'est pas (encore) fini. "
"Retrouvez plus d'informations sur le blog de Framasoft:"
msgid "In the meantime, please consider this software as not (yet) fully functional. Read more on the Framasoft blog:"
msgstr "D'ici là, veuillez considérer que le logiciel n'est pas (encore) fini. Retrouvez plus d'informations sur le blog de Framasoft:"
#: lib/web/templates/email/email.html.eex:147 lib/web/templates/email/email.text.eex:16
msgid "Learn more about Mobilizon here!"
@ -952,19 +947,19 @@ msgstr "<b>Veuillez ne pas l'utiliser pour un cas réel.</b>"
msgid "Mobilizon is still under development, we will add new features along the updates, until the release of <b>version 1 of the software in the fall of 2020</b>."
msgstr "Mobilizon est en cours de développement, nous y ajouterons de nouvelles fonctionnalités lors de mises à jour régulières, jusqu'à la publication de <b>la version 1 du logiciel à l'automne 2020</b>."
#: lib/web/templates/email/group_removal.html.eex:45 lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45 lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr "Si vous pensez qu'il s'agit d'une erreur, vous pouvez contacter les administrateurs du groupe afin qu'ils vous réintègrent."
#: lib/web/templates/email/group_removal.html.eex:13 lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13 lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr "Salut, et encore merci pour le poisson !"
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr "Vous avez été enlevé du groupe %{group}"
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr "Vous avez été enlevé du groupe %{group}. Vous ne serez plus en mesure d'accéder au contenu privé du groupe."
@ -972,6 +967,38 @@ msgstr "Vous avez été enlevé du groupe %{group}. Vous ne serez plus en mesure
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr "<b>%{inviter}</b> vient de vous inviter à rejoindre son groupe %{link_start}<b>%{group}</b>%{link_end}"
#: lib/web/templates/email/group_removal.html.eex:38
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr "Vous avez été enlevé du groupe %{link_start}<b>%{group}</b>%{link_end}. Vous ne serez plus en mesure d'accéder au contenu privé du groupe."
#: lib/web/templates/email/group_suspension.html.eex:54 lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr "Comme ce groupe était originaire d'une autre instance, il continuera à fonctionner pour d'autres instances que celle-ci."
#: lib/web/templates/email/group_suspension.html.eex:46 lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr "Comme ce groupe était originaire de cette instance, toutes ses données ont été irrémédiablement détruites."
#: lib/web/templates/email/group_deletion.html.eex:38 lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr "L'administrateur·ice %{author} a supprimé le groupe %{group}. Tous les événements, discussions, billets et todos du groupe ont été supprimés."
#: lib/web/templates/email/group_suspension.html.eex:13 lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr "Le groupe %{group} a été suspendu sur %{instance} !"
#: lib/web/templates/email/group_deletion.html.eex:13 lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr "Le groupe %{group} a été supprimé sur %{instance} !"
#: lib/web/templates/email/group_suspension.html.eex:38 lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr "L'équipe de modération de votre instance a décidé de suspendre %{group_name} (%{group_address}). Vous n'êtes désormais plus membre de ce groupe."
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr "Le groupe %{group} a été supprimé sur %{instance}"
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr "Le groupe %{group} a été suspendu sur %{instance}"

View file

@ -398,7 +398,7 @@ msgid "View the event on: %{link}"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1181,33 +1181,79 @@ msgid "Mobilizon is still under development, we will add new features along the
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -394,7 +394,7 @@ msgid "View the event on: %{link}"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1171,33 +1171,79 @@ msgid "Mobilizon is still under development, we will add new features along the
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -407,7 +407,7 @@ msgid "View the event on: %{link}"
msgstr "Bekijk het bijgewerkte evenement op: %{link}"
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1208,33 +1208,79 @@ msgstr ""
"beschikbaar is in de eerste helft van 2020</b>."
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -405,7 +405,7 @@ msgid "View the event on: %{link}"
msgstr "Veire leveniment actualizat sus : %{link}"
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr "%{inviter} vos a convidat a rejónher lo grop %{group}"
@ -1210,33 +1210,79 @@ msgstr ""
"la version 1 del logicial al primièr semèstre 2020</b>."
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -413,7 +413,7 @@ msgid "View the event on: %{link}"
msgstr "Zobacz zaktualizowane wydarzenie na %{link}"
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1221,33 +1221,79 @@ msgstr ""
"roku</b>."
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -396,7 +396,7 @@ msgid "View the event on: %{link}"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1178,33 +1178,79 @@ msgid "Mobilizon is still under development, we will add new features along the
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

View file

@ -444,7 +444,7 @@ msgid "View the event on: %{link}"
msgstr "Veja o evento atualizado em: %{link}"
#, elixir-format
#: lib/web/email/group.ex:28
#: lib/web/email/group.ex:32
msgid "You have been invited by %{inviter} to join group %{group}"
msgstr ""
@ -1300,33 +1300,79 @@ msgstr ""
"1 do aplicativo no primeiro semestre de 2020</b>."
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:45
#: lib/web/templates/email/group_removal.text.eex:5
#: lib/web/templates/email/group_member_removal.html.eex:45
#: lib/web/templates/email/group_member_removal.text.eex:5
msgid "If you feel this is an error, you may contact the group's administrators so that they can add you back."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.html.eex:13
#: lib/web/templates/email/group_removal.text.eex:1
#: lib/web/templates/email/group_member_removal.html.eex:13
#: lib/web/templates/email/group_member_removal.text.eex:1
msgid "So long, and thanks for the fish!"
msgstr ""
#, elixir-format
#: lib/web/email/group.ex:58
#: lib/web/email/group.ex:62
msgid "You have been removed from group %{group}"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_removal.text.eex:3
#: lib/web/templates/email/group_member_removal.text.eex:3
msgid "You have been removed from group %{group}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format, fuzzy
#, elixir-format
#: lib/web/templates/email/group_invite.html.eex:38
msgid "<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/templates/email/group_removal.html.eex:38
#, elixir-format
#: lib/web/templates/email/group_member_removal.html.eex:38
msgid "You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:54
#: lib/web/templates/email/group_suspension.text.eex:7
msgid "As this group was located on another instance, it will continue to work for other instances than this one."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:46
#: lib/web/templates/email/group_suspension.text.eex:5
msgid "As this group was located on this instance, all of it's data has been irretrievably deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:38
#: lib/web/templates/email/group_deletion.text.eex:3
msgid "The administrator %{author} deleted group %{group}. All of the group's events, discussions, posts and todos have been deleted."
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:13
#: lib/web/templates/email/group_suspension.text.eex:1
msgid "The group %{group} has been suspended on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_deletion.html.eex:13
#: lib/web/templates/email/group_deletion.text.eex:1
msgid "The group %{group} was deleted on %{instance}!"
msgstr ""
#, elixir-format
#: lib/web/templates/email/group_suspension.html.eex:38
#: lib/web/templates/email/group_suspension.text.eex:3
msgid "Your instance's moderation team has decided to suspend %{group_name} (%{group_address}). You are no longer a member of this group."
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:135
msgid "The group %{group} has been deleted on %{instance}"
msgstr ""
#, elixir-format, fuzzy
#: lib/web/email/group.ex:96
msgid "The group %{group} has been suspended on %{instance}"
msgstr ""

Some files were not shown because too many files have changed in this diff Show more